1interface Av1Preset {
2 id: string;
3 preset: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
4 mbitMax?: number;
5 crf: number;
6 maxHeight?: number;
7 depth?: 8 | 10;
8}
9interface OpusPreset {
10 id: string;
11 audioKbit: number;
12}
13interface Dimensions {
14 width: number;
15 height: number;
16}
17
18export const videoFormats: Av1Preset[] = [
19 {
20 id: "au", // AV1 Ultra-High
21 preset: 1,
22 crf: 28,
23 depth: 10,
24 mbitMax: 30,
25 },
26 {
27 id: "ah", // AV1 High
28 preset: 2,
29 mbitMax: 5,
30 crf: 35,
31 depth: 10,
32 },
33 {
34 id: "am", // AV1 Medium
35 preset: 2,
36 mbitMax: 2,
37 crf: 40,
38 maxHeight: 900,
39 },
40 {
41 id: "al", // AV1 Low
42 preset: 2,
43 mbitMax: 1.25,
44 crf: 40,
45 maxHeight: 600,
46 },
47 {
48 id: "ad", // AV1 Data-saving
49 preset: 1,
50 mbitMax: 0.5,
51 crf: 10, // highest quality clamping to 0.5
52 maxHeight: 360,
53 },
54];
55export const audioFormats: OpusPreset[] = [
56 { id: "h", audioKbit: 192 },
57 { id: "m", audioKbit: 128 },
58 { id: "d", audioKbit: 64 },
59];
60
61export const imageSizes = [64, 128, 256, 512, 1024, 2048];
62export const imagePresets = [
63 {
64 ext: ".webp",
65 args: [
66 "-lossless",
67 "0",
68 "-compression_level",
69 "6",
70 "-quality",
71 "95",
72 "-method",
73 "6",
74 ],
75 },
76 {
77 ext: ".jxl",
78 args: [
79 "-c:v",
80 "libjxl",
81 "-distance",
82 "0.8",
83 "-effort",
84 "9",
85 "-update",
86 "1",
87 "-frames:v",
88 "1",
89 ],
90 },
91];
92
93export async function getVideoInputArgs(path: Path): Promise<InputArgs> {
94 const config = await path.parent!.join("." + path.replaceExt(".json").base)
95 .readIfExists<any>("json");
96 if (config) {
97 if (config.encoder && typeof config.encoder.videoSrc === "string") {
98 const { videoSrc, audioSrc, rate } = config.encoder;
99 return {
100 both: [
101 ...(rate ? ["-r", String(rate)] : []),
102 "-i",
103 String(videoSrc),
104 ...audioSrc ? ["-i", path.toString()] : [],
105 ],
106 video: [
107 ...(rate ? ["-r", String(rate)] : []),
108 "-i",
109 String(videoSrc),
110 ],
111 audio: audioSrc
112 ? ["-i", String(audioSrc)]
113 : (await ffmpeg.probeStreams(path)).audio
114 ? ["-i", path.toString()]
115 : null,
116 };
117 }
118 }
119 const streams = await ffmpeg.probeStreams(path);
120 return {
121 both: ["-i", path.toString()],
122 video: streams.video ? ["-i", path.toString()] : null,
123 audio: streams.audio ? ["-i", path.toString()] : null,
124 };
125}
126
127export interface InputArgs {
128 both: string[];
129 video: string[] | null;
130 audio: string[] | null;
131}
132
133export const opusFileName = "tmp.opus.webm";
134export const av1FileName = "tmp.av1.mp4";
135
136function gcd(a: number, b: number): number {
137 let x = Math.abs(a);
138 let y = Math.abs(b);
139 while (y !== 0) {
140 [x, y] = [y, x % y];
141 }
142 return x || 1;
143}
144
145function getMaxHeightCaps(source: Dimensions): number[] {
146 return [
147 source.height,
148 ...videoFormats.flatMap((preset) =>
149 preset.maxHeight != null
150 ? [Math.min(preset.maxHeight, source.height)]
151 : []
152 ),
153 ];
154}
155
156function getExactSquarePixelDimensions(
157 source: Dimensions,
158 aspect: Dimensions,
159 maxHeight?: number,
160): Dimensions | null {
161 const heightCap = Math.min(maxHeight ?? source.height, source.height);
162 const maxScale = Math.floor(Math.min(
163 source.width / aspect.width,
164 heightCap / aspect.height,
165 ));
166 const scale = maxScale % 2 === 0 ? maxScale : maxScale - 1;
167
168 if (scale < 2) return null;
169
170 return {
171 width: aspect.width * scale,
172 height: aspect.height * scale,
173 };
174}
175
176function getCanonicalSquarePixelAspectRatio(source: Dimensions): Dimensions {
177 const ratio = source.width / source.height;
178 const caps = getMaxHeightCaps(source);
179 const maxDenominator = Math.max(1, Math.floor(Math.min(...caps) / 2));
180 const exactErrorThreshold = 0.0005;
181 const seen = new Set<string>();
182
183 let bestWithinThreshold: {
184 aspect: Dimensions;
185 error: number;
186 retainedArea: number;
187 } | null = null;
188 let bestFallback: {
189 aspect: Dimensions;
190 error: number;
191 retainedArea: number;
192 } | null = null;
193
194 for (let denominator = 1; denominator <= maxDenominator; denominator++) {
195 const numerator = Math.max(1, Math.round(ratio * denominator));
196 const divisor = gcd(numerator, denominator);
197 const aspect = {
198 width: numerator / divisor,
199 height: denominator / divisor,
200 };
201 const key = `${aspect.width}:${aspect.height}`;
202 if (seen.has(key)) continue;
203 seen.add(key);
204
205 const dimensions = caps.map((cap) => getExactSquarePixelDimensions(source, aspect, cap));
206 if (dimensions.some((value) => value == null)) continue;
207
208 const retainedArea = dimensions
209 .map((value) => value!.width * value!.height)
210 .reduce((sum, area) => sum + area, 0);
211 const error = Math.abs((aspect.width / aspect.height) - ratio) / ratio;
212 const candidate = { aspect, error, retainedArea };
213
214 if (error <= exactErrorThreshold) {
215 if (
216 !bestWithinThreshold
217 || retainedArea > bestWithinThreshold.retainedArea
218 || (
219 retainedArea === bestWithinThreshold.retainedArea
220 && error < bestWithinThreshold.error
221 )
222 ) {
223 bestWithinThreshold = candidate;
224 }
225 continue;
226 }
227
228 if (
229 !bestFallback
230 || error < bestFallback.error
231 || (error === bestFallback.error && retainedArea > bestFallback.retainedArea)
232 ) {
233 bestFallback = candidate;
234 }
235 }
236
237 return bestWithinThreshold?.aspect ?? bestFallback?.aspect ?? source;
238}
239
240export function getConstantAspectScaleFilter(
241 source: Dimensions,
242 maxHeight?: number,
243): string | null {
244 const aspect = getCanonicalSquarePixelAspectRatio(source);
245 const dimensions = getExactSquarePixelDimensions(source, aspect, maxHeight);
246 if (!dimensions) return null;
247 if (
248 dimensions.width === source.width
249 && dimensions.height === source.height
250 ) return null;
251 return `scale=${dimensions.width}:${dimensions.height}`;
252}
253
254export function getAv1VideoArgs(
255 av1: Av1Preset,
256 source: Dimensions,
257 videoInputArgs: string[],
258 outDir: Path,
259) {
260 const { preset, mbitMax, crf, maxHeight, depth } = av1;
261 const cmd = [...videoInputArgs];
262
263 cmd.push("-c:v", "libsvtav1");
264 cmd.push(
265 "-svtav1-params",
266 [
267 `preset=${preset}`,
268 "keyint=2s",
269 depth && `input-depth=${depth}`, // EncoderBitDepth
270 `crf=${crf}`, // ConstantRateFactor
271 mbitMax && `mbr=${mbitMax}m`, // MaxBitRate
272 "tune=1", // Tune, PSNR
273 "enable-overlays=1", // EnableOverlays
274 "fast-decode=1", // FastDecode
275 "scm=2", // ScreenContentMode, adaptive
276 ].filter(Boolean).join(":"),
277 );
278 const filter = getConstantAspectScaleFilter(source, maxHeight);
279 if (filter) {
280 cmd.push("-vf", filter);
281 }
282
283 cmd.push("-y");
284 cmd.push(outDir.join(av1FileName).toString());
285
286 return cmd;
287}
288
289export function getOpusAudioArgs(
290 opus: OpusPreset,
291 audioInputArgs: string[],
292 outDir: Path,
293) {
294 const { audioKbit } = opus;
295 const cmd = [...audioInputArgs];
296
297 cmd.push("-vn");
298
299 cmd.push("-c:a", "libopus");
300 cmd.push("-b:a", audioKbit + "k");
301
302 cmd.push("-y");
303 cmd.push(outDir.join(opusFileName).toString());
304
305 return cmd;
306}
307
308export function getMpegDashArgs(
309 videos: Path[],
310 audios: Path[],
311 outDir: Path,
312) {
313 const cmd = [];
314
315 // inputs
316 videos.forEach((v) => cmd.push("-i", v.toString()));
317 audios.forEach((a) => cmd.push("-i", a.toString()));
318
319 // map streams
320 videos.forEach((_, i) => cmd.push("-map", `${i}:0`));
321 audios.forEach((_, i) => cmd.push("-map", `${videos.length + i}:0`));
322
323 cmd.push("-c", "copy");
324 cmd.push("-f", "dash");
325 cmd.push("-seg_duration", "4");
326 cmd.push("-init_seg_name", "d/$RepresentationID$.init.$ext$");
327 cmd.push("-media_seg_name", "d/$RepresentationID$.$Number$.$ext$");
328 cmd.push("-use_timeline", "1");
329 cmd.push("-use_template", "1");
330
331 let adaptationSets = videos.length > 0
332 ? `id=0,streams=${videos.map((_, i) => i).join(",")}`
333 : "";
334 if (audios.length > 0) {
335 const audioId = videos.length > 0 ? 1 : 0;
336 const audioIndices = audios.map((_, i) => videos.length + i).join(",");
337 adaptationSets += `${adaptationSets ? " " : ""}id=${audioId},streams=${audioIndices}`;
338 }
339
340 cmd.push("-adaptation_sets", adaptationSets);
341 cmd.push("-y");
342 cmd.push(outDir.join("dash.mpd").toString());
343
344 return cmd;
345}
346
347interface HlsPreset {
348 id: string;
349 videoBitrate: string;
350 audioBitrate: string;
351 maxHeight: number;
352}
353
354const hlsPresets: HlsPreset[] = [
355 { id: "0", videoBitrate: "3000k", audioBitrate: "192k", maxHeight: 1080 },
356 { id: "1", videoBitrate: "1200k", audioBitrate: "128k", maxHeight: 720 },
357 { id: "2", videoBitrate: "400k", audioBitrate: "96k", maxHeight: 580 },
358];
359
360export function getH264HlsArgs(
361 inputArgs: InputArgs,
362 outDir: Path,
363) {
364 const cmd = [...inputArgs.both];
365
366 if (inputArgs.audio) {
367 // with audio: create v+a pairs for each preset
368 hlsPresets.forEach(() => {
369 cmd.push("-map", "0:v:0", "-map", "0:a:0");
370 });
371
372 hlsPresets.forEach((preset, i) => {
373 const vIdx = i * 2;
374 const aIdx = i * 2 + 1;
375 // deno-fmt-ignore
376 cmd.push(
377 `-c:${vIdx}`,
378 "libx264",
379 `-b:v:${vIdx}`,
380 preset.videoBitrate,
381 `-filter:${vIdx}`,
382 `scale=-2:min(${preset.maxHeight}\\,ih)`,
383 `-c:${aIdx}`,
384 "aac",
385 `-b:a:${aIdx}`,
386 preset.audioBitrate,
387 );
388 });
389
390 cmd.push(
391 "-var_stream_map",
392 hlsPresets.map((_, i) => `v:${i},a:${i}`).join(" "),
393 );
394 } else {
395 // video only: single stream per preset
396 hlsPresets.forEach(() => {
397 cmd.push("-map", "0:v:0");
398 });
399
400 hlsPresets.forEach((preset, i) => {
401 // deno-fmt-ignore
402 cmd.push(
403 `-c:v:${i}`,
404 "libx264",
405 `-b:v:${i}`,
406 preset.videoBitrate,
407 `-filter:v:${i}`,
408 `scale=-2:min(${preset.maxHeight}\\,ih)`,
409 );
410 });
411
412 cmd.push(
413 "-var_stream_map",
414 hlsPresets.map((_, i) => `v:${i}`).join(" "),
415 );
416 }
417
418 // deno-fmt-ignore
419 cmd.push(
420 "-f",
421 "hls",
422 "-hls_time",
423 "2",
424 "-hls_list_size",
425 "0",
426 "-hls_segment_type",
427 "fmp4",
428 "-hls_allow_cache",
429 "1",
430 "-master_pl_name",
431 "master.m3u8",
432 "-hls_segment_filename",
433 "hls.%v.%03d.ts",
434 "-y",
435 outDir.join("hls.%v.m3u8").toString(),
436 );
437
438 return cmd;
439}
440
441import type { Path } from "#sitegen/path";
442import * as ffmpeg from "@clo/lib/subprocess/ffmpeg";