ffmpeg.ts
⎇
Raw
1import { AudioCodec, MediaContainer, type Metadata, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
2import { createRFC6381String } from "music-server-shared/utils";
3import { readStream } from "./utils";
4
5function secondsToFfmpeg(seconds: number): string {
6 // Ensure seconds is non-negative
7 if (seconds < 0) {
8 throw new Error("Seconds must be a non-negative number");
9 }
10
11 // Extract hours, minutes, seconds, and milliseconds
12 const hours = Math.floor(seconds / 3600);
13 const minutes = Math.floor((seconds % 3600) / 60);
14 const secs = Math.floor(seconds % 60);
15
16 // Calculate the fractional part for milliseconds
17 const milliseconds = Math.floor((seconds - Math.floor(seconds)) * 1000);
18
19 // Pad numbers with leading zeros
20 const hoursStr = hours.toString().padStart(2, "0");
21 const minutesStr = minutes.toString().padStart(2, "0");
22 const secondsStr = secs.toString().padStart(2, "0");
23 const millisecondsStr = milliseconds.toString().padStart(3, "0");
24
25 return `${hoursStr}:${minutesStr}:${secondsStr}.${millisecondsStr}`;
26}
27
28export async function convertWithFFmpeg(
29 filePath: string,
30 audioBitrate: number,
31 videoBitrate: number,
32 container: MediaContainer,
33 requestedAudioCodec: AudioCodec,
34 requestedVideoCodec: VideoCodec,
35 requestedVideoEncodingSetting: VideoEncodingSetting,
36 seekTo: number | undefined,
37 languages: string,
38 metadata: Metadata,
39) {
40 const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec);
41
42 let audioCodec: string;
43 switch (requestedAudioCodec) {
44 case AudioCodec.aac:
45 audioCodec = "aac";
46 break;
47 case AudioCodec.mp3: {
48 audioCodec = "libmp3lame";
49 break;
50 }
51 case AudioCodec.opus: {
52 audioCodec = "libopus";
53 break;
54 }
55 case AudioCodec.vorbis: {
56 audioCodec = "libvorbis";
57 break;
58 }
59 default: {
60 throw new Error(`Unsupported audio codec: ${requestedAudioCodec}`);
61 }
62 }
63
64 let maxWidth: number | undefined;
65 if (videoBitrate <= 210000) {
66 maxWidth = 860;
67 } else if (videoBitrate < 420000) {
68 maxWidth = 1000;
69 } else if (videoBitrate < 1500000) {
70 maxWidth = 1280;
71 } else if (videoBitrate < 2000000) {
72 maxWidth = 1920;
73 } else if (videoBitrate < 10000000) {
74 maxWidth = 3840;
75 }
76
77 let containerSettings: string[] | undefined;
78 if (container === MediaContainer.mp4) {
79 containerSettings = [
80 "-avoid_negative_ts",
81 "make_zero",
82 "-movflags",
83 "+frag_keyframe+empty_moov+default_base_moof+faststart",
84 ];
85 }
86
87 const videoEncodingSettings: string[] = [];
88 let videoCodec: string | undefined;
89 let extraVideoSettings: string[] | undefined;
90 //only convert video if transcoding is requesting so
91 if (requestedVideoCodec !== VideoCodec.none) {
92 extraVideoSettings = [
93 //make sure pixel format is browser compatible
94 "-pix_fmt",
95 "yuv420p",
96 //automatically deinterlace video to double fps if video is interlaced
97 //scale video down based on bitrate
98 "-vf",
99 `yadif=deint=1:mode=1${maxWidth ? `, scale='if(gt(iw,${maxWidth}),${maxWidth},iw)':-2` : ""}`,
100 //one keyframe ~ 5 seconds for 30 fps video
101 //important because if this is too long, the frontend might remove frames relevant for current playback when discarding old data from the sourcebuffer
102 "-g",
103 "150",
104 ];
105
106 switch (requestedVideoCodec) {
107 case VideoCodec.av1:
108 videoCodec = "libsvtav1"; //TODO: support hardware encoding
109 switch (requestedVideoEncodingSetting) {
110 case VideoEncodingSetting.veryfast:
111 videoEncodingSettings.push("-preset", "8");
112 break;
113 case VideoEncodingSetting.fast:
114 videoEncodingSettings.push("-preset", "7");
115 break;
116 case VideoEncodingSetting.balanced:
117 videoEncodingSettings.push("-preset", "6");
118 break;
119 case VideoEncodingSetting.quality:
120 videoEncodingSettings.push("-preset", "5");
121 break;
122 default: {
123 throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
124 }
125 }
126 break;
127 case VideoCodec.h264:
128 videoCodec = "libx264"; //TODO: support hardware encoding
129 videoEncodingSettings.push("-profile", "high");
130 switch (requestedVideoEncodingSetting) {
131 case VideoEncodingSetting.veryfast:
132 videoEncodingSettings.push("-preset", "veryfast");
133 break;
134 case VideoEncodingSetting.fast:
135 videoEncodingSettings.push("-preset", "fast");
136 break;
137 case VideoEncodingSetting.balanced:
138 videoEncodingSettings.push("-preset", "medium");
139 break;
140 case VideoEncodingSetting.quality:
141 videoEncodingSettings.push("-preset", "slow");
142 break;
143 default: {
144 throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
145 }
146 }
147 break;
148 case VideoCodec.h265:
149 videoCodec = "libx265"; //TODO: support hardware encoding
150 videoEncodingSettings.push("-profile", "main");
151 switch (requestedVideoEncodingSetting) {
152 case VideoEncodingSetting.veryfast:
153 videoEncodingSettings.push("-preset", "veryfast");
154 break;
155 case VideoEncodingSetting.fast:
156 videoEncodingSettings.push("-preset", "fast");
157 break;
158 case VideoEncodingSetting.balanced:
159 videoEncodingSettings.push("-preset", "medium");
160 break;
161 case VideoEncodingSetting.quality:
162 videoEncodingSettings.push("-preset", "slow");
163 break;
164 default: {
165 throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
166 }
167 }
168 break;
169 case VideoCodec.vp9:
170 videoCodec = "libvpx-vp9";
171 switch (requestedVideoEncodingSetting) {
172 case VideoEncodingSetting.veryfast:
173 videoEncodingSettings.push("-deadline", "realtime", "-cpu-used", "5");
174 break;
175 case VideoEncodingSetting.fast:
176 videoEncodingSettings.push("-deadline", "realtime", "-cpu-used", "4");
177 break;
178 case VideoEncodingSetting.balanced:
179 videoEncodingSettings.push("-deadline", "good", "-cpu-used", "2");
180 break;
181 case VideoEncodingSetting.quality:
182 videoEncodingSettings.push("-deadline", "good", "-cpu-used", "2");
183 break;
184 default: {
185 throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
186 }
187 }
188 break;
189 }
190 }
191 const seekParams = seekTo === undefined ? [] : ["-ss", secondsToFfmpeg(seekTo)];
192
193 let selectedAudioTrack = 0; //0 as fallback if no language matches and no default is set
194 const languageArray = languages.split(",").map((s) => s.toLocaleLowerCase());
195 if (languageArray.length > 0) {
196 const tracks = metadata.audioTracks || [];
197 let highestPriorityMatch = Number.POSITIVE_INFINITY;
198 // Check each track for preferred languages in priority order
199 for (let i = 0; i < tracks.length; i++) {
200 const track = tracks[i];
201 // Store the first default track as fallback
202 if (track.default && selectedAudioTrack === 0) {
203 selectedAudioTrack = i;
204 }
205
206 // Check if this track's language matches any preference
207 const langIndex = track.language ? languageArray.indexOf(track.language.toLocaleLowerCase()) : -1;
208
209 // If this is a higher priority match than we've seen before, take it
210 if (langIndex >= 0 && langIndex < highestPriorityMatch) {
211 selectedAudioTrack = i;
212 highestPriorityMatch = langIndex;
213
214 // Early exit if we already found the most preferred language (first in array)
215 if (highestPriorityMatch === 0) break;
216 }
217 }
218 }
219
220 const args = ["ffmpeg"]
221 .concat(seekParams) //put seek first to do a fast keyframe seek
222 .concat(["-i", filePath])
223 .concat(containerSettings || [])
224 .concat(
225 videoCodec
226 ? ["-c:v", videoCodec, "-b:v", videoBitrate.toString()] //TODO: better bitrate selection
227 .concat(extraVideoSettings || [])
228 .concat(videoEncodingSettings)
229 .concat(["-map", "0:v:0"])
230 : ["-vn"],
231 )
232 .concat(["-map", `0:a:${selectedAudioTrack.toString()}?`])
233 .concat(["-c:a", audioCodec, "-b:a", audioBitrate.toString(), "-ac", "2"])
234 .concat(["-f", container, "-"]);
235 console.log(args.join(" "));
236 const cmd = Bun.spawn(args, {
237 stdin: "ignore",
238 stdout: "pipe",
239 stderr: "pipe",
240 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
241 if (exitCode !== 0 && exitCode !== null) {
242 console.error("ffmpeg failed with status code", exitCode);
243 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
244 }
245 },
246 });
247 return { cmd, mimeType };
248}
249