ffmpeg.ts
⎇
Raw
1import { AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
2import { createRFC6381String } from "music-server-shared/utils";
3import { analyzeWithMediainfo, 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) {
39 const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec);
40
41 let audioCodec = undefined;
42 switch (requestedAudioCodec) {
43 case AudioCodec.aac:
44 audioCodec = "aac";
45 break;
46 case AudioCodec.mp3: {
47 audioCodec = "libmp3lame";
48 break;
49 }
50 case AudioCodec.opus: {
51 audioCodec = "libopus";
52 break;
53 }
54 case AudioCodec.vorbis: {
55 audioCodec = "libvorbis";
56 break;
57 }
58 default: {
59 throw new Error(`Unsupported audio codec: ${requestedAudioCodec}`);
60 }
61 }
62
63 let maxWidth = undefined;
64 if (videoBitrate <= 210000) {
65 maxWidth = 860;
66 } else if (videoBitrate < 420000) {
67 maxWidth = 1000;
68 } else if (videoBitrate < 1500000) {
69 maxWidth = 1280;
70 } else if (videoBitrate < 2000000) {
71 maxWidth = 1920;
72 } else if (videoBitrate < 10000000) {
73 maxWidth = 3840;
74 }
75
76 let containerSettings = undefined;
77 if (container === MediaContainer.mp4) {
78 containerSettings = [
79 "-avoid_negative_ts",
80 "make_zero",
81 "-movflags",
82 "+frag_keyframe+empty_moov+default_base_moof+faststart",
83 ];
84 }
85
86 const videoEncodingSettings: string[] = [];
87 let videoCodec = undefined;
88 let extraVideoSettings = undefined;
89 //only convert video if transcoding is requesting so
90 if (videoCodec !== VideoCodec.none) {
91 extraVideoSettings = [
92 //make sure pixel format is browser compatible
93 "-pix_fmt",
94 "yuv420p",
95 //automatically deinterlace video to double fps if video is interlaced
96 //scale video down based on bitrate
97 "-vf",
98 `yadif=deint=1:mode=1${maxWidth ? `, scale='if(gt(iw,${maxWidth}),${maxWidth},iw)':-2` : ""}`,
99 //one keyframe ~ 5 seconds for 30 fps video
100 //important because if this is too long, the frontend might remove frames relevant for current playback when discarding old data from the sourcebuffer
101 "-g",
102 "150",
103 ];
104
105 switch (requestedVideoCodec) {
106 case VideoCodec.av1:
107 videoCodec = "libsvtav1"; //TODO: support hardware encoding
108 switch (requestedVideoEncodingSetting) {
109 case VideoEncodingSetting.veryfast:
110 videoEncodingSettings.push("-preset", "8");
111 break;
112 case VideoEncodingSetting.fast:
113 videoEncodingSettings.concat("-preset", "7");
114 break;
115 case VideoEncodingSetting.balanced:
116 videoEncodingSettings.concat("-preset", "6");
117 break;
118 case VideoEncodingSetting.quality:
119 videoEncodingSettings.concat("-preset", "5");
120 break;
121 default: {
122 throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
123 }
124 }
125 break;
126 case VideoCodec.h264:
127 videoCodec = "libx264"; //TODO: support hardware encoding
128 videoEncodingSettings.concat(["-profile", "high"]);
129 switch (requestedVideoEncodingSetting) {
130 case VideoEncodingSetting.veryfast:
131 videoEncodingSettings.push("-preset", "veryfast");
132 break;
133 case VideoEncodingSetting.fast:
134 videoEncodingSettings.concat("-preset", "fast");
135 break;
136 case VideoEncodingSetting.balanced:
137 videoEncodingSettings.concat("-preset", "medium");
138 break;
139 case VideoEncodingSetting.quality:
140 videoEncodingSettings.concat("-preset", "slow");
141 break;
142 default: {
143 throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
144 }
145 }
146 break;
147 case VideoCodec.h265:
148 videoCodec = "libx265"; //TODO: support hardware encoding
149 videoEncodingSettings.concat(["-profile", "main"]);
150 switch (requestedVideoEncodingSetting) {
151 case VideoEncodingSetting.veryfast:
152 videoEncodingSettings.push("-preset", "veryfast");
153 break;
154 case VideoEncodingSetting.fast:
155 videoEncodingSettings.concat("-preset", "fast");
156 break;
157 case VideoEncodingSetting.balanced:
158 videoEncodingSettings.concat("-preset", "medium");
159 break;
160 case VideoEncodingSetting.quality:
161 videoEncodingSettings.concat("-preset", "slow");
162 break;
163 default: {
164 throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
165 }
166 }
167 break;
168 case VideoCodec.vp9:
169 videoCodec = "libvpx-vp9";
170 switch (requestedVideoEncodingSetting) {
171 case VideoEncodingSetting.veryfast:
172 videoEncodingSettings.concat("-deadline", "realtime", "-cpu-used", "5");
173 break;
174 case VideoEncodingSetting.fast:
175 videoEncodingSettings.concat("-deadline", "realtime", "-cpu-used", "4");
176 break;
177 case VideoEncodingSetting.balanced:
178 videoEncodingSettings.concat("-deadline", "good", "-cpu-used", "2");
179 break;
180 case VideoEncodingSetting.quality:
181 videoEncodingSettings.concat("-deadline", "good", "-cpu-used", "2");
182 break;
183 default: {
184 throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
185 }
186 }
187 break;
188 }
189 }
190 const seekParams = seekTo === undefined ? [] : ["-ss", secondsToFfmpeg(seekTo)];
191
192 const info = await analyzeWithMediainfo(filePath); //analyze video file with mediainfo to get video duration and other metadata
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 let index = 0;
197 for (const track of info.media?.track || []) {
198 if (track["@type"] === "Audio") {
199 if (track.Default === "Yes") selectedAudioTrack = index; //use the default track if no language matches
200 if (track.Language && languageArray.includes(track.Language)) {
201 selectedAudioTrack = index;
202 //use the first matching track
203 break;
204 }
205 index++;
206 }
207 }
208 }
209
210 const args = ["ffmpeg"]
211 .concat(seekParams) //put seek first to do a fast keyframe seek
212 .concat(["-i", filePath])
213 .concat(containerSettings || [])
214 .concat(
215 videoCodec
216 ? ["-c:v", videoCodec, "-b:v", videoBitrate.toString()] //TODO: better bitrate selection
217 .concat(extraVideoSettings || [])
218 .concat(videoEncodingSettings)
219 .concat(["-map", "0:v:0"])
220 : ["-vn"],
221 )
222 .concat(videoEncodingSettings)
223 .concat(["-map", `0:a:${selectedAudioTrack.toString()}?`])
224 .concat(["-c:a", audioCodec, "-b:a", audioBitrate.toString(), "-ac", "2"])
225 .concat(["-f", container, "-"]);
226 console.log(args.join(" "));
227 const cmd = Bun.spawn(args, {
228 stdin: "ignore",
229 stdout: "pipe",
230 stderr: "pipe",
231 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
232 if (exitCode !== 0 && exitCode !== null) {
233 console.error("ffmpeg failed with status code", exitCode);
234 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
235 }
236 },
237 });
238 return { cmd, mimeType };
239}
240