ffmpeg.ts
⎇
Raw
1import { AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
2import { createRFC6381String } from "music-server-shared/utils";
3import { probeFile, 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 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) {
38 const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec);
39
40 let audioCodec = undefined;
41 switch (requestedAudioCodec) {
42 case AudioCodec.aac:
43 audioCodec = "aac";
44 break;
45 case AudioCodec.mp3: {
46 audioCodec = "libmp3lame";
47 break;
48 }
49 case AudioCodec.opus: {
50 audioCodec = "libopus";
51 break;
52 }
53 case AudioCodec.vorbis: {
54 audioCodec = "libvorbis";
55 break;
56 }
57 default: {
58 throw new Error(`Unsupported audio codec: ${requestedAudioCodec}`);
59 }
60 }
61
62 let maxWidth = undefined;
63 if (videoBitrate <= 210000) {
64 maxWidth = 860;
65 } else if (videoBitrate < 420000) {
66 maxWidth = 1000;
67 } else if (videoBitrate < 1500000) {
68 maxWidth = 1280;
69 } else if (videoBitrate < 2000000) {
70 maxWidth = 1920;
71 } else if (videoBitrate < 10000000) {
72 maxWidth = 3840;
73 }
74
75 let containerSettings = undefined;
76 if (container === MediaContainer.mp4) {
77 containerSettings = [
78 "-avoid_negative_ts",
79 "make_zero",
80 "-movflags",
81 "+frag_keyframe+empty_moov+default_base_moof+faststart",
82 "-g",
83 "300",
84 ];
85 }
86
87 const videoEncodingSettings: string[] = [];
88 let videoCodec = undefined;
89 let extraVideoSettings = undefined;
90 //only convert video if transcoding is requesting so
91 if (videoCodec !== 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 "-g",
101 "600",
102 ];
103
104 switch (requestedVideoCodec) {
105 case VideoCodec.av1:
106 videoCodec = "libsvtav1"; //TODO: support hardware encoding
107 switch (requestedVideoEncodingSetting) {
108 case VideoEncodingSetting.veryfast:
109 videoEncodingSettings.push("-preset", "8");
110 break;
111 case VideoEncodingSetting.fast:
112 videoEncodingSettings.concat("-preset", "7");
113 break;
114 case VideoEncodingSetting.balanced:
115 videoEncodingSettings.concat("-preset", "6");
116 break;
117 case VideoEncodingSetting.quality:
118 videoEncodingSettings.concat("-preset", "5");
119 break;
120 default: {
121 throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
122 }
123 }
124 break;
125 case VideoCodec.h264:
126 videoCodec = "libx264"; //TODO: support hardware encoding
127 videoEncodingSettings.concat(["-profile", "high"]);
128 switch (requestedVideoEncodingSetting) {
129 case VideoEncodingSetting.veryfast:
130 videoEncodingSettings.push("-preset", "veryfast");
131 break;
132 case VideoEncodingSetting.fast:
133 videoEncodingSettings.concat("-preset", "fast");
134 break;
135 case VideoEncodingSetting.balanced:
136 videoEncodingSettings.concat("-preset", "medium");
137 break;
138 case VideoEncodingSetting.quality:
139 videoEncodingSettings.concat("-preset", "slow");
140 break;
141 default: {
142 throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
143 }
144 }
145 break;
146 case VideoCodec.h265:
147 videoCodec = "libx265"; //TODO: support hardware encoding
148 videoEncodingSettings.concat(["-profile", "main"]);
149 switch (requestedVideoEncodingSetting) {
150 case VideoEncodingSetting.veryfast:
151 videoEncodingSettings.push("-preset", "veryfast");
152 break;
153 case VideoEncodingSetting.fast:
154 videoEncodingSettings.concat("-preset", "fast");
155 break;
156 case VideoEncodingSetting.balanced:
157 videoEncodingSettings.concat("-preset", "medium");
158 break;
159 case VideoEncodingSetting.quality:
160 videoEncodingSettings.concat("-preset", "slow");
161 break;
162 default: {
163 throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
164 }
165 }
166 break;
167 case VideoCodec.vp9:
168 videoCodec = "libvpx-vp9";
169 switch (requestedVideoEncodingSetting) {
170 case VideoEncodingSetting.veryfast:
171 videoEncodingSettings.concat("-deadline", "realtime", "-cpu-used", "5");
172 break;
173 case VideoEncodingSetting.fast:
174 videoEncodingSettings.concat("-deadline", "realtime", "-cpu-used", "4");
175 break;
176 case VideoEncodingSetting.balanced:
177 videoEncodingSettings.concat("-deadline", "good", "-cpu-used", "2");
178 break;
179 case VideoEncodingSetting.quality:
180 videoEncodingSettings.concat("-deadline", "good", "-cpu-used", "2");
181 break;
182 default: {
183 throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
184 }
185 }
186 break;
187 }
188 }
189 const seekParams = seekTo === undefined ? [] : ["-ss", secondsToFfmpeg(seekTo)];
190 const args = ["ffmpeg"]
191 .concat(seekParams) //put seek first to do a fast keyframe seek
192 .concat(["-i", filePath])
193 .concat(containerSettings || [])
194 .concat(
195 videoCodec
196 ? ["-c:v", videoCodec, "-b:v", videoBitrate.toString()] //TODO: better bitrate selection
197 .concat(extraVideoSettings || [])
198 .concat(videoEncodingSettings)
199 : ["-vn"],
200 )
201 .concat(videoEncodingSettings)
202 .concat(["-c:a", audioCodec, "-b:a", audioBitrate.toString()])
203 .concat(["-f", container, "-"]);
204 const cmd = Bun.spawn(args, {
205 stdin: "ignore",
206 stdout: "pipe",
207 stderr: "pipe",
208 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
209 if (exitCode !== 0 && exitCode !== null) {
210 console.error("ffmpeg failed with status code", exitCode);
211 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
212 }
213 },
214 });
215 return { cmd, mimeType };
216}
217