ffmpeg.ts
⎇
Raw
1import { AudioCodec, MediaContainer, type Metadata, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
2import { createRFC6381String } from "music-server-shared/utils";
3
4const KEYFRAME_INTERVAL_SECONDS = 5;
5
6//+frag_keyframe fragments the mp4 at video keyframes, of which an audio-only stream has none. without
7//an explicit duration the entire track ends up as a single moof/mdat pair
8const AUDIO_FRAGMENT_MICROSECONDS = 2_000_000;
9
10const AUDIO_ENCODERS: Record<AudioCodec, string> = {
11 [AudioCodec.aac]: "aac",
12 [AudioCodec.mp3]: "libmp3lame",
13 [AudioCodec.opus]: "libopus",
14 [AudioCodec.vorbis]: "libvorbis",
15};
16
17const RESOLUTION_LADDER: { upToBitrate: number; width: number }[] = [
18 { upToBitrate: 400_000, width: 640 },
19 { upToBitrate: 800_000, width: 854 },
20 { upToBitrate: 2_500_000, width: 1280 },
21 { upToBitrate: 5_000_000, width: 1920 },
22 { upToBitrate: 12_000_000, width: 2560 },
23];
24
25//x264 and x265 share the same preset names
26const X26X_PRESETS: Record<VideoEncodingSetting, string> = {
27 [VideoEncodingSetting.veryfast]: "veryfast",
28 [VideoEncodingSetting.fast]: "fast",
29 [VideoEncodingSetting.balanced]: "medium",
30 [VideoEncodingSetting.quality]: "slow",
31};
32const AV1_PRESETS: Record<VideoEncodingSetting, string> = {
33 [VideoEncodingSetting.veryfast]: "8",
34 [VideoEncodingSetting.fast]: "7",
35 [VideoEncodingSetting.balanced]: "6",
36 [VideoEncodingSetting.quality]: "5",
37};
38//libvpx wants a deadline/cpu-used pair instead of a preset name
39const VP9_PRESETS: Record<VideoEncodingSetting, string[]> = {
40 [VideoEncodingSetting.veryfast]: ["-deadline", "realtime", "-cpu-used", "5"],
41 [VideoEncodingSetting.fast]: ["-deadline", "realtime", "-cpu-used", "4"],
42 [VideoEncodingSetting.balanced]: ["-deadline", "good", "-cpu-used", "2"],
43 [VideoEncodingSetting.quality]: ["-deadline", "good", "-cpu-used", "0"],
44};
45
46function lookup<T>(table: Record<string, T>, key: string, what: string): T {
47 const value = table[key];
48 if (value === undefined) throw new Error(`Unsupported ${what}: ${key}`);
49 return value;
50}
51
52//pick the audio track matching the earliest entry of the caller's preference list. with no match at
53//all, prefer whichever track the file itself flags as default, and only then fall back to the first
54function selectAudioTrack(languages: string, tracks: NonNullable<Metadata["audioTracks"]>): number {
55 const preferences = languages ? languages.toLocaleLowerCase().split(",") : [];
56 let best = -1;
57 let bestPreference = Number.POSITIVE_INFINITY;
58 for (let i = 0; i < tracks.length; i++) {
59 const language = tracks[i].language?.toLocaleLowerCase();
60 const preference = language ? preferences.indexOf(language) : -1;
61 if (preference >= 0 && preference < bestPreference) {
62 best = i;
63 bestPreference = preference;
64 if (preference === 0) break;
65 }
66 }
67 if (best >= 0) return best;
68 const defaultTrack = tracks.findIndex((track) => track.default);
69 return defaultTrack >= 0 ? defaultTrack : 0;
70}
71
72function videoArgsFor(
73 requestedVideoCodec: VideoCodec,
74 requestedVideoEncodingSetting: VideoEncodingSetting,
75 videoBitrate: number,
76): string[] {
77 const args: string[] = [];
78 //TODO: support hardware encoding
79 switch (requestedVideoCodec) {
80 case VideoCodec.av1:
81 args.push("-c:v", "libsvtav1", "-preset", lookup(AV1_PRESETS, requestedVideoEncodingSetting, "encoding setting"));
82 break;
83 case VideoCodec.h264:
84 args.push("-c:v", "libx264", "-profile:v", "high");
85 args.push("-preset", lookup(X26X_PRESETS, requestedVideoEncodingSetting, "encoding setting"));
86 break;
87 case VideoCodec.h265:
88 args.push("-c:v", "libx265", "-profile:v", "main");
89 args.push("-preset", lookup(X26X_PRESETS, requestedVideoEncodingSetting, "encoding setting"));
90 args.push("-x265-params", "log-level=error");
91 break;
92 case VideoCodec.vp9:
93 args.push("-c:v", "libvpx-vp9", ...lookup(VP9_PRESETS, requestedVideoEncodingSetting, "encoding setting"));
94 args.push("-row-mt", "1", "-tile-columns", "2");
95 break;
96 default:
97 throw new Error(`Unsupported video codec: ${requestedVideoCodec}`);
98 }
99
100 const maxWidth = RESOLUTION_LADDER.find((rung) => videoBitrate <= rung.upToBitrate)?.width;
101 args.push(
102 "-b:v",
103 videoBitrate.toString(), //TODO: better bitrate selection
104 //make sure pixel format is browser compatible
105 "-pix_fmt",
106 "yuv420p",
107 //automatically deinterlace video to double fps if video is interlaced
108 //scale video down based on bitrate
109 "-vf",
110 `yadif=deint=1:mode=1${maxWidth ? `, scale='if(gt(iw,${maxWidth}),${maxWidth},iw)':-2` : ""}`,
111 "-force_key_frames",
112 `expr:gte(t,n_forced*${KEYFRAME_INTERVAL_SECONDS})`,
113 "-map",
114 "0:v:0",
115 );
116 return args;
117}
118
119export async function convertWithFFmpeg(
120 filePath: string,
121 audioBitrate: number,
122 videoBitrate: number,
123 container: MediaContainer,
124 requestedAudioCodec: AudioCodec,
125 requestedVideoCodec: VideoCodec,
126 requestedVideoEncodingSetting: VideoEncodingSetting,
127 seekTo: number | undefined,
128 languages: string,
129 metadata: Metadata,
130) {
131 const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec);
132 const audioEncoder = lookup(AUDIO_ENCODERS, requestedAudioCodec, "audio codec");
133 const hasVideo = requestedVideoCodec !== VideoCodec.none;
134
135 const containerArgs: string[] = [];
136 if (container === MediaContainer.mp4) {
137 //note: no +faststart - it relocates the moov atom, which needs a seekable output, and this
138 //muxes to a pipe
139 containerArgs.push("-avoid_negative_ts", "make_zero", "-movflags", "+frag_keyframe+empty_moov+default_base_moof");
140 if (!hasVideo) containerArgs.push("-frag_duration", AUDIO_FRAGMENT_MICROSECONDS.toString());
141 }
142
143 const selectedAudioTrack = selectAudioTrack(languages, metadata.audioTracks || []);
144
145 const args = [
146 "ffmpeg",
147 "-nostats",
148 "-loglevel",
149 "error",
150 //put seek first to do a fast keyframe seek
151 ...(seekTo === undefined ? [] : ["-ss", seekTo.toString()]),
152 "-i",
153 filePath,
154 ...containerArgs,
155 ...(hasVideo ? videoArgsFor(requestedVideoCodec, requestedVideoEncodingSetting, videoBitrate) : ["-vn"]),
156 "-map",
157 `0:a:${selectedAudioTrack}?`,
158 "-c:a",
159 audioEncoder,
160 "-b:a",
161 audioBitrate.toString(),
162 "-ac",
163 "2",
164 "-f",
165 container,
166 "-",
167 ];
168 console.log(args.join(" "));
169 const cmd = Bun.spawn(args, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
170 const stderrText = new Response(cmd.stderr).text().catch(() => "");
171 void cmd.exited
172 .then(async (exitCode) => {
173 if (exitCode !== 0 && exitCode !== null) {
174 console.error("ffmpeg failed with status code", exitCode);
175 console.error(await stderrText);
176 }
177 })
178 .catch(() => {});
179 return { cmd, mimeType, stderrText };
180}
181