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
109109 : process.platform === "darwin"
110110 ? [
111111 "/Library/Fonts",
112 path.posix.join(home, "@clo/library/Fonts"),
112 path.posix.join(home, "/Library/Fonts"),
113113 ]
114114 : [
115115 "/usr/share/fonts",
lib/subprocess/ffmpeg.ts+1-1
......@@ -21,7 +21,7 @@ export interface SpawnOptions {
2121 *
2222 * ```ts
2323 * await ffmpeg.spawn({
24 * cmd: ["-i", "hello.mov", "-c:v", "@clo/libsvtav1", "hello.mp4"],
24 * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"],
2525 * progress: progress.start("encode hello.mov"),
2626 * });
2727 * ```
src/file-viewer/bin/file-scan.ts+61-45
......@@ -15,7 +15,8 @@ const sotToken = process.env.CLOVER_SOT_KEY;
1515export async function main() {
1616 const start = performance.now();
1717 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]`,
1920 });
2021
2122 const promises = new async.PromiseAggregator();
......@@ -62,10 +63,10 @@ export async function main() {
6263 }
6364
6465 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.
6667 // Rerun all processors if it changed
67 || stat.size !== mediaFile.size
68 || stat.mtime.getTime() !== mediaFile.date.getTime()
68 stat.size !== mediaFile.size ||
69 stat.mtime.getTime() !== mediaFile.date.getTime()
6970 ) {
7071 promises.push(updateMetadata({ path, publicPath, stat, mediaFile }));
7172 } else {
......@@ -100,9 +101,9 @@ export async function main() {
100101 });
101102 let date = stat.mtime;
102103 if (
103 mediaFile
104 && mediaFile.date.getTime() < stat.mtime.getTime()
105 && Date.now() - stat.mtime.getTime() < monthMilliseconds
104 mediaFile &&
105 mediaFile.date.getTime() < stat.mtime.getTime() &&
106 Date.now() - stat.mtime.getTime() < monthMilliseconds
106107 ) {
107108 date = mediaFile.date;
108109 console.warn(
......@@ -136,7 +137,7 @@ export async function main() {
136137 },
137138 () => ({ priority: -1 }),
138139 );
139 const queueFileProcessor = queue.wrap(async function({
140 const queueFileProcessor = queue.wrap(async function ({
140141 path,
141142 stat,
142143 mediaFile,
......@@ -189,7 +190,9 @@ export async function main() {
189190 node.passive = true;
190191
191192 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 );
193196 if (possible.length === 0) return;
194197 ASSERT(possible.length < 16, "too many bits");
195198
......@@ -224,7 +227,9 @@ export async function main() {
224227 .join(";"),
225228 );
226229 } 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 );
228233 }
229234
230235 // Queue needed processors.
......@@ -289,11 +294,14 @@ export async function main() {
289294 const children = dir.getChildren();
290295
291296 // 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
294301 // dirsort
295302 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 ?? "";
297305 if (dirSortRaw) {
298306 dirsort = dirSortRaw
299307 .split("\n")
......@@ -409,9 +417,9 @@ export async function main() {
409417 } else console.warn("Missing SOT token");
410418
411419 console.info(
412 "Updated file viewer index in \x1b[1m"
413 + ((performance.now() - start) / 1000).toFixed(1)
414 + "s\x1b[0m",
420 "Updated file viewer index in \x1b[1m" +
421 ((performance.now() - start) / 1000).toFixed(1) +
422 "s\x1b[0m",
415423 );
416424
417425 const { duration, count } = MediaFile.db
......@@ -434,12 +442,12 @@ export async function main() {
434442
435443 console.info();
436444 console.info(
437 "Global Stats:\n"
438 + `- File Count: \x1b[1m${count}\x1b[0m\n`
439 + `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n`
440 + `- Media Duration: \x1b[1m${formatDurationLong(duration)}\x1b[0m\n`
441 + `- Canonical Size: \x1b[1m${formatSize(canonicalSize)}\x1b[0m\n`
442 + `- Derived Size: \x1b[1m${formatSize(derivedSize)}\x1b[0m\n`,
445 "Global Stats:\n" +
446 `- File Count: \x1b[1m${count}\x1b[0m\n` +
447 `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n` +
448 `- Media Duration: \x1b[1m${formatDurationLong(duration)}\x1b[0m\n` +
449 `- Canonical Size: \x1b[1m${formatSize(canonicalSize)}\x1b[0m\n` +
450 `- Derived Size: \x1b[1m${formatSize(derivedSize)}\x1b[0m\n`,
443451 );
444452}
445453
......@@ -586,7 +594,7 @@ const procImageSubsets: Process = {
586594 name: "encode image subsets",
587595 include: rules.extsImage,
588596 depends: [procDimensions.name],
589 version: 2,
597 version: 3,
590598 async run({ path, mediaFile, node }) {
591599 const { width, height } = UNWRAP(mediaFile.parseDimensions());
592600 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);
......@@ -638,6 +646,7 @@ const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({
638646 include: rules.extsVideo,
639647 enable: ffmpegBin != null,
640648 depends: [procDuration.name, procDimensions.name],
649 version: 4,
641650 async run({ path, mediaFile, node }) {
642651 if ((mediaFile.duration ?? 0) < 5) return;
643652 if (!mediaFile.dimensions) return;
......@@ -651,8 +660,11 @@ const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({
651660 subkey: `av1-${preset.id}`,
652661 async producer(dir) {
653662 const input = await videoArgsCache.getOrRun(path);
663 const dimensions = mediaFile.parseDimensions();
664 ASSERT(dimensions);
654665 const args = transcodeRules.getAv1VideoArgs(
655666 preset,
667 dimensions,
656668 UNWRAP(input.video, "frick on " + path + JSON.stringify(input)),
657669 dir,
658670 );
......@@ -672,7 +684,7 @@ const procVideoAudios = transcodeRules.audioFormats.map<Process>((preset) => ({
672684 include: rules.extsVideo,
673685 enable: ffmpegBin != null,
674686 depends: [procDuration.name, procDimensions.name],
675 version: 2,
687 version: 3,
676688 async run({ path, mediaFile, node }) {
677689 if ((mediaFile.duration ?? 0) < 5) return;
678690 if (!mediaFile.dimensions) return;
......@@ -703,7 +715,7 @@ const procDash: Process = {
703715 include: rules.extsVideo,
704716 enable: ffmpegBin != null,
705717 depends: [...procVideos, ...procVideoAudios].map((x) => x.name),
706 version: 4,
718 version: 7,
707719 async run({ path, mediaFile, node }) {
708720 if ((mediaFile.duration ?? 0) < 5) return;
709721 if (!mediaFile.dimensions) return;
......@@ -825,8 +837,8 @@ const processors = [
825837 crypto
826838 .createHash("sha1")
827839 .update(
828 process.run.toString()
829 + (process.version ? String(process.version) : ""),
840 process.run.toString() +
841 (process.version ? String(process.version) : ""),
830842 )
831843 .digest().buffer,
832844 ).reduce((a, b) => a ^ b),
......@@ -876,12 +888,12 @@ function skipBasename(basename: string): boolean {
876888 if (basename === ".date") return false;
877889
878890 return (
879 basename.startsWith(".")
891 basename.startsWith(".") ||
880892 // basename.startsWith("._") ||
881893 // basename.startsWith(".tmp") ||
882894 // basename === ".DS_Store" ||
883 || basename.toLowerCase() === "thumbs.db"
884 || basename.toLowerCase() === "desktop.ini"
895 basename.toLowerCase() === "thumbs.db" ||
896 basename.toLowerCase() === "desktop.ini"
885897 );
886898}
887899
......@@ -934,8 +946,8 @@ async function scrubLocationMetadata(
934946 "-s",
935947 path.toString(),
936948 ]);
937 hasLocation = videoCheck.includes("GPS")
938 || videoCheck.includes("Location");
949 hasLocation = videoCheck.includes("GPS") ||
950 videoCheck.includes("Location");
939951 args = [
940952 "-gps:all=",
941953 "-xmp:all=",
......@@ -951,10 +963,10 @@ async function scrubLocationMetadata(
951963 "-s",
952964 path.toString(),
953965 ]);
954 hasLocation = m4aCheck.includes("GPS")
955 || m4aCheck.includes("Location")
956 || m4aCheck.includes("Filename")
957 || m4aCheck.includes("Title");
966 hasLocation = m4aCheck.includes("GPS") ||
967 m4aCheck.includes("Location") ||
968 m4aCheck.includes("Filename") ||
969 m4aCheck.includes("Title");
958970
959971 if (hasLocation) {
960972 args = [
......@@ -1016,13 +1028,13 @@ async function scrubLocationMetadata(
10161028
10171029const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;
10181030
1031import * as async from "@clo/lib/async";
10191032import * as fs from "#sitegen/fs";
1033import * as subprocess from "@clo/lib/subprocess";
10201034import { Path } from "#sitegen/path";
1021import * as async from "@clo/lib/async";
1035import * as queue from "@clo/lib/queue";
10221036import * as log from "@clo/lib/log";
10231037import * as progress from "@clo/lib/progress";
1024import * as queue from "@clo/lib/queue";
1025import * as subprocess from "@clo/lib/subprocess";
10261038import * as ts from "@clo/lib/ts";
10271039
10281040import * as child_process from "node:child_process";
......@@ -1032,15 +1044,19 @@ import * as path from "node:path";
10321044import * as stream from "node:stream";
10331045import * 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";
10391047import { 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";
10411054import * 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";
10431056import * 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";
10461061import { 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 @@
11export async function main() {
22 const start = performance.now();
33 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]`,
56 });
67
78 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 {
1010 id: string;
1111 audioKbit: number;
1212}
13interface Dimensions {
14 width: number;
15 height: number;
16}
1317
1418export const videoFormats: Av1Preset[] = [
1519 {
......@@ -69,22 +73,11 @@ export const imagePresets = [
6973 "6",
7074 ],
7175 },
72 {
73 ext: ".avif",
74 args: [
75 "-c:v",
76 "@clo/libaom-av1",
77 "-crf",
78 "30",
79 "-pix_fmt",
80 "yuv420p10le",
81 ],
82 },
8376 {
8477 ext: ".jxl",
8578 args: [
8679 "-c:v",
87 "@clo/libjxl",
80 "libjxl",
8881 "-distance",
8982 "0.8",
9083 "-effort",
......@@ -140,15 +133,136 @@ export interface InputArgs {
140133export const opusFileName = "tmp.opus.webm";
141134export 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
143256export function getAv1VideoArgs(
144257 av1: Av1Preset,
258 source: Dimensions,
145259 videoInputArgs: string[],
146260 outDir: Path,
147261) {
148262 const { preset, mbitMax, crf, maxHeight, depth } = av1;
149263 const cmd = [...videoInputArgs];
150264
151 cmd.push("-c:v", "@clo/libsvtav1");
265 cmd.push("-c:v", "libsvtav1");
152266 cmd.push(
153267 "-svtav1-params",
154268 [
......@@ -163,8 +277,9 @@ export function getAv1VideoArgs(
163277 "scm=2", // ScreenContentMode, adaptive
164278 ].filter(Boolean).join(":"),
165279 );
166 if (maxHeight != null) {
167 cmd.push("-vf", `scale=-2:min(${maxHeight}\\,ih)`);
280 const filter = getConstantAspectScaleFilter(source, maxHeight);
281 if (filter) {
282 cmd.push("-vf", filter);
168283 }
169284
170285 cmd.push("-y");
......@@ -183,7 +298,7 @@ export function getOpusAudioArgs(
183298
184299 cmd.push("-vn");
185300
186 cmd.push("-c:a", "@clo/libopus");
301 cmd.push("-c:a", "libopus");
187302 cmd.push("-b:a", audioKbit + "k");
188303
189304 cmd.push("-y");
......@@ -215,16 +330,15 @@ export function getMpegDashArgs(
215330 cmd.push("-use_timeline", "1");
216331 cmd.push("-use_template", "1");
217332
218 // calculate actual dimensions per video
219 const videoAdaptationSets = videos
220 .map((_, i) => `id=${i},streams=${i}`)
221 .join(" ");
222
223 let adaptationSets = videoAdaptationSets;
333 let adaptationSets = videos.length > 0
334 ? `id=0,streams=${videos.map((_, i) => i).join(",")}`
335 : "";
224336 if (audios.length > 0) {
225 const audioId = videos.length;
337 const audioId = videos.length > 0 ? 1 : 0;
226338 const audioIndices = audios.map((_, i) => videos.length + i).join(",");
227 adaptationSets += ` id=${audioId},streams=${audioIndices}`;
339 adaptationSets += `${
340 adaptationSets ? " " : ""
341 }id=${audioId},streams=${audioIndices}`;
228342 }
229343
230344 cmd.push("-adaptation_sets", adaptationSets);
......@@ -264,16 +378,11 @@ export function getH264HlsArgs(
264378 const aIdx = i * 2 + 1;
265379 // deno-fmt-ignore
266380 cmd.push(
267 `-c:${vIdx}`,
268 "@clo/libx264",
269 `-b:v:${vIdx}`,
270 preset.videoBitrate,
271 `-filter:${vIdx}`,
272 `scale=-2:min(${preset.maxHeight}\\,ih)`,
273 `-c:${aIdx}`,
274 "aac",
275 `-b:a:${aIdx}`,
276 preset.audioBitrate,
381 `-c:${vIdx}`, "libx264",
382 `-b:v:${vIdx}`, preset.videoBitrate,
383 `-filter:${vIdx}`, `scale=-2:min(${preset.maxHeight}\\,ih)`,
384 `-c:${aIdx}`, "aac",
385 `-b:a:${aIdx}`, preset.audioBitrate,
277386 );
278387 });
279388
......@@ -290,12 +399,9 @@ export function getH264HlsArgs(
290399 hlsPresets.forEach((preset, i) => {
291400 // deno-fmt-ignore
292401 cmd.push(
293 `-c:v:${i}`,
294 "@clo/libx264",
295 `-b:v:${i}`,
296 preset.videoBitrate,
297 `-filter:v:${i}`,
298 `scale=-2:min(${preset.maxHeight}\\,ih)`,
402 `-c:v:${i}`, "libx264",
403 `-b:v:${i}`, preset.videoBitrate,
404 `-filter:v:${i}`, `scale=-2:min(${preset.maxHeight}\\,ih)`,
299405 );
300406 });
301407
......@@ -307,20 +413,13 @@ export function getH264HlsArgs(
307413
308414 // deno-fmt-ignore
309415 cmd.push(
310 "-f",
311 "hls",
312 "-hls_time",
313 "2",
314 "-hls_list_size",
315 "0",
316 "-hls_segment_type",
317 "fmp4",
318 "-hls_allow_cache",
319 "1",
320 "-master_pl_name",
321 "master.m3u8",
322 "-hls_segment_filename",
323 "hls.%v.%03d.ts",
416 "-f", "hls",
417 "-hls_time", "2",
418 "-hls_list_size", "0",
419 "-hls_segment_type", "fmp4",
420 "-hls_allow_cache", "1",
421 "-master_pl_name", "master.m3u8",
422 "-hls_segment_filename", "hls.%v.%03d.ts",
324423 "-y",
325424 outDir.join("hls.%v.m3u8").toString(),
326425 );