authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-23 23:16:33-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-04-02 22:58:04-07:00
logec1448f737d8aed1c7f7fdd8a02fd6232b19eff8
treef29d32282eaa2be9ed5880be4241863c2f628326
parent907e9083b681531d3be2f8ba0cafc63dede6e61d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: resolve file viewer video conflict


6 files changed, 570 insertions(+), 102 deletions(-)

framework/font.ts+1-1
...@@ -109,7 +109,7 @@ export async function fetchFont(ref: progress.Ref, name: string, sources: string...@@ -109,7 +109,7 @@ export async function fetchFont(ref: progress.Ref, name: string, sources: string
109 : process.platform === "darwin"109 : process.platform === "darwin"
110 ? [110 ? [
111 "/Library/Fonts",111 "/Library/Fonts",
112 path.posix.join(home, "@clo/library/Fonts"),112 path.posix.join(home, "/Library/Fonts"),
113 ]113 ]
114 : [114 : [
115 "/usr/share/fonts",115 "/usr/share/fonts",
lib/subprocess/ffmpeg.ts+1-1
...@@ -21,7 +21,7 @@ export interface SpawnOptions {...@@ -21,7 +21,7 @@ export interface SpawnOptions {
21 *21 *
22 * ```ts22 * ```ts
23 * await ffmpeg.spawn({23 * await ffmpeg.spawn({
24 * cmd: ["-i", "hello.mov", "-c:v", "@clo/libsvtav1", "hello.mp4"],24 * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"],
25 * progress: progress.start("encode hello.mov"),25 * progress: progress.start("encode hello.mov"),
26 * });26 * });
27 * ```27 * ```
src/file-viewer/bin/file-scan.ts+61-45
...@@ -15,7 +15,8 @@ const sotToken = process.env.CLOVER_SOT_KEY;...@@ -15,7 +15,8 @@ const sotToken = process.env.CLOVER_SOT_KEY;
15export async function main() {15export async function main() {
16 const start = performance.now();16 const start = performance.now();
17 using _ = log.startWidget({17 using _ = log.startWidget({
18 format: ({ now }) => `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,18 format: ({ now }) =>
19 `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,
19 });20 });
2021
21 const promises = new async.PromiseAggregator();22 const promises = new async.PromiseAggregator();
...@@ -62,10 +63,10 @@ export async function main() {...@@ -62,10 +63,10 @@ export async function main() {
62 }63 }
6364
64 if (65 if (
65 !mediaFile // All processes must be performed if there is no file.66 !mediaFile || // All processes must be performed if there is no file.
66 // Rerun all processors if it changed67 // Rerun all processors if it changed
67 || stat.size !== mediaFile.size68 stat.size !== mediaFile.size ||
68 || stat.mtime.getTime() !== mediaFile.date.getTime()69 stat.mtime.getTime() !== mediaFile.date.getTime()
69 ) {70 ) {
70 promises.push(updateMetadata({ path, publicPath, stat, mediaFile }));71 promises.push(updateMetadata({ path, publicPath, stat, mediaFile }));
71 } else {72 } else {
...@@ -100,9 +101,9 @@ export async function main() {...@@ -100,9 +101,9 @@ export async function main() {
100 });101 });
101 let date = stat.mtime;102 let date = stat.mtime;
102 if (103 if (
103 mediaFile104 mediaFile &&
104 && mediaFile.date.getTime() < stat.mtime.getTime()105 mediaFile.date.getTime() < stat.mtime.getTime() &&
105 && Date.now() - stat.mtime.getTime() < monthMilliseconds106 Date.now() - stat.mtime.getTime() < monthMilliseconds
106 ) {107 ) {
107 date = mediaFile.date;108 date = mediaFile.date;
108 console.warn(109 console.warn(
...@@ -136,7 +137,7 @@ export async function main() {...@@ -136,7 +137,7 @@ export async function main() {
136 },137 },
137 () => ({ priority: -1 }),138 () => ({ priority: -1 }),
138 );139 );
139 const queueFileProcessor = queue.wrap(async function({140 const queueFileProcessor = queue.wrap(async function ({
140 path,141 path,
141 stat,142 stat,
142 mediaFile,143 mediaFile,
...@@ -189,7 +190,9 @@ export async function main() {...@@ -189,7 +190,9 @@ export async function main() {
189 node.passive = true;190 node.passive = true;
190191
191 const ext = mediaFile.extensionNonEmpty.toLowerCase();192 const ext = mediaFile.extensionNonEmpty.toLowerCase();
192 let possible = processors.filter((p) => p.include ? p.include.has(ext) : !p.exclude?.has(ext));193 let possible = processors.filter((p) =>
194 p.include ? p.include.has(ext) : !p.exclude?.has(ext)
195 );
193 if (possible.length === 0) return;196 if (possible.length === 0) return;
194 ASSERT(possible.length < 16, "too many bits");197 ASSERT(possible.length < 16, "too many bits");
195198
...@@ -224,7 +227,9 @@ export async function main() {...@@ -224,7 +227,9 @@ export async function main() {
224 .join(";"),227 .join(";"),
225 );228 );
226 } else {229 } else {
227 possible = order.map(({ id }) => UNWRAP(possible.find((p) => p.id === id)));230 possible = order.map(({ id }) =>
231 UNWRAP(possible.find((p) => p.id === id))
232 );
228 }233 }
229234
230 // Queue needed processors.235 // Queue needed processors.
...@@ -289,11 +294,14 @@ export async function main() {...@@ -289,11 +294,14 @@ export async function main() {
289 const children = dir.getChildren();294 const children = dir.getChildren();
290295
291 // readme.txt296 // readme.txt
292 const readmeContent = children.find((x) => x.basename === "readme.txt")?.contents ?? "";297 const readmeContent = children.find((x) =>
298 x.basename === "readme.txt"
299 )?.contents ?? "";
293300
294 // dirsort301 // dirsort
295 let dirsort: string[] | null = null;302 let dirsort: string[] | null = null;
296 const dirSortRaw = children.find((x) => x.basename === ".dirsort")?.contents ?? "";303 const dirSortRaw =
304 children.find((x) => x.basename === ".dirsort")?.contents ?? "";
297 if (dirSortRaw) {305 if (dirSortRaw) {
298 dirsort = dirSortRaw306 dirsort = dirSortRaw
299 .split("\n")307 .split("\n")
...@@ -409,9 +417,9 @@ export async function main() {...@@ -409,9 +417,9 @@ export async function main() {
409 } else console.warn("Missing SOT token");417 } else console.warn("Missing SOT token");
410418
411 console.info(419 console.info(
412 "Updated file viewer index in \x1b[1m"420 "Updated file viewer index in \x1b[1m" +
413 + ((performance.now() - start) / 1000).toFixed(1)421 ((performance.now() - start) / 1000).toFixed(1) +
414 + "s\x1b[0m",422 "s\x1b[0m",
415 );423 );
416424
417 const { duration, count } = MediaFile.db425 const { duration, count } = MediaFile.db
...@@ -434,12 +442,12 @@ export async function main() {...@@ -434,12 +442,12 @@ export async function main() {
434442
435 console.info();443 console.info();
436 console.info(444 console.info(
437 "Global Stats:\n"445 "Global Stats:\n" +
438 + `- File Count: \x1b[1m${count}\x1b[0m\n`446 `- File Count: \x1b[1m${count}\x1b[0m\n` +
439 + `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n`447 `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n` +
440 + `- Media Duration: \x1b[1m${formatDurationLong(duration)}\x1b[0m\n`448 `- Media Duration: \x1b[1m${formatDurationLong(duration)}\x1b[0m\n` +
441 + `- Canonical Size: \x1b[1m${formatSize(canonicalSize)}\x1b[0m\n`449 `- Canonical Size: \x1b[1m${formatSize(canonicalSize)}\x1b[0m\n` +
442 + `- Derived Size: \x1b[1m${formatSize(derivedSize)}\x1b[0m\n`,450 `- Derived Size: \x1b[1m${formatSize(derivedSize)}\x1b[0m\n`,
443 );451 );
444}452}
445453
...@@ -586,7 +594,7 @@ const procImageSubsets: Process = {...@@ -586,7 +594,7 @@ const procImageSubsets: Process = {
586 name: "encode image subsets",594 name: "encode image subsets",
587 include: rules.extsImage,595 include: rules.extsImage,
588 depends: [procDimensions.name],596 depends: [procDimensions.name],
589 version: 2,597 version: 3,
590 async run({ path, mediaFile, node }) {598 async run({ path, mediaFile, node }) {
591 const { width, height } = UNWRAP(mediaFile.parseDimensions());599 const { width, height } = UNWRAP(mediaFile.parseDimensions());
592 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);600 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);
...@@ -638,6 +646,7 @@ const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({...@@ -638,6 +646,7 @@ const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({
638 include: rules.extsVideo,646 include: rules.extsVideo,
639 enable: ffmpegBin != null,647 enable: ffmpegBin != null,
640 depends: [procDuration.name, procDimensions.name],648 depends: [procDuration.name, procDimensions.name],
649 version: 4,
641 async run({ path, mediaFile, node }) {650 async run({ path, mediaFile, node }) {
642 if ((mediaFile.duration ?? 0) < 5) return;651 if ((mediaFile.duration ?? 0) < 5) return;
643 if (!mediaFile.dimensions) return;652 if (!mediaFile.dimensions) return;
...@@ -651,8 +660,11 @@ const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({...@@ -651,8 +660,11 @@ const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({
651 subkey: `av1-${preset.id}`,660 subkey: `av1-${preset.id}`,
652 async producer(dir) {661 async producer(dir) {
653 const input = await videoArgsCache.getOrRun(path);662 const input = await videoArgsCache.getOrRun(path);
663 const dimensions = mediaFile.parseDimensions();
664 ASSERT(dimensions);
654 const args = transcodeRules.getAv1VideoArgs(665 const args = transcodeRules.getAv1VideoArgs(
655 preset,666 preset,
667 dimensions,
656 UNWRAP(input.video, "frick on " + path + JSON.stringify(input)),668 UNWRAP(input.video, "frick on " + path + JSON.stringify(input)),
657 dir,669 dir,
658 );670 );
...@@ -672,7 +684,7 @@ const procVideoAudios = transcodeRules.audioFormats.map<Process>((preset) => ({...@@ -672,7 +684,7 @@ const procVideoAudios = transcodeRules.audioFormats.map<Process>((preset) => ({
672 include: rules.extsVideo,684 include: rules.extsVideo,
673 enable: ffmpegBin != null,685 enable: ffmpegBin != null,
674 depends: [procDuration.name, procDimensions.name],686 depends: [procDuration.name, procDimensions.name],
675 version: 2,687 version: 3,
676 async run({ path, mediaFile, node }) {688 async run({ path, mediaFile, node }) {
677 if ((mediaFile.duration ?? 0) < 5) return;689 if ((mediaFile.duration ?? 0) < 5) return;
678 if (!mediaFile.dimensions) return;690 if (!mediaFile.dimensions) return;
...@@ -703,7 +715,7 @@ const procDash: Process = {...@@ -703,7 +715,7 @@ const procDash: Process = {
703 include: rules.extsVideo,715 include: rules.extsVideo,
704 enable: ffmpegBin != null,716 enable: ffmpegBin != null,
705 depends: [...procVideos, ...procVideoAudios].map((x) => x.name),717 depends: [...procVideos, ...procVideoAudios].map((x) => x.name),
706 version: 4,718 version: 7,
707 async run({ path, mediaFile, node }) {719 async run({ path, mediaFile, node }) {
708 if ((mediaFile.duration ?? 0) < 5) return;720 if ((mediaFile.duration ?? 0) < 5) return;
709 if (!mediaFile.dimensions) return;721 if (!mediaFile.dimensions) return;
...@@ -825,8 +837,8 @@ const processors = [...@@ -825,8 +837,8 @@ const processors = [
825 crypto837 crypto
826 .createHash("sha1")838 .createHash("sha1")
827 .update(839 .update(
828 process.run.toString()840 process.run.toString() +
829 + (process.version ? String(process.version) : ""),841 (process.version ? String(process.version) : ""),
830 )842 )
831 .digest().buffer,843 .digest().buffer,
832 ).reduce((a, b) => a ^ b),844 ).reduce((a, b) => a ^ b),
...@@ -876,12 +888,12 @@ function skipBasename(basename: string): boolean {...@@ -876,12 +888,12 @@ function skipBasename(basename: string): boolean {
876 if (basename === ".date") return false;888 if (basename === ".date") return false;
877889
878 return (890 return (
879 basename.startsWith(".")891 basename.startsWith(".") ||
880 // basename.startsWith("._") ||892 // basename.startsWith("._") ||
881 // basename.startsWith(".tmp") ||893 // basename.startsWith(".tmp") ||
882 // basename === ".DS_Store" ||894 // basename === ".DS_Store" ||
883 || basename.toLowerCase() === "thumbs.db"895 basename.toLowerCase() === "thumbs.db" ||
884 || basename.toLowerCase() === "desktop.ini"896 basename.toLowerCase() === "desktop.ini"
885 );897 );
886}898}
887899
...@@ -934,8 +946,8 @@ async function scrubLocationMetadata(...@@ -934,8 +946,8 @@ async function scrubLocationMetadata(
934 "-s",946 "-s",
935 path.toString(),947 path.toString(),
936 ]);948 ]);
937 hasLocation = videoCheck.includes("GPS")949 hasLocation = videoCheck.includes("GPS") ||
938 || videoCheck.includes("Location");950 videoCheck.includes("Location");
939 args = [951 args = [
940 "-gps:all=",952 "-gps:all=",
941 "-xmp:all=",953 "-xmp:all=",
...@@ -951,10 +963,10 @@ async function scrubLocationMetadata(...@@ -951,10 +963,10 @@ async function scrubLocationMetadata(
951 "-s",963 "-s",
952 path.toString(),964 path.toString(),
953 ]);965 ]);
954 hasLocation = m4aCheck.includes("GPS")966 hasLocation = m4aCheck.includes("GPS") ||
955 || m4aCheck.includes("Location")967 m4aCheck.includes("Location") ||
956 || m4aCheck.includes("Filename")968 m4aCheck.includes("Filename") ||
957 || m4aCheck.includes("Title");969 m4aCheck.includes("Title");
958970
959 if (hasLocation) {971 if (hasLocation) {
960 args = [972 args = [
...@@ -1016,13 +1028,13 @@ async function scrubLocationMetadata(...@@ -1016,13 +1028,13 @@ async function scrubLocationMetadata(
10161028
1017const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;1029const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;
10181030
1031import * as async from "@clo/lib/async";
1019import * as fs from "#sitegen/fs";1032import * as fs from "#sitegen/fs";
1033import * as subprocess from "@clo/lib/subprocess";
1020import { Path } from "#sitegen/path";1034import { Path } from "#sitegen/path";
1021import * as async from "@clo/lib/async";1035import * as queue from "@clo/lib/queue";
1022import * as log from "@clo/lib/log";1036import * as log from "@clo/lib/log";
1023import * as progress from "@clo/lib/progress";1037import * as progress from "@clo/lib/progress";
1024import * as queue from "@clo/lib/queue";
1025import * as subprocess from "@clo/lib/subprocess";
1026import * as ts from "@clo/lib/ts";1038import * as ts from "@clo/lib/ts";
10271039
1028import * as child_process from "node:child_process";1040import * as child_process from "node:child_process";
...@@ -1032,15 +1044,19 @@ import * as path from "node:path";...@@ -1032,15 +1044,19 @@ import * as path from "node:path";
1032import * as stream from "node:stream";1044import * as stream from "node:stream";
1033import * as zlib from "node:zlib";1045import * as zlib from "node:zlib";
10341046
1035import { formatDate, formatDurationLong, formatSize } from "#src/file-viewer/format.ts";
1036import * as highlight from "#src/file-viewer/highlight.ts";
1037import * as derived from "#src/file-viewer/models/derived.ts";
1038import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts";
1039import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";1047import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";
1040import * as rsync from "#src/file-viewer/rsync.ts";1048import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts";
1049import {
1050 formatDate,
1051 formatDurationLong,
1052 formatSize,
1053} from "#src/file-viewer/format.ts";
1041import * as rules from "#src/file-viewer/rules.ts";1054import * as rules from "#src/file-viewer/rules.ts";
1042import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";1055import * as highlight from "#src/file-viewer/highlight.ts";
1043import * as ffmpeg from "@clo/lib/subprocess/ffmpeg";1056import * as ffmpeg from "@clo/lib/subprocess/ffmpeg";
1057import * as rsync from "#src/file-viewer/rsync.ts";
1058import * as derived from "#src/file-viewer/models/derived.ts";
1059import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";
10441060
1045import { ASSERT, UNWRAP } from "@clo/lib/assert";
1046import { rawFileRoot as root } from "../paths.ts";1061import { rawFileRoot as root } from "../paths.ts";
1062import { ASSERT, UNWRAP } from "@clo/lib/assert";
src/file-viewer/bin/file-trim.ts+2-1
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1export async function main() {1export async function main() {
2 const start = performance.now();2 const start = performance.now();
3 using _ = log.startWidget({3 using _ = log.startWidget({
4 format: (now) => `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,4 format: (now) =>
5 `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,
5 });6 });
67
7 const orphaned = derived.findOrphanedRoots();8 const orphaned = derived.findOrphanedRoots();
src/file-viewer/transcode-rules.test.ts created+352
...@@ -0,0 +1,352 @@
1test("getConstantAspectScaleFilter picks one square-pixel ratio for the ladder", () => {
2 const filter = getConstantAspectScaleFilter({ width: 1920, height: 817 }, 600);
3 assert.equal(filter, "scale=1410:600");
4});
5
6test("getAv1VideoArgs uses constant-aspect scaling for downscaled renditions", () => {
7 const args = getAv1VideoArgs(
8 { id: "al", preset: 2, mbitMax: 1.25, crf: 40, maxHeight: 600 },
9 { width: 1920, height: 817 },
10 ["-i", "/tmp/input.mp4"],
11 new Path("/tmp/out"),
12 );
13
14 assert.equal(args[args.indexOf("-vf") + 1], "scale=1410:600");
15 assert.equal(args[args.indexOf("-y")], "-y");
16 assert.equal(args.at(-1), "/tmp/out/tmp.av1.mp4");
17});
18
19test("getAv1VideoArgs also snaps top-quality renditions onto the same exact ratio", () => {
20 const args = getAv1VideoArgs(
21 { id: "au", preset: 1, mbitMax: 30, crf: 28, depth: 10 },
22 { width: 1920, height: 817 },
23 ["-i", "/tmp/input.mp4"],
24 new Path("/tmp/out"),
25 );
26
27 assert.equal(args[args.indexOf("-vf") + 1], "scale=1880:800");
28});
29
30test("getMpegDashArgs keeps all video streams in one adaptation set", () => {
31 const args = getMpegDashArgs(
32 [
33 new Path("/tmp/av1-1.mp4"),
34 new Path("/tmp/av1-2.mp4"),
35 new Path("/tmp/av1-3.mp4"),
36 ],
37 [
38 new Path("/tmp/audio-1.webm"),
39 new Path("/tmp/audio-2.webm"),
40 ],
41 new Path("/tmp/dash"),
42 );
43
44 const adaptationSets = args[args.indexOf("-adaptation_sets") + 1];
45 assert.equal(adaptationSets, "id=0,streams=0,1,2 id=1,streams=3,4");
46});
47
48test("all AV1 presets share one exact square-pixel ratio across many source sizes", () => {
49 const sources = [
50 { width: 3840, height: 2160 },
51 { width: 1920, height: 1080 },
52 { width: 1920, height: 817 },
53 { width: 1440, height: 1080 },
54 { width: 1280, height: 720 },
55 { width: 1280, height: 534 },
56 { width: 1080, height: 1920 },
57 { width: 1080, height: 1350 },
58 { width: 1024, height: 768 },
59 { width: 853, height: 480 },
60 { width: 800, height: 800 },
61 { width: 721, height: 405 },
62 { width: 640, height: 360 },
63 { width: 426, height: 240 },
64 ];
65
66 for (const source of sources) {
67 let expectedRatio: number | null = null;
68 for (const preset of videoFormats) {
69 const args = getAv1VideoArgs(
70 preset,
71 source,
72 ["-i", "/tmp/input.mp4"],
73 new Path("/tmp/out"),
74 );
75 const vfIndex = args.indexOf("-vf");
76 const filter = vfIndex === -1 ? null : args[vfIndex + 1];
77 const dims = filter == null
78 ? source
79 : parseScaleFilter(assertString(filter));
80
81 assert.ok(dims.width > 0);
82 assert.ok(dims.height > 0);
83 assert.equal(dims.width % 2, 0);
84 assert.equal(dims.height % 2, 0);
85 assert.ok(dims.width <= source.width);
86 assert.ok(dims.height <= source.height);
87 if (preset.maxHeight != null) {
88 assert.ok(
89 dims.height <= preset.maxHeight,
90 `${source.width}x${source.height} exceeded maxHeight ${preset.maxHeight} with ${dims.width}x${dims.height}`,
91 );
92 }
93
94 const ratio = dims.width / dims.height;
95 if (expectedRatio == null) {
96 expectedRatio = ratio;
97 } else {
98 assert.equal(
99 ratio,
100 expectedRatio,
101 `${source.width}x${source.height} produced mismatched ladder ratio ${dims.width}x${dims.height}`,
102 );
103 }
104 }
105 }
106});
107
108test("canonical ratio sweep matches the best feasible exact ratio", () => {
109 for (let width = 320; width <= 4096; width += 113) {
110 for (let height = 240; height <= 3072; height += 97) {
111 const source = { width, height };
112 const actualDimsByPreset: Array<{ width: number; height: number }> = [];
113 let referenceDims: { width: number; height: number } | null = null;
114
115 for (const preset of videoFormats) {
116 const args = getAv1VideoArgs(
117 preset,
118 source,
119 ["-i", "/tmp/input.mp4"],
120 new Path("/tmp/out"),
121 );
122 const vfIndex = args.indexOf("-vf");
123 const filter = vfIndex === -1 ? null : args[vfIndex + 1];
124 const dims = filter == null
125 ? source
126 : parseScaleFilter(assertString(filter));
127
128 actualDimsByPreset.push(dims);
129
130 if (referenceDims == null) {
131 referenceDims = dims;
132 } else {
133 assert.equal(
134 dims.width * referenceDims.height,
135 referenceDims.width * dims.height,
136 `${width}x${height} produced inconsistent exact ratios within the ladder`,
137 );
138 }
139 }
140
141 const actualRatio = reduceRatio(referenceDims!);
142 const actualScore = getCandidateScore(source, actualRatio);
143 assert.ok(actualScore, `${width}x${height} produced an infeasible ratio`);
144 const feasibleCandidates = getFeasibleCandidates(source);
145 const candidatesWithinThreshold = feasibleCandidates.filter((candidate) =>
146 candidate.error <= EXACT_ERROR_THRESHOLD
147 );
148
149 if (candidatesWithinThreshold.length > 0) {
150 assert.ok(
151 actualScore.error <= EXACT_ERROR_THRESHOLD,
152 `${width}x${height} should have found a ratio within the allowed drift threshold`,
153 );
154 } else {
155 const bestFallbackError = Math.min(
156 ...feasibleCandidates.map((candidate) => candidate.error),
157 );
158 assert.equal(
159 actualScore.error,
160 bestFallbackError,
161 `${width}x${height} did not pick a minimum-drift fallback ratio`,
162 );
163 }
164
165 for (let i = 0; i < videoFormats.length; i++) {
166 const expectedDims = getExpectedDimensionsForPreset(
167 source,
168 actualRatio,
169 videoFormats[i].maxHeight,
170 );
171 assert.deepEqual(
172 actualDimsByPreset[i],
173 expectedDims,
174 `${width}x${height} produced unexpected dimensions for preset ${videoFormats[i].id}`,
175 );
176 }
177 }
178 }
179});
180
181test("dense sweep never produces mismatched ladder aspect ratios", () => {
182 for (let width = 2; width <= 2048; width++) {
183 for (let height = 2; height <= 2048; height++) {
184 const source = { width, height };
185 let referenceDims: { width: number; height: number } | null = null;
186
187 for (const preset of videoFormats) {
188 const args = getAv1VideoArgs(
189 preset,
190 source,
191 ["-i", "/tmp/input.mp4"],
192 new Path("/tmp/out"),
193 );
194 const vfIndex = args.indexOf("-vf");
195 const filter = vfIndex === -1 ? null : args[vfIndex + 1];
196 const dims = filter == null
197 ? source
198 : parseScaleFilter(assertString(filter));
199
200 assert.ok(dims.width >= 2, `${width}x${height} produced width < 2`);
201 assert.ok(dims.height >= 2, `${width}x${height} produced height < 2`);
202 if (filter != null) {
203 assert.equal(dims.width % 2, 0, `${width}x${height} produced odd scaled width`);
204 assert.equal(dims.height % 2, 0, `${width}x${height} produced odd scaled height`);
205 }
206 assert.ok(dims.width <= source.width, `${width}x${height} exceeded source width`);
207 assert.ok(dims.height <= source.height, `${width}x${height} exceeded source height`);
208 if (preset.maxHeight != null) {
209 assert.ok(
210 dims.height <= preset.maxHeight,
211 `${width}x${height} exceeded maxHeight ${preset.maxHeight} with ${dims.width}x${dims.height}`,
212 );
213 }
214
215 if (referenceDims == null) {
216 referenceDims = dims;
217 } else {
218 assert.equal(
219 dims.width * referenceDims.height,
220 referenceDims.width * dims.height,
221 `${width}x${height} produced mismatched exact ratios: ${referenceDims.width}x${referenceDims.height} vs ${dims.width}x${dims.height}`,
222 );
223 }
224 }
225 }
226 }
227});
228
229import { test } from "vitest";
230import assert from "node:assert";
231import { Path } from "#sitegen/path";
232import {
233 getAv1VideoArgs,
234 getConstantAspectScaleFilter,
235 getMpegDashArgs,
236 videoFormats,
237} from "./transcode-rules.ts";
238
239function assertString(value: string | undefined | null): string {
240 assert.equal(typeof value, "string");
241 return value;
242}
243
244function parseScaleFilter(filter: string) {
245 const match = /^scale=(\d+):(\d+)$/.exec(filter);
246 assert.ok(match, `unexpected filter ${filter}`);
247 return {
248 width: Number(match[1]),
249 height: Number(match[2]),
250 };
251}
252
253function getExpectedDimensionsForPreset(
254 source: { width: number; height: number },
255 ratio: { width: number; height: number },
256 maxHeight?: number,
257) {
258 const heightCap = Math.min(maxHeight ?? source.height, source.height);
259 const maxScale = Math.floor(Math.min(
260 source.width / ratio.width,
261 heightCap / ratio.height,
262 ));
263 const scale = maxScale % 2 === 0 ? maxScale : maxScale - 1;
264 const dims = {
265 width: ratio.width * scale,
266 height: ratio.height * scale,
267 };
268
269 if (dims.width === source.width && dims.height === source.height) return source;
270 return dims;
271}
272
273const EXACT_ERROR_THRESHOLD = 0.0005;
274
275function getFeasibleCandidates(source: { width: number; height: number }) {
276 const caps = [
277 source.height,
278 ...videoFormats.flatMap((preset) =>
279 preset.maxHeight != null ? [Math.min(preset.maxHeight, source.height)] : []
280 ),
281 ];
282 const sourceRatio = source.width / source.height;
283 const maxDenominator = Math.max(1, Math.floor(Math.min(...caps) / 2));
284 const seen = new Set<string>();
285 const candidates: Array<{
286 width: number;
287 height: number;
288 error: number;
289 retainedArea: number;
290 }> = [];
291
292 for (let denominator = 1; denominator <= maxDenominator; denominator++) {
293 const numerator = Math.max(1, Math.round(sourceRatio * denominator));
294 const divisor = greatestCommonDivisor(numerator, denominator);
295 const candidate = {
296 width: numerator / divisor,
297 height: denominator / divisor,
298 };
299 const key = `${candidate.width}:${candidate.height}`;
300 if (seen.has(key)) continue;
301 seen.add(key);
302
303 const dims = videoFormats.map((preset) =>
304 getExpectedDimensionsForPreset(source, candidate, preset.maxHeight)
305 );
306 if (dims.some((value) => value.width <= 0 || value.height <= 0)) continue;
307
308 const retainedArea = dims
309 .map((value) => value.width * value.height)
310 .reduce((sum, value) => sum + value, 0);
311 const error = Math.abs((candidate.width / candidate.height) - sourceRatio) /
312 sourceRatio;
313 candidates.push({ ...candidate, error, retainedArea });
314 }
315
316 return candidates;
317}
318
319function greatestCommonDivisor(a: number, b: number): number {
320 let x = Math.abs(a);
321 let y = Math.abs(b);
322 while (y !== 0) {
323 [x, y] = [y, x % y];
324 }
325 return x || 1;
326}
327
328function reduceRatio(dimensions: { width: number; height: number }) {
329 const divisor = greatestCommonDivisor(dimensions.width, dimensions.height);
330 return {
331 width: dimensions.width / divisor,
332 height: dimensions.height / divisor,
333 };
334}
335
336function getCandidateScore(
337 source: { width: number; height: number },
338 ratio: { width: number; height: number },
339) {
340 const dims = videoFormats.map((preset) =>
341 getExpectedDimensionsForPreset(source, ratio, preset.maxHeight)
342 );
343 if (dims.some((value) => value.width <= 0 || value.height <= 0)) return null;
344 const sourceRatio = source.width / source.height;
345 return {
346 ...ratio,
347 error: Math.abs((ratio.width / ratio.height) - sourceRatio) / sourceRatio,
348 retainedArea: dims
349 .map((value) => value.width * value.height)
350 .reduce((sum, value) => sum + value, 0),
351 };
352}
src/file-viewer/transcode-rules.ts+153-54
...@@ -10,6 +10,10 @@ interface OpusPreset {...@@ -10,6 +10,10 @@ interface OpusPreset {
10 id: string;10 id: string;
11 audioKbit: number;11 audioKbit: number;
12}12}
13interface Dimensions {
14 width: number;
15 height: number;
16}
1317
14export const videoFormats: Av1Preset[] = [18export const videoFormats: Av1Preset[] = [
15 {19 {
...@@ -69,22 +73,11 @@ export const imagePresets = [...@@ -69,22 +73,11 @@ export const imagePresets = [
69 "6",73 "6",
70 ],74 ],
71 },75 },
72 {
73 ext: ".avif",
74 args: [
75 "-c:v",
76 "@clo/libaom-av1",
77 "-crf",
78 "30",
79 "-pix_fmt",
80 "yuv420p10le",
81 ],
82 },
83 {76 {
84 ext: ".jxl",77 ext: ".jxl",
85 args: [78 args: [
86 "-c:v",79 "-c:v",
87 "@clo/libjxl",80 "libjxl",
88 "-distance",81 "-distance",
89 "0.8",82 "0.8",
90 "-effort",83 "-effort",
...@@ -140,15 +133,136 @@ export interface InputArgs {...@@ -140,15 +133,136 @@ export interface InputArgs {
140export const opusFileName = "tmp.opus.webm";133export const opusFileName = "tmp.opus.webm";
141export const av1FileName = "tmp.av1.mp4";134export const av1FileName = "tmp.av1.mp4";
142135
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) =>
206 getExactSquarePixelDimensions(source, aspect, cap)
207 );
208 if (dimensions.some((value) => value == null)) continue;
209
210 const retainedArea = dimensions
211 .map((value) => value!.width * value!.height)
212 .reduce((sum, area) => sum + area, 0);
213 const error = Math.abs((aspect.width / aspect.height) - ratio) / ratio;
214 const candidate = { aspect, error, retainedArea };
215
216 if (error <= exactErrorThreshold) {
217 if (
218 !bestWithinThreshold ||
219 retainedArea > bestWithinThreshold.retainedArea ||
220 (
221 retainedArea === bestWithinThreshold.retainedArea &&
222 error < bestWithinThreshold.error
223 )
224 ) {
225 bestWithinThreshold = candidate;
226 }
227 continue;
228 }
229
230 if (
231 !bestFallback ||
232 error < bestFallback.error ||
233 (error === bestFallback.error && retainedArea > bestFallback.retainedArea)
234 ) {
235 bestFallback = candidate;
236 }
237 }
238
239 return bestWithinThreshold?.aspect ?? bestFallback?.aspect ?? source;
240}
241
242export function getConstantAspectScaleFilter(
243 source: Dimensions,
244 maxHeight?: number,
245): string | null {
246 const aspect = getCanonicalSquarePixelAspectRatio(source);
247 const dimensions = getExactSquarePixelDimensions(source, aspect, maxHeight);
248 if (!dimensions) return null;
249 if (
250 dimensions.width === source.width &&
251 dimensions.height === source.height
252 ) return null;
253 return `scale=${dimensions.width}:${dimensions.height}`;
254}
255
143export function getAv1VideoArgs(256export function getAv1VideoArgs(
144 av1: Av1Preset,257 av1: Av1Preset,
258 source: Dimensions,
145 videoInputArgs: string[],259 videoInputArgs: string[],
146 outDir: Path,260 outDir: Path,
147) {261) {
148 const { preset, mbitMax, crf, maxHeight, depth } = av1;262 const { preset, mbitMax, crf, maxHeight, depth } = av1;
149 const cmd = [...videoInputArgs];263 const cmd = [...videoInputArgs];
150264
151 cmd.push("-c:v", "@clo/libsvtav1");265 cmd.push("-c:v", "libsvtav1");
152 cmd.push(266 cmd.push(
153 "-svtav1-params",267 "-svtav1-params",
154 [268 [
...@@ -163,8 +277,9 @@ export function getAv1VideoArgs(...@@ -163,8 +277,9 @@ export function getAv1VideoArgs(
163 "scm=2", // ScreenContentMode, adaptive277 "scm=2", // ScreenContentMode, adaptive
164 ].filter(Boolean).join(":"),278 ].filter(Boolean).join(":"),
165 );279 );
166 if (maxHeight != null) {280 const filter = getConstantAspectScaleFilter(source, maxHeight);
167 cmd.push("-vf", `scale=-2:min(${maxHeight}\\,ih)`);281 if (filter) {
282 cmd.push("-vf", filter);
168 }283 }
169284
170 cmd.push("-y");285 cmd.push("-y");
...@@ -183,7 +298,7 @@ export function getOpusAudioArgs(...@@ -183,7 +298,7 @@ export function getOpusAudioArgs(
183298
184 cmd.push("-vn");299 cmd.push("-vn");
185300
186 cmd.push("-c:a", "@clo/libopus");301 cmd.push("-c:a", "libopus");
187 cmd.push("-b:a", audioKbit + "k");302 cmd.push("-b:a", audioKbit + "k");
188303
189 cmd.push("-y");304 cmd.push("-y");
...@@ -215,16 +330,15 @@ export function getMpegDashArgs(...@@ -215,16 +330,15 @@ export function getMpegDashArgs(
215 cmd.push("-use_timeline", "1");330 cmd.push("-use_timeline", "1");
216 cmd.push("-use_template", "1");331 cmd.push("-use_template", "1");
217332
218 // calculate actual dimensions per video333 let adaptationSets = videos.length > 0
219 const videoAdaptationSets = videos334 ? `id=0,streams=${videos.map((_, i) => i).join(",")}`
220 .map((_, i) => `id=${i},streams=${i}`)335 : "";
221 .join(" ");
222
223 let adaptationSets = videoAdaptationSets;
224 if (audios.length > 0) {336 if (audios.length > 0) {
225 const audioId = videos.length;337 const audioId = videos.length > 0 ? 1 : 0;
226 const audioIndices = audios.map((_, i) => videos.length + i).join(",");338 const audioIndices = audios.map((_, i) => videos.length + i).join(",");
227 adaptationSets += ` id=${audioId},streams=${audioIndices}`;339 adaptationSets += `${
340 adaptationSets ? " " : ""
341 }id=${audioId},streams=${audioIndices}`;
228 }342 }
229343
230 cmd.push("-adaptation_sets", adaptationSets);344 cmd.push("-adaptation_sets", adaptationSets);
...@@ -264,16 +378,11 @@ export function getH264HlsArgs(...@@ -264,16 +378,11 @@ export function getH264HlsArgs(
264 const aIdx = i * 2 + 1;378 const aIdx = i * 2 + 1;
265 // deno-fmt-ignore379 // deno-fmt-ignore
266 cmd.push(380 cmd.push(
267 `-c:${vIdx}`,381 `-c:${vIdx}`, "libx264",
268 "@clo/libx264",382 `-b:v:${vIdx}`, preset.videoBitrate,
269 `-b:v:${vIdx}`,383 `-filter:${vIdx}`, `scale=-2:min(${preset.maxHeight}\\,ih)`,
270 preset.videoBitrate,384 `-c:${aIdx}`, "aac",
271 `-filter:${vIdx}`,385 `-b:a:${aIdx}`, preset.audioBitrate,
272 `scale=-2:min(${preset.maxHeight}\\,ih)`,
273 `-c:${aIdx}`,
274 "aac",
275 `-b:a:${aIdx}`,
276 preset.audioBitrate,
277 );386 );
278 });387 });
279388
...@@ -290,12 +399,9 @@ export function getH264HlsArgs(...@@ -290,12 +399,9 @@ export function getH264HlsArgs(
290 hlsPresets.forEach((preset, i) => {399 hlsPresets.forEach((preset, i) => {
291 // deno-fmt-ignore400 // deno-fmt-ignore
292 cmd.push(401 cmd.push(
293 `-c:v:${i}`,402 `-c:v:${i}`, "libx264",
294 "@clo/libx264",403 `-b:v:${i}`, preset.videoBitrate,
295 `-b:v:${i}`,404 `-filter:v:${i}`, `scale=-2:min(${preset.maxHeight}\\,ih)`,
296 preset.videoBitrate,
297 `-filter:v:${i}`,
298 `scale=-2:min(${preset.maxHeight}\\,ih)`,
299 );405 );
300 });406 });
301407
...@@ -307,20 +413,13 @@ export function getH264HlsArgs(...@@ -307,20 +413,13 @@ export function getH264HlsArgs(
307413
308 // deno-fmt-ignore414 // deno-fmt-ignore
309 cmd.push(415 cmd.push(
310 "-f",416 "-f", "hls",
311 "hls",417 "-hls_time", "2",
312 "-hls_time",418 "-hls_list_size", "0",
313 "2",419 "-hls_segment_type", "fmp4",
314 "-hls_list_size",420 "-hls_allow_cache", "1",
315 "0",421 "-master_pl_name", "master.m3u8",
316 "-hls_segment_type",422 "-hls_segment_filename", "hls.%v.%03d.ts",
317 "fmp4",
318 "-hls_allow_cache",
319 "1",
320 "-master_pl_name",
321 "master.m3u8",
322 "-hls_segment_filename",
323 "hls.%v.%03d.ts",
324 "-y",423 "-y",
325 outDir.join("hls.%v.m3u8").toString(),424 outDir.join("hls.%v.m3u8").toString(),
326 );425 );