import { AudioCodec, MediaContainer, type Metadata, VideoCodec, VideoEncodingSetting } from "music-server-shared/types"; import { createRFC6381String, ladderMaxWidth, videoStreamInfo } from "music-server-shared/utils"; const KEYFRAME_INTERVAL_SECONDS = 5; //+frag_keyframe fragments the mp4 at video keyframes, of which an audio-only stream has none. without //an explicit duration the entire track ends up as a single moof/mdat pair const AUDIO_FRAGMENT_MICROSECONDS = 2_000_000; const AUDIO_ENCODERS: Record = { [AudioCodec.aac]: "aac", [AudioCodec.mp3]: "libmp3lame", [AudioCodec.opus]: "libopus", [AudioCodec.vorbis]: "libvorbis", [AudioCodec.flac]: "flac", }; //x264 and x265 share the same preset names const X26X_PRESETS: Record = { [VideoEncodingSetting.veryfast]: "veryfast", [VideoEncodingSetting.fast]: "fast", [VideoEncodingSetting.balanced]: "medium", [VideoEncodingSetting.quality]: "slow", }; const AV1_PRESETS: Record = { [VideoEncodingSetting.veryfast]: "8", [VideoEncodingSetting.fast]: "7", [VideoEncodingSetting.balanced]: "6", [VideoEncodingSetting.quality]: "5", }; //libvpx wants a deadline/cpu-used pair instead of a preset name const VP9_PRESETS: Record = { [VideoEncodingSetting.veryfast]: ["-deadline", "realtime", "-cpu-used", "5"], [VideoEncodingSetting.fast]: ["-deadline", "realtime", "-cpu-used", "4"], [VideoEncodingSetting.balanced]: ["-deadline", "good", "-cpu-used", "2"], [VideoEncodingSetting.quality]: ["-deadline", "good", "-cpu-used", "0"], }; function lookup(table: Record, key: string, what: string): T { const value = table[key]; if (value === undefined) throw new Error(`Unsupported ${what}: ${key}`); return value; } //pick the audio track matching the earliest entry of the caller's preference list. with no match at //all, prefer whichever track the file itself flags as default, and only then fall back to the first function selectAudioTrack(languages: string, tracks: NonNullable): number { const preferences = languages ? languages.toLocaleLowerCase().split(",") : []; let best = -1; let bestPreference = Number.POSITIVE_INFINITY; for (let i = 0; i < tracks.length; i++) { const language = tracks[i].language?.toLocaleLowerCase(); const preference = language ? preferences.indexOf(language) : -1; if (preference >= 0 && preference < bestPreference) { best = i; bestPreference = preference; if (preference === 0) break; } } if (best >= 0) return best; const defaultTrack = tracks.findIndex((track) => track.default); return defaultTrack >= 0 ? defaultTrack : 0; } function videoArgsFor( requestedVideoCodec: VideoCodec, requestedVideoEncodingSetting: VideoEncodingSetting, videoBitrate: number, ): string[] { const args: string[] = []; //TODO: support hardware encoding switch (requestedVideoCodec) { case VideoCodec.av1: args.push("-c:v", "libsvtav1", "-preset", lookup(AV1_PRESETS, requestedVideoEncodingSetting, "encoding setting")); break; case VideoCodec.h264: args.push("-c:v", "libx264", "-profile:v", "high"); args.push("-preset", lookup(X26X_PRESETS, requestedVideoEncodingSetting, "encoding setting")); break; case VideoCodec.h265: args.push("-c:v", "libx265", "-profile:v", "main"); args.push("-preset", lookup(X26X_PRESETS, requestedVideoEncodingSetting, "encoding setting")); args.push("-x265-params", "log-level=error"); break; case VideoCodec.vp9: args.push("-c:v", "libvpx-vp9", ...lookup(VP9_PRESETS, requestedVideoEncodingSetting, "encoding setting")); args.push("-row-mt", "1", "-tile-columns", "2"); break; default: throw new Error(`Unsupported video codec: ${requestedVideoCodec}`); } const maxWidth = ladderMaxWidth(videoBitrate); args.push( "-b:v", videoBitrate.toString(), //TODO: better bitrate selection //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` : ""}`, "-force_key_frames", `expr:gte(t,n_forced*${KEYFRAME_INTERVAL_SECONDS})`, "-map", "0:v:0", ); return args; } export async function convertWithFFmpeg( filePath: string, //undefined means no -b:a at all, which is the only setting that means anything for a lossless codec audioBitrate: number | undefined, videoBitrate: number, container: MediaContainer, requestedAudioCodec: AudioCodec, requestedVideoCodec: VideoCodec, requestedVideoEncodingSetting: VideoEncodingSetting, seekTo: number | undefined, languages: string, metadata: Metadata, ) { const video = videoStreamInfo(metadata, videoBitrate); const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec, video); const audioEncoder = lookup(AUDIO_ENCODERS, requestedAudioCodec, "audio codec"); const hasVideo = requestedVideoCodec !== VideoCodec.none; const containerArgs: string[] = []; if (container === MediaContainer.mp4) { //note: no +faststart - it relocates the moov atom, which needs a seekable output, and this //muxes to a pipe containerArgs.push("-avoid_negative_ts", "make_zero", "-movflags", "+frag_keyframe+empty_moov+default_base_moof"); if (!hasVideo) containerArgs.push("-frag_duration", AUDIO_FRAGMENT_MICROSECONDS.toString()); } const selectedAudioTrack = selectAudioTrack(languages, metadata.audioTracks || []); const args = [ "ffmpeg", "-nostats", "-loglevel", "error", //put seek first to do a fast keyframe seek ...(seekTo === undefined ? [] : ["-ss", seekTo.toString()]), "-i", filePath, ...containerArgs, ...(hasVideo ? videoArgsFor(requestedVideoCodec, requestedVideoEncodingSetting, videoBitrate) : ["-vn"]), "-map", `0:a:${selectedAudioTrack}?`, "-c:a", audioEncoder, ...(audioBitrate === undefined ? [] : ["-b:a", audioBitrate.toString()]), "-ac", "2", "-f", container, "-", ]; console.log(args.join(" ")); const cmd = Bun.spawn(args, { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); const stderrText = new Response(cmd.stderr).text().catch(() => ""); void cmd.exited .then(async (exitCode) => { if (exitCode !== 0 && exitCode !== null) { console.error("ffmpeg failed with status code", exitCode); console.error(await stderrText); } }) .catch(() => {}); return { cmd, mimeType, stderrText }; }