import { AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types"; import { createRFC6381String } from "music-server-shared/utils"; import { probeFile, readStream } from "./utils"; function secondsToFfmpeg(seconds: number): string { // Ensure seconds is non-negative if (seconds < 0) { throw new Error("Seconds must be a non-negative number"); } // Extract hours, minutes, seconds, and milliseconds const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); // Calculate the fractional part for milliseconds const milliseconds = Math.floor((seconds - Math.floor(seconds)) * 1000); // Pad numbers with leading zeros const hoursStr = hours.toString().padStart(2, "0"); const minutesStr = minutes.toString().padStart(2, "0"); const secondsStr = secs.toString().padStart(2, "0"); const millisecondsStr = milliseconds.toString().padStart(3, "0"); return `${hoursStr}:${minutesStr}:${secondsStr}.${millisecondsStr}`; } export function convertWithFFmpeg( filePath: string, audioBitrate: number, videoBitrate: number, container: MediaContainer, requestedAudioCodec: AudioCodec, requestedVideoCodec: VideoCodec, requestedVideoEncodingSetting: VideoEncodingSetting, seekTo: number | undefined, ) { const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec); let audioCodec = undefined; switch (requestedAudioCodec) { case AudioCodec.aac: audioCodec = "aac"; break; case AudioCodec.mp3: { audioCodec = "libmp3lame"; break; } case AudioCodec.opus: { audioCodec = "libopus"; break; } case AudioCodec.vorbis: { audioCodec = "libvorbis"; break; } default: { throw new Error(`Unsupported audio codec: ${requestedAudioCodec}`); } } let maxWidth = undefined; if (videoBitrate <= 210000) { maxWidth = 860; } else if (videoBitrate < 420000) { maxWidth = 1000; } else if (videoBitrate < 1500000) { maxWidth = 1280; } else if (videoBitrate < 2000000) { maxWidth = 1920; } else if (videoBitrate < 10000000) { maxWidth = 3840; } let containerSettings = undefined; if (container === MediaContainer.mp4) { containerSettings = [ "-avoid_negative_ts", "make_zero", "-movflags", "+frag_keyframe+empty_moov+default_base_moof+faststart", "-g", "300", ]; } const videoEncodingSettings: string[] = []; let videoCodec = undefined; let extraVideoSettings = undefined; //only convert video if transcoding is requesting so if (videoCodec !== VideoCodec.none) { extraVideoSettings = [ //make sure pixel format is browser compatible "-pix_fmt", "yuv420p", //automatically deinterlace video to double fps if video is interlaced //scale video down based on bitrate "-vf", `yadif=deint=1:mode=1${maxWidth ? `, scale='if(gt(iw,${maxWidth}),${maxWidth},iw)':-2` : ""}`, "-g", "600", ]; switch (requestedVideoCodec) { case VideoCodec.av1: videoCodec = "libsvtav1"; //TODO: support hardware encoding switch (requestedVideoEncodingSetting) { case VideoEncodingSetting.veryfast: videoEncodingSettings.push("-preset", "8"); break; case VideoEncodingSetting.fast: videoEncodingSettings.concat("-preset", "7"); break; case VideoEncodingSetting.balanced: videoEncodingSettings.concat("-preset", "6"); break; case VideoEncodingSetting.quality: videoEncodingSettings.concat("-preset", "5"); break; default: { throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`); } } break; case VideoCodec.h264: videoCodec = "libx264"; //TODO: support hardware encoding videoEncodingSettings.concat(["-profile", "high"]); switch (requestedVideoEncodingSetting) { case VideoEncodingSetting.veryfast: videoEncodingSettings.push("-preset", "veryfast"); break; case VideoEncodingSetting.fast: videoEncodingSettings.concat("-preset", "fast"); break; case VideoEncodingSetting.balanced: videoEncodingSettings.concat("-preset", "medium"); break; case VideoEncodingSetting.quality: videoEncodingSettings.concat("-preset", "slow"); break; default: { throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`); } } break; case VideoCodec.h265: videoCodec = "libx265"; //TODO: support hardware encoding videoEncodingSettings.concat(["-profile", "main"]); switch (requestedVideoEncodingSetting) { case VideoEncodingSetting.veryfast: videoEncodingSettings.push("-preset", "veryfast"); break; case VideoEncodingSetting.fast: videoEncodingSettings.concat("-preset", "fast"); break; case VideoEncodingSetting.balanced: videoEncodingSettings.concat("-preset", "medium"); break; case VideoEncodingSetting.quality: videoEncodingSettings.concat("-preset", "slow"); break; default: { throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`); } } break; case VideoCodec.vp9: videoCodec = "libvpx-vp9"; switch (requestedVideoEncodingSetting) { case VideoEncodingSetting.veryfast: videoEncodingSettings.concat("-deadline", "realtime", "-cpu-used", "5"); break; case VideoEncodingSetting.fast: videoEncodingSettings.concat("-deadline", "realtime", "-cpu-used", "4"); break; case VideoEncodingSetting.balanced: videoEncodingSettings.concat("-deadline", "good", "-cpu-used", "2"); break; case VideoEncodingSetting.quality: videoEncodingSettings.concat("-deadline", "good", "-cpu-used", "2"); break; default: { throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`); } } break; } } const seekParams = seekTo === undefined ? [] : ["-ss", secondsToFfmpeg(seekTo)]; const args = ["ffmpeg"] .concat(seekParams) //put seek first to do a fast keyframe seek .concat(["-i", filePath]) .concat(containerSettings || []) .concat( videoCodec ? ["-c:v", videoCodec, "-b:v", videoBitrate.toString()] //TODO: better bitrate selection .concat(extraVideoSettings || []) .concat(videoEncodingSettings) : ["-vn"], ) .concat(videoEncodingSettings) .concat(["-c:a", audioCodec, "-b:a", audioBitrate.toString()]) .concat(["-f", container, "-"]); const cmd = Bun.spawn(args, { stdin: "ignore", stdout: "pipe", stderr: "pipe", onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => { if (exitCode !== 0 && exitCode !== null) { console.error("ffmpeg failed with status code", exitCode); console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream))); } }, }); return { cmd, mimeType }; }