| author | |
| committer | |
| log | ec1448f737d8aed1c7f7fdd8a02fd6232b19eff8 |
| tree | f29d32282eaa2be9ed5880be4241863c2f628326 |
| parent | 907e9083b681531d3be2f8ba0cafc63dede6e61d |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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 | 109 | : process.platform === "darwin" |
| 110 | 110 | ? [ |
| 111 | 111 | "/Library/Fonts", |
| 112 | path.posix.join(home, "@clo/library/Fonts"), | |
| 112 | path.posix.join(home, "/Library/Fonts"), | |
| 113 | 113 | ] |
| 114 | 114 | : [ |
| 115 | 115 | "/usr/share/fonts", |
lib/subprocess/ffmpeg.ts+1-1| ... | ... | @@ -21,7 +21,7 @@ export interface SpawnOptions { |
| 21 | 21 | * |
| 22 | 22 | * ```ts |
| 23 | 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 | 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 | 15 | export async function main() { |
| 16 | 16 | const start = performance.now(); |
| 17 | 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 | }); |
| 20 | 21 | |
| 21 | 22 | const promises = new async.PromiseAggregator(); |
| ... | ... | @@ -62,10 +63,10 @@ export async function main() { |
| 62 | 63 | } |
| 63 | 64 | |
| 64 | 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 | 67 | // 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() | |
| 69 | 70 | ) { |
| 70 | 71 | promises.push(updateMetadata({ path, publicPath, stat, mediaFile })); |
| 71 | 72 | } else { |
| ... | ... | @@ -100,9 +101,9 @@ export async function main() { |
| 100 | 101 | }); |
| 101 | 102 | let date = stat.mtime; |
| 102 | 103 | 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 | |
| 106 | 107 | ) { |
| 107 | 108 | date = mediaFile.date; |
| 108 | 109 | console.warn( |
| ... | ... | @@ -136,7 +137,7 @@ export async function main() { |
| 136 | 137 | }, |
| 137 | 138 | () => ({ priority: -1 }), |
| 138 | 139 | ); |
| 139 | const queueFileProcessor = queue.wrap(async function({ | |
| 140 | const queueFileProcessor = queue.wrap(async function ({ | |
| 140 | 141 | path, |
| 141 | 142 | stat, |
| 142 | 143 | mediaFile, |
| ... | ... | @@ -189,7 +190,9 @@ export async function main() { |
| 189 | 190 | node.passive = true; |
| 190 | 191 | |
| 191 | 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 | 196 | if (possible.length === 0) return; |
| 194 | 197 | ASSERT(possible.length < 16, "too many bits"); |
| 195 | 198 | |
| ... | ... | @@ -224,7 +227,9 @@ export async function main() { |
| 224 | 227 | .join(";"), |
| 225 | 228 | ); |
| 226 | 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 | } |
| 229 | 234 | |
| 230 | 235 | // Queue needed processors. |
| ... | ... | @@ -289,11 +294,14 @@ export async function main() { |
| 289 | 294 | const children = dir.getChildren(); |
| 290 | 295 | |
| 291 | 296 | // 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 ?? ""; | |
| 293 | 300 | |
| 294 | 301 | // dirsort |
| 295 | 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 | 305 | if (dirSortRaw) { |
| 298 | 306 | dirsort = dirSortRaw |
| 299 | 307 | .split("\n") |
| ... | ... | @@ -409,9 +417,9 @@ export async function main() { |
| 409 | 417 | } else console.warn("Missing SOT token"); |
| 410 | 418 | |
| 411 | 419 | 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", | |
| 415 | 423 | ); |
| 416 | 424 | |
| 417 | 425 | const { duration, count } = MediaFile.db |
| ... | ... | @@ -434,12 +442,12 @@ export async function main() { |
| 434 | 442 | |
| 435 | 443 | console.info(); |
| 436 | 444 | 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`, | |
| 443 | 451 | ); |
| 444 | 452 | } |
| 445 | 453 | |
| ... | ... | @@ -586,7 +594,7 @@ const procImageSubsets: Process = { |
| 586 | 594 | name: "encode image subsets", |
| 587 | 595 | include: rules.extsImage, |
| 588 | 596 | depends: [procDimensions.name], |
| 589 | version: 2, | |
| 597 | version: 3, | |
| 590 | 598 | async run({ path, mediaFile, node }) { |
| 591 | 599 | const { width, height } = UNWRAP(mediaFile.parseDimensions()); |
| 592 | 600 | const targetSizes = transcodeRules.imageSizes.filter((w) => w < width); |
| ... | ... | @@ -638,6 +646,7 @@ const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({ |
| 638 | 646 | include: rules.extsVideo, |
| 639 | 647 | enable: ffmpegBin != null, |
| 640 | 648 | depends: [procDuration.name, procDimensions.name], |
| 649 | version: 4, | |
| 641 | 650 | async run({ path, mediaFile, node }) { |
| 642 | 651 | if ((mediaFile.duration ?? 0) < 5) return; |
| 643 | 652 | if (!mediaFile.dimensions) return; |
| ... | ... | @@ -651,8 +660,11 @@ const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({ |
| 651 | 660 | subkey: `av1-${preset.id}`, |
| 652 | 661 | async producer(dir) { |
| 653 | 662 | const input = await videoArgsCache.getOrRun(path); |
| 663 | const dimensions = mediaFile.parseDimensions(); | |
| 664 | ASSERT(dimensions); | |
| 654 | 665 | const args = transcodeRules.getAv1VideoArgs( |
| 655 | 666 | preset, |
| 667 | dimensions, | |
| 656 | 668 | UNWRAP(input.video, "frick on " + path + JSON.stringify(input)), |
| 657 | 669 | dir, |
| 658 | 670 | ); |
| ... | ... | @@ -672,7 +684,7 @@ const procVideoAudios = transcodeRules.audioFormats.map<Process>((preset) => ({ |
| 672 | 684 | include: rules.extsVideo, |
| 673 | 685 | enable: ffmpegBin != null, |
| 674 | 686 | depends: [procDuration.name, procDimensions.name], |
| 675 | version: 2, | |
| 687 | version: 3, | |
| 676 | 688 | async run({ path, mediaFile, node }) { |
| 677 | 689 | if ((mediaFile.duration ?? 0) < 5) return; |
| 678 | 690 | if (!mediaFile.dimensions) return; |
| ... | ... | @@ -703,7 +715,7 @@ const procDash: Process = { |
| 703 | 715 | include: rules.extsVideo, |
| 704 | 716 | enable: ffmpegBin != null, |
| 705 | 717 | depends: [...procVideos, ...procVideoAudios].map((x) => x.name), |
| 706 | version: 4, | |
| 718 | version: 7, | |
| 707 | 719 | async run({ path, mediaFile, node }) { |
| 708 | 720 | if ((mediaFile.duration ?? 0) < 5) return; |
| 709 | 721 | if (!mediaFile.dimensions) return; |
| ... | ... | @@ -825,8 +837,8 @@ const processors = [ |
| 825 | 837 | crypto |
| 826 | 838 | .createHash("sha1") |
| 827 | 839 | .update( |
| 828 | process.run.toString() | |
| 829 | + (process.version ? String(process.version) : ""), | |
| 840 | process.run.toString() + | |
| 841 | (process.version ? String(process.version) : ""), | |
| 830 | 842 | ) |
| 831 | 843 | .digest().buffer, |
| 832 | 844 | ).reduce((a, b) => a ^ b), |
| ... | ... | @@ -876,12 +888,12 @@ function skipBasename(basename: string): boolean { |
| 876 | 888 | if (basename === ".date") return false; |
| 877 | 889 | |
| 878 | 890 | return ( |
| 879 | basename.startsWith(".") | |
| 891 | basename.startsWith(".") || | |
| 880 | 892 | // basename.startsWith("._") || |
| 881 | 893 | // basename.startsWith(".tmp") || |
| 882 | 894 | // basename === ".DS_Store" || |
| 883 | || basename.toLowerCase() === "thumbs.db" | |
| 884 | || basename.toLowerCase() === "desktop.ini" | |
| 895 | basename.toLowerCase() === "thumbs.db" || | |
| 896 | basename.toLowerCase() === "desktop.ini" | |
| 885 | 897 | ); |
| 886 | 898 | } |
| 887 | 899 | |
| ... | ... | @@ -934,8 +946,8 @@ async function scrubLocationMetadata( |
| 934 | 946 | "-s", |
| 935 | 947 | path.toString(), |
| 936 | 948 | ]); |
| 937 | hasLocation = videoCheck.includes("GPS") | |
| 938 | || videoCheck.includes("Location"); | |
| 949 | hasLocation = videoCheck.includes("GPS") || | |
| 950 | videoCheck.includes("Location"); | |
| 939 | 951 | args = [ |
| 940 | 952 | "-gps:all=", |
| 941 | 953 | "-xmp:all=", |
| ... | ... | @@ -951,10 +963,10 @@ async function scrubLocationMetadata( |
| 951 | 963 | "-s", |
| 952 | 964 | path.toString(), |
| 953 | 965 | ]); |
| 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"); | |
| 958 | 970 | |
| 959 | 971 | if (hasLocation) { |
| 960 | 972 | args = [ |
| ... | ... | @@ -1016,13 +1028,13 @@ async function scrubLocationMetadata( |
| 1016 | 1028 | |
| 1017 | 1029 | const monthMilliseconds = 30 * 24 * 60 * 60 * 1000; |
| 1018 | 1030 | |
| 1031 | import * as async from "@clo/lib/async"; | |
| 1019 | 1032 | import * as fs from "#sitegen/fs"; |
| 1033 | import * as subprocess from "@clo/lib/subprocess"; | |
| 1020 | 1034 | import { Path } from "#sitegen/path"; |
| 1021 | import * as async from "@clo/lib/async"; | |
| 1035 | import * as queue from "@clo/lib/queue"; | |
| 1022 | 1036 | import * as log from "@clo/lib/log"; |
| 1023 | 1037 | import * as progress from "@clo/lib/progress"; |
| 1024 | import * as queue from "@clo/lib/queue"; | |
| 1025 | import * as subprocess from "@clo/lib/subprocess"; | |
| 1026 | 1038 | import * as ts from "@clo/lib/ts"; |
| 1027 | 1039 | |
| 1028 | 1040 | import * as child_process from "node:child_process"; |
| ... | ... | @@ -1032,15 +1044,19 @@ import * as path from "node:path"; |
| 1032 | 1044 | import * as stream from "node:stream"; |
| 1033 | 1045 | import * as zlib from "node:zlib"; |
| 1034 | 1046 | |
| 1035 | import { formatDate, formatDurationLong, formatSize } from "#src/file-viewer/format.ts"; | |
| 1036 | import * as highlight from "#src/file-viewer/highlight.ts"; | |
| 1037 | import * as derived from "#src/file-viewer/models/derived.ts"; | |
| 1038 | import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts"; | |
| 1039 | 1047 | import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts"; |
| 1040 | import * as rsync from "#src/file-viewer/rsync.ts"; | |
| 1048 | import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts"; | |
| 1049 | import { | |
| 1050 | formatDate, | |
| 1051 | formatDurationLong, | |
| 1052 | formatSize, | |
| 1053 | } from "#src/file-viewer/format.ts"; | |
| 1041 | 1054 | import * as rules from "#src/file-viewer/rules.ts"; |
| 1042 | import * as transcodeRules from "#src/file-viewer/transcode-rules.ts"; | |
| 1055 | import * as highlight from "#src/file-viewer/highlight.ts"; | |
| 1043 | 1056 | import * as ffmpeg from "@clo/lib/subprocess/ffmpeg"; |
| 1057 | import * as rsync from "#src/file-viewer/rsync.ts"; | |
| 1058 | import * as derived from "#src/file-viewer/models/derived.ts"; | |
| 1059 | import * as transcodeRules from "#src/file-viewer/transcode-rules.ts"; | |
| 1044 | 1060 | |
| 1045 | import { ASSERT, UNWRAP } from "@clo/lib/assert"; | |
| 1046 | 1061 | import { rawFileRoot as root } from "../paths.ts"; |
| 1062 | import { ASSERT, UNWRAP } from "@clo/lib/assert"; |
src/file-viewer/bin/file-trim.ts+2-1| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | export async function main() { |
| 2 | 2 | const start = performance.now(); |
| 3 | 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 | }); |
| 6 | 7 | |
| 7 | 8 | const orphaned = derived.findOrphanedRoots(); |
src/file-viewer/transcode-rules.test.ts created+352| ... | ... | @@ -0,0 +1,352 @@ |
| 1 | test("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 | ||
| 6 | test("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 | ||
| 19 | test("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 | ||
| 30 | test("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 | ||
| 48 | test("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 | ||
| 108 | test("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 | ||
| 181 | test("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 | ||
| 229 | import { test } from "vitest"; | |
| 230 | import assert from "node:assert"; | |
| 231 | import { Path } from "#sitegen/path"; | |
| 232 | import { | |
| 233 | getAv1VideoArgs, | |
| 234 | getConstantAspectScaleFilter, | |
| 235 | getMpegDashArgs, | |
| 236 | videoFormats, | |
| 237 | } from "./transcode-rules.ts"; | |
| 238 | ||
| 239 | function assertString(value: string | undefined | null): string { | |
| 240 | assert.equal(typeof value, "string"); | |
| 241 | return value; | |
| 242 | } | |
| 243 | ||
| 244 | function 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 | ||
| 253 | function 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 | ||
| 273 | const EXACT_ERROR_THRESHOLD = 0.0005; | |
| 274 | ||
| 275 | function 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 | ||
| 319 | function 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 | ||
| 328 | function 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 | ||
| 336 | function 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 | 10 | id: string; |
| 11 | 11 | audioKbit: number; |
| 12 | 12 | } |
| 13 | interface Dimensions { | |
| 14 | width: number; | |
| 15 | height: number; | |
| 16 | } | |
| 13 | 17 | |
| 14 | 18 | export const videoFormats: Av1Preset[] = [ |
| 15 | 19 | { |
| ... | ... | @@ -69,22 +73,11 @@ export const imagePresets = [ |
| 69 | 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 | 77 | ext: ".jxl", |
| 85 | 78 | args: [ |
| 86 | 79 | "-c:v", |
| 87 | "@clo/libjxl", | |
| 80 | "libjxl", | |
| 88 | 81 | "-distance", |
| 89 | 82 | "0.8", |
| 90 | 83 | "-effort", |
| ... | ... | @@ -140,15 +133,136 @@ export interface InputArgs { |
| 140 | 133 | export const opusFileName = "tmp.opus.webm"; |
| 141 | 134 | export const av1FileName = "tmp.av1.mp4"; |
| 142 | 135 | |
| 136 | function 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 | ||
| 145 | function 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 | ||
| 156 | function 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 | ||
| 176 | function 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 | ||
| 242 | export 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 | ||
| 143 | 256 | export function getAv1VideoArgs( |
| 144 | 257 | av1: Av1Preset, |
| 258 | source: Dimensions, | |
| 145 | 259 | videoInputArgs: string[], |
| 146 | 260 | outDir: Path, |
| 147 | 261 | ) { |
| 148 | 262 | const { preset, mbitMax, crf, maxHeight, depth } = av1; |
| 149 | 263 | const cmd = [...videoInputArgs]; |
| 150 | 264 | |
| 151 | cmd.push("-c:v", "@clo/libsvtav1"); | |
| 265 | cmd.push("-c:v", "libsvtav1"); | |
| 152 | 266 | cmd.push( |
| 153 | 267 | "-svtav1-params", |
| 154 | 268 | [ |
| ... | ... | @@ -163,8 +277,9 @@ export function getAv1VideoArgs( |
| 163 | 277 | "scm=2", // ScreenContentMode, adaptive |
| 164 | 278 | ].filter(Boolean).join(":"), |
| 165 | 279 | ); |
| 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); | |
| 168 | 283 | } |
| 169 | 284 | |
| 170 | 285 | cmd.push("-y"); |
| ... | ... | @@ -183,7 +298,7 @@ export function getOpusAudioArgs( |
| 183 | 298 | |
| 184 | 299 | cmd.push("-vn"); |
| 185 | 300 | |
| 186 | cmd.push("-c:a", "@clo/libopus"); | |
| 301 | cmd.push("-c:a", "libopus"); | |
| 187 | 302 | cmd.push("-b:a", audioKbit + "k"); |
| 188 | 303 | |
| 189 | 304 | cmd.push("-y"); |
| ... | ... | @@ -215,16 +330,15 @@ export function getMpegDashArgs( |
| 215 | 330 | cmd.push("-use_timeline", "1"); |
| 216 | 331 | cmd.push("-use_template", "1"); |
| 217 | 332 | |
| 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 | : ""; | |
| 224 | 336 | if (audios.length > 0) { |
| 225 | const audioId = videos.length; | |
| 337 | const audioId = videos.length > 0 ? 1 : 0; | |
| 226 | 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 | } |
| 229 | 343 | |
| 230 | 344 | cmd.push("-adaptation_sets", adaptationSets); |
| ... | ... | @@ -264,16 +378,11 @@ export function getH264HlsArgs( |
| 264 | 378 | const aIdx = i * 2 + 1; |
| 265 | 379 | // deno-fmt-ignore |
| 266 | 380 | 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, | |
| 277 | 386 | ); |
| 278 | 387 | }); |
| 279 | 388 | |
| ... | ... | @@ -290,12 +399,9 @@ export function getH264HlsArgs( |
| 290 | 399 | hlsPresets.forEach((preset, i) => { |
| 291 | 400 | // deno-fmt-ignore |
| 292 | 401 | 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)`, | |
| 299 | 405 | ); |
| 300 | 406 | }); |
| 301 | 407 | |
| ... | ... | @@ -307,20 +413,13 @@ export function getH264HlsArgs( |
| 307 | 413 | |
| 308 | 414 | // deno-fmt-ignore |
| 309 | 415 | 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", | |
| 324 | 423 | "-y", |
| 325 | 424 | outDir.join("hls.%v.m3u8").toString(), |
| 326 | 425 | ); |