diff --git a/framework/font.ts b/framework/font.ts index 7381a3684268bc704ea8c6a05bafe3216ef12282..8814b1aab17ac32dcb01165f2d91c0a5d9a48f66 100644 --- a/framework/font.ts +++ b/framework/font.ts @@ -109,7 +109,7 @@ export async function fetchFont(ref: progress.Ref, name: string, sources: string : process.platform === "darwin" ? [ "/Library/Fonts", - path.posix.join(home, "@clo/library/Fonts"), + path.posix.join(home, "/Library/Fonts"), ] : [ "/usr/share/fonts", diff --git a/lib/subprocess/ffmpeg.ts b/lib/subprocess/ffmpeg.ts index 5f09a26ca6ff9173b3f07e9812771f3c7c30d752..d9e526ad1e5ba3ff63aa31cbde1a09fd7077a479 100644 --- a/lib/subprocess/ffmpeg.ts +++ b/lib/subprocess/ffmpeg.ts @@ -21,7 +21,7 @@ export interface SpawnOptions { * * ```ts * await ffmpeg.spawn({ - * cmd: ["-i", "hello.mov", "-c:v", "@clo/libsvtav1", "hello.mp4"], + * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"], * progress: progress.start("encode hello.mov"), * }); * ``` diff --git a/src/file-viewer/bin/file-scan.ts b/src/file-viewer/bin/file-scan.ts index 3cb5a824f2b1cdb13a15a91a81bac0b386a1cf2a..0deb9ad522931ef17dc5701e84333675c51cbe02 100644 --- a/src/file-viewer/bin/file-scan.ts +++ b/src/file-viewer/bin/file-scan.ts @@ -15,7 +15,8 @@ const sotToken = process.env.CLOVER_SOT_KEY; export async function main() { const start = performance.now(); using _ = log.startWidget({ - format: ({ now }) => `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`, + format: ({ now }) => + `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`, }); const promises = new async.PromiseAggregator(); @@ -62,10 +63,10 @@ export async function main() { } if ( - !mediaFile // All processes must be performed if there is no file. + !mediaFile || // All processes must be performed if there is no file. // Rerun all processors if it changed - || stat.size !== mediaFile.size - || stat.mtime.getTime() !== mediaFile.date.getTime() + stat.size !== mediaFile.size || + stat.mtime.getTime() !== mediaFile.date.getTime() ) { promises.push(updateMetadata({ path, publicPath, stat, mediaFile })); } else { @@ -100,9 +101,9 @@ export async function main() { }); let date = stat.mtime; if ( - mediaFile - && mediaFile.date.getTime() < stat.mtime.getTime() - && Date.now() - stat.mtime.getTime() < monthMilliseconds + mediaFile && + mediaFile.date.getTime() < stat.mtime.getTime() && + Date.now() - stat.mtime.getTime() < monthMilliseconds ) { date = mediaFile.date; console.warn( @@ -136,7 +137,7 @@ export async function main() { }, () => ({ priority: -1 }), ); - const queueFileProcessor = queue.wrap(async function({ + const queueFileProcessor = queue.wrap(async function ({ path, stat, mediaFile, @@ -189,7 +190,9 @@ export async function main() { node.passive = true; const ext = mediaFile.extensionNonEmpty.toLowerCase(); - let possible = processors.filter((p) => p.include ? p.include.has(ext) : !p.exclude?.has(ext)); + let possible = processors.filter((p) => + p.include ? p.include.has(ext) : !p.exclude?.has(ext) + ); if (possible.length === 0) return; ASSERT(possible.length < 16, "too many bits"); @@ -224,7 +227,9 @@ export async function main() { .join(";"), ); } else { - possible = order.map(({ id }) => UNWRAP(possible.find((p) => p.id === id))); + possible = order.map(({ id }) => + UNWRAP(possible.find((p) => p.id === id)) + ); } // Queue needed processors. @@ -289,11 +294,14 @@ export async function main() { const children = dir.getChildren(); // readme.txt - const readmeContent = children.find((x) => x.basename === "readme.txt")?.contents ?? ""; + const readmeContent = children.find((x) => + x.basename === "readme.txt" + )?.contents ?? ""; // dirsort let dirsort: string[] | null = null; - const dirSortRaw = children.find((x) => x.basename === ".dirsort")?.contents ?? ""; + const dirSortRaw = + children.find((x) => x.basename === ".dirsort")?.contents ?? ""; if (dirSortRaw) { dirsort = dirSortRaw .split("\n") @@ -409,9 +417,9 @@ export async function main() { } else console.warn("Missing SOT token"); console.info( - "Updated file viewer index in \x1b[1m" - + ((performance.now() - start) / 1000).toFixed(1) - + "s\x1b[0m", + "Updated file viewer index in \x1b[1m" + + ((performance.now() - start) / 1000).toFixed(1) + + "s\x1b[0m", ); const { duration, count } = MediaFile.db @@ -434,12 +442,12 @@ export async function main() { console.info(); console.info( - "Global Stats:\n" - + `- File Count: \x1b[1m${count}\x1b[0m\n` - + `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n` - + `- Media Duration: \x1b[1m${formatDurationLong(duration)}\x1b[0m\n` - + `- Canonical Size: \x1b[1m${formatSize(canonicalSize)}\x1b[0m\n` - + `- Derived Size: \x1b[1m${formatSize(derivedSize)}\x1b[0m\n`, + "Global Stats:\n" + + `- File Count: \x1b[1m${count}\x1b[0m\n` + + `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n` + + `- Media Duration: \x1b[1m${formatDurationLong(duration)}\x1b[0m\n` + + `- Canonical Size: \x1b[1m${formatSize(canonicalSize)}\x1b[0m\n` + + `- Derived Size: \x1b[1m${formatSize(derivedSize)}\x1b[0m\n`, ); } @@ -586,7 +594,7 @@ const procImageSubsets: Process = { name: "encode image subsets", include: rules.extsImage, depends: [procDimensions.name], - version: 2, + version: 3, async run({ path, mediaFile, node }) { const { width, height } = UNWRAP(mediaFile.parseDimensions()); const targetSizes = transcodeRules.imageSizes.filter((w) => w < width); @@ -638,6 +646,7 @@ const procVideos = transcodeRules.videoFormats.map((preset) => ({ include: rules.extsVideo, enable: ffmpegBin != null, depends: [procDuration.name, procDimensions.name], + version: 4, async run({ path, mediaFile, node }) { if ((mediaFile.duration ?? 0) < 5) return; if (!mediaFile.dimensions) return; @@ -651,8 +660,11 @@ const procVideos = transcodeRules.videoFormats.map((preset) => ({ subkey: `av1-${preset.id}`, async producer(dir) { const input = await videoArgsCache.getOrRun(path); + const dimensions = mediaFile.parseDimensions(); + ASSERT(dimensions); const args = transcodeRules.getAv1VideoArgs( preset, + dimensions, UNWRAP(input.video, "frick on " + path + JSON.stringify(input)), dir, ); @@ -672,7 +684,7 @@ const procVideoAudios = transcodeRules.audioFormats.map((preset) => ({ include: rules.extsVideo, enable: ffmpegBin != null, depends: [procDuration.name, procDimensions.name], - version: 2, + version: 3, async run({ path, mediaFile, node }) { if ((mediaFile.duration ?? 0) < 5) return; if (!mediaFile.dimensions) return; @@ -703,7 +715,7 @@ const procDash: Process = { include: rules.extsVideo, enable: ffmpegBin != null, depends: [...procVideos, ...procVideoAudios].map((x) => x.name), - version: 4, + version: 7, async run({ path, mediaFile, node }) { if ((mediaFile.duration ?? 0) < 5) return; if (!mediaFile.dimensions) return; @@ -825,8 +837,8 @@ const processors = [ crypto .createHash("sha1") .update( - process.run.toString() - + (process.version ? String(process.version) : ""), + process.run.toString() + + (process.version ? String(process.version) : ""), ) .digest().buffer, ).reduce((a, b) => a ^ b), @@ -876,12 +888,12 @@ function skipBasename(basename: string): boolean { if (basename === ".date") return false; return ( - basename.startsWith(".") + basename.startsWith(".") || // basename.startsWith("._") || // basename.startsWith(".tmp") || // basename === ".DS_Store" || - || basename.toLowerCase() === "thumbs.db" - || basename.toLowerCase() === "desktop.ini" + basename.toLowerCase() === "thumbs.db" || + basename.toLowerCase() === "desktop.ini" ); } @@ -934,8 +946,8 @@ async function scrubLocationMetadata( "-s", path.toString(), ]); - hasLocation = videoCheck.includes("GPS") - || videoCheck.includes("Location"); + hasLocation = videoCheck.includes("GPS") || + videoCheck.includes("Location"); args = [ "-gps:all=", "-xmp:all=", @@ -951,10 +963,10 @@ async function scrubLocationMetadata( "-s", path.toString(), ]); - hasLocation = m4aCheck.includes("GPS") - || m4aCheck.includes("Location") - || m4aCheck.includes("Filename") - || m4aCheck.includes("Title"); + hasLocation = m4aCheck.includes("GPS") || + m4aCheck.includes("Location") || + m4aCheck.includes("Filename") || + m4aCheck.includes("Title"); if (hasLocation) { args = [ @@ -1016,13 +1028,13 @@ async function scrubLocationMetadata( const monthMilliseconds = 30 * 24 * 60 * 60 * 1000; +import * as async from "@clo/lib/async"; import * as fs from "#sitegen/fs"; +import * as subprocess from "@clo/lib/subprocess"; import { Path } from "#sitegen/path"; -import * as async from "@clo/lib/async"; +import * as queue from "@clo/lib/queue"; import * as log from "@clo/lib/log"; import * as progress from "@clo/lib/progress"; -import * as queue from "@clo/lib/queue"; -import * as subprocess from "@clo/lib/subprocess"; import * as ts from "@clo/lib/ts"; import * as child_process from "node:child_process"; @@ -1032,15 +1044,19 @@ import * as path from "node:path"; import * as stream from "node:stream"; import * as zlib from "node:zlib"; -import { formatDate, formatDurationLong, formatSize } from "#src/file-viewer/format.ts"; -import * as highlight from "#src/file-viewer/highlight.ts"; -import * as derived from "#src/file-viewer/models/derived.ts"; -import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts"; import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts"; -import * as rsync from "#src/file-viewer/rsync.ts"; +import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts"; +import { + formatDate, + formatDurationLong, + formatSize, +} from "#src/file-viewer/format.ts"; import * as rules from "#src/file-viewer/rules.ts"; -import * as transcodeRules from "#src/file-viewer/transcode-rules.ts"; +import * as highlight from "#src/file-viewer/highlight.ts"; import * as ffmpeg from "@clo/lib/subprocess/ffmpeg"; +import * as rsync from "#src/file-viewer/rsync.ts"; +import * as derived from "#src/file-viewer/models/derived.ts"; +import * as transcodeRules from "#src/file-viewer/transcode-rules.ts"; -import { ASSERT, UNWRAP } from "@clo/lib/assert"; import { rawFileRoot as root } from "../paths.ts"; +import { ASSERT, UNWRAP } from "@clo/lib/assert"; diff --git a/src/file-viewer/bin/file-trim.ts b/src/file-viewer/bin/file-trim.ts index 7f11f3378120f254a0f22ab59182ff10de2b33c3..a7780d8195b90c0e4b1ffac7b016a955ccfe1e90 100644 --- a/src/file-viewer/bin/file-trim.ts +++ b/src/file-viewer/bin/file-trim.ts @@ -1,7 +1,8 @@ export async function main() { const start = performance.now(); using _ = log.startWidget({ - format: (now) => `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`, + format: (now) => + `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`, }); const orphaned = derived.findOrphanedRoots(); diff --git a/src/file-viewer/transcode-rules.test.ts b/src/file-viewer/transcode-rules.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e49bafa6581c4941c9e9aee3bc1a55e6046dbb6 --- /dev/null +++ b/src/file-viewer/transcode-rules.test.ts @@ -0,0 +1,352 @@ +test("getConstantAspectScaleFilter picks one square-pixel ratio for the ladder", () => { + const filter = getConstantAspectScaleFilter({ width: 1920, height: 817 }, 600); + assert.equal(filter, "scale=1410:600"); +}); + +test("getAv1VideoArgs uses constant-aspect scaling for downscaled renditions", () => { + const args = getAv1VideoArgs( + { id: "al", preset: 2, mbitMax: 1.25, crf: 40, maxHeight: 600 }, + { width: 1920, height: 817 }, + ["-i", "/tmp/input.mp4"], + new Path("/tmp/out"), + ); + + assert.equal(args[args.indexOf("-vf") + 1], "scale=1410:600"); + assert.equal(args[args.indexOf("-y")], "-y"); + assert.equal(args.at(-1), "/tmp/out/tmp.av1.mp4"); +}); + +test("getAv1VideoArgs also snaps top-quality renditions onto the same exact ratio", () => { + const args = getAv1VideoArgs( + { id: "au", preset: 1, mbitMax: 30, crf: 28, depth: 10 }, + { width: 1920, height: 817 }, + ["-i", "/tmp/input.mp4"], + new Path("/tmp/out"), + ); + + assert.equal(args[args.indexOf("-vf") + 1], "scale=1880:800"); +}); + +test("getMpegDashArgs keeps all video streams in one adaptation set", () => { + const args = getMpegDashArgs( + [ + new Path("/tmp/av1-1.mp4"), + new Path("/tmp/av1-2.mp4"), + new Path("/tmp/av1-3.mp4"), + ], + [ + new Path("/tmp/audio-1.webm"), + new Path("/tmp/audio-2.webm"), + ], + new Path("/tmp/dash"), + ); + + const adaptationSets = args[args.indexOf("-adaptation_sets") + 1]; + assert.equal(adaptationSets, "id=0,streams=0,1,2 id=1,streams=3,4"); +}); + +test("all AV1 presets share one exact square-pixel ratio across many source sizes", () => { + const sources = [ + { width: 3840, height: 2160 }, + { width: 1920, height: 1080 }, + { width: 1920, height: 817 }, + { width: 1440, height: 1080 }, + { width: 1280, height: 720 }, + { width: 1280, height: 534 }, + { width: 1080, height: 1920 }, + { width: 1080, height: 1350 }, + { width: 1024, height: 768 }, + { width: 853, height: 480 }, + { width: 800, height: 800 }, + { width: 721, height: 405 }, + { width: 640, height: 360 }, + { width: 426, height: 240 }, + ]; + + for (const source of sources) { + let expectedRatio: number | null = null; + for (const preset of videoFormats) { + const args = getAv1VideoArgs( + preset, + source, + ["-i", "/tmp/input.mp4"], + new Path("/tmp/out"), + ); + const vfIndex = args.indexOf("-vf"); + const filter = vfIndex === -1 ? null : args[vfIndex + 1]; + const dims = filter == null + ? source + : parseScaleFilter(assertString(filter)); + + assert.ok(dims.width > 0); + assert.ok(dims.height > 0); + assert.equal(dims.width % 2, 0); + assert.equal(dims.height % 2, 0); + assert.ok(dims.width <= source.width); + assert.ok(dims.height <= source.height); + if (preset.maxHeight != null) { + assert.ok( + dims.height <= preset.maxHeight, + `${source.width}x${source.height} exceeded maxHeight ${preset.maxHeight} with ${dims.width}x${dims.height}`, + ); + } + + const ratio = dims.width / dims.height; + if (expectedRatio == null) { + expectedRatio = ratio; + } else { + assert.equal( + ratio, + expectedRatio, + `${source.width}x${source.height} produced mismatched ladder ratio ${dims.width}x${dims.height}`, + ); + } + } + } +}); + +test("canonical ratio sweep matches the best feasible exact ratio", () => { + for (let width = 320; width <= 4096; width += 113) { + for (let height = 240; height <= 3072; height += 97) { + const source = { width, height }; + const actualDimsByPreset: Array<{ width: number; height: number }> = []; + let referenceDims: { width: number; height: number } | null = null; + + for (const preset of videoFormats) { + const args = getAv1VideoArgs( + preset, + source, + ["-i", "/tmp/input.mp4"], + new Path("/tmp/out"), + ); + const vfIndex = args.indexOf("-vf"); + const filter = vfIndex === -1 ? null : args[vfIndex + 1]; + const dims = filter == null + ? source + : parseScaleFilter(assertString(filter)); + + actualDimsByPreset.push(dims); + + if (referenceDims == null) { + referenceDims = dims; + } else { + assert.equal( + dims.width * referenceDims.height, + referenceDims.width * dims.height, + `${width}x${height} produced inconsistent exact ratios within the ladder`, + ); + } + } + + const actualRatio = reduceRatio(referenceDims!); + const actualScore = getCandidateScore(source, actualRatio); + assert.ok(actualScore, `${width}x${height} produced an infeasible ratio`); + const feasibleCandidates = getFeasibleCandidates(source); + const candidatesWithinThreshold = feasibleCandidates.filter((candidate) => + candidate.error <= EXACT_ERROR_THRESHOLD + ); + + if (candidatesWithinThreshold.length > 0) { + assert.ok( + actualScore.error <= EXACT_ERROR_THRESHOLD, + `${width}x${height} should have found a ratio within the allowed drift threshold`, + ); + } else { + const bestFallbackError = Math.min( + ...feasibleCandidates.map((candidate) => candidate.error), + ); + assert.equal( + actualScore.error, + bestFallbackError, + `${width}x${height} did not pick a minimum-drift fallback ratio`, + ); + } + + for (let i = 0; i < videoFormats.length; i++) { + const expectedDims = getExpectedDimensionsForPreset( + source, + actualRatio, + videoFormats[i].maxHeight, + ); + assert.deepEqual( + actualDimsByPreset[i], + expectedDims, + `${width}x${height} produced unexpected dimensions for preset ${videoFormats[i].id}`, + ); + } + } + } +}); + +test("dense sweep never produces mismatched ladder aspect ratios", () => { + for (let width = 2; width <= 2048; width++) { + for (let height = 2; height <= 2048; height++) { + const source = { width, height }; + let referenceDims: { width: number; height: number } | null = null; + + for (const preset of videoFormats) { + const args = getAv1VideoArgs( + preset, + source, + ["-i", "/tmp/input.mp4"], + new Path("/tmp/out"), + ); + const vfIndex = args.indexOf("-vf"); + const filter = vfIndex === -1 ? null : args[vfIndex + 1]; + const dims = filter == null + ? source + : parseScaleFilter(assertString(filter)); + + assert.ok(dims.width >= 2, `${width}x${height} produced width < 2`); + assert.ok(dims.height >= 2, `${width}x${height} produced height < 2`); + if (filter != null) { + assert.equal(dims.width % 2, 0, `${width}x${height} produced odd scaled width`); + assert.equal(dims.height % 2, 0, `${width}x${height} produced odd scaled height`); + } + assert.ok(dims.width <= source.width, `${width}x${height} exceeded source width`); + assert.ok(dims.height <= source.height, `${width}x${height} exceeded source height`); + if (preset.maxHeight != null) { + assert.ok( + dims.height <= preset.maxHeight, + `${width}x${height} exceeded maxHeight ${preset.maxHeight} with ${dims.width}x${dims.height}`, + ); + } + + if (referenceDims == null) { + referenceDims = dims; + } else { + assert.equal( + dims.width * referenceDims.height, + referenceDims.width * dims.height, + `${width}x${height} produced mismatched exact ratios: ${referenceDims.width}x${referenceDims.height} vs ${dims.width}x${dims.height}`, + ); + } + } + } + } +}); + +import { test } from "vitest"; +import assert from "node:assert"; +import { Path } from "#sitegen/path"; +import { + getAv1VideoArgs, + getConstantAspectScaleFilter, + getMpegDashArgs, + videoFormats, +} from "./transcode-rules.ts"; + +function assertString(value: string | undefined | null): string { + assert.equal(typeof value, "string"); + return value; +} + +function parseScaleFilter(filter: string) { + const match = /^scale=(\d+):(\d+)$/.exec(filter); + assert.ok(match, `unexpected filter ${filter}`); + return { + width: Number(match[1]), + height: Number(match[2]), + }; +} + +function getExpectedDimensionsForPreset( + source: { width: number; height: number }, + ratio: { width: number; height: number }, + maxHeight?: number, +) { + const heightCap = Math.min(maxHeight ?? source.height, source.height); + const maxScale = Math.floor(Math.min( + source.width / ratio.width, + heightCap / ratio.height, + )); + const scale = maxScale % 2 === 0 ? maxScale : maxScale - 1; + const dims = { + width: ratio.width * scale, + height: ratio.height * scale, + }; + + if (dims.width === source.width && dims.height === source.height) return source; + return dims; +} + +const EXACT_ERROR_THRESHOLD = 0.0005; + +function getFeasibleCandidates(source: { width: number; height: number }) { + const caps = [ + source.height, + ...videoFormats.flatMap((preset) => + preset.maxHeight != null ? [Math.min(preset.maxHeight, source.height)] : [] + ), + ]; + const sourceRatio = source.width / source.height; + const maxDenominator = Math.max(1, Math.floor(Math.min(...caps) / 2)); + const seen = new Set(); + const candidates: Array<{ + width: number; + height: number; + error: number; + retainedArea: number; + }> = []; + + for (let denominator = 1; denominator <= maxDenominator; denominator++) { + const numerator = Math.max(1, Math.round(sourceRatio * denominator)); + const divisor = greatestCommonDivisor(numerator, denominator); + const candidate = { + width: numerator / divisor, + height: denominator / divisor, + }; + const key = `${candidate.width}:${candidate.height}`; + if (seen.has(key)) continue; + seen.add(key); + + const dims = videoFormats.map((preset) => + getExpectedDimensionsForPreset(source, candidate, preset.maxHeight) + ); + if (dims.some((value) => value.width <= 0 || value.height <= 0)) continue; + + const retainedArea = dims + .map((value) => value.width * value.height) + .reduce((sum, value) => sum + value, 0); + const error = Math.abs((candidate.width / candidate.height) - sourceRatio) / + sourceRatio; + candidates.push({ ...candidate, error, retainedArea }); + } + + return candidates; +} + +function greatestCommonDivisor(a: number, b: number): number { + let x = Math.abs(a); + let y = Math.abs(b); + while (y !== 0) { + [x, y] = [y, x % y]; + } + return x || 1; +} + +function reduceRatio(dimensions: { width: number; height: number }) { + const divisor = greatestCommonDivisor(dimensions.width, dimensions.height); + return { + width: dimensions.width / divisor, + height: dimensions.height / divisor, + }; +} + +function getCandidateScore( + source: { width: number; height: number }, + ratio: { width: number; height: number }, +) { + const dims = videoFormats.map((preset) => + getExpectedDimensionsForPreset(source, ratio, preset.maxHeight) + ); + if (dims.some((value) => value.width <= 0 || value.height <= 0)) return null; + const sourceRatio = source.width / source.height; + return { + ...ratio, + error: Math.abs((ratio.width / ratio.height) - sourceRatio) / sourceRatio, + retainedArea: dims + .map((value) => value.width * value.height) + .reduce((sum, value) => sum + value, 0), + }; +} diff --git a/src/file-viewer/transcode-rules.ts b/src/file-viewer/transcode-rules.ts index 84843b8de11cec9f74fb9e5f0210a298201748ec..b9a2be4e9e61cb25f22f63579f863f267cf84150 100644 --- a/src/file-viewer/transcode-rules.ts +++ b/src/file-viewer/transcode-rules.ts @@ -10,6 +10,10 @@ interface OpusPreset { id: string; audioKbit: number; } +interface Dimensions { + width: number; + height: number; +} export const videoFormats: Av1Preset[] = [ { @@ -69,22 +73,11 @@ export const imagePresets = [ "6", ], }, - { - ext: ".avif", - args: [ - "-c:v", - "@clo/libaom-av1", - "-crf", - "30", - "-pix_fmt", - "yuv420p10le", - ], - }, { ext: ".jxl", args: [ "-c:v", - "@clo/libjxl", + "libjxl", "-distance", "0.8", "-effort", @@ -140,15 +133,136 @@ export interface InputArgs { export const opusFileName = "tmp.opus.webm"; export const av1FileName = "tmp.av1.mp4"; +function gcd(a: number, b: number): number { + let x = Math.abs(a); + let y = Math.abs(b); + while (y !== 0) { + [x, y] = [y, x % y]; + } + return x || 1; +} + +function getMaxHeightCaps(source: Dimensions): number[] { + return [ + source.height, + ...videoFormats.flatMap((preset) => + preset.maxHeight != null + ? [Math.min(preset.maxHeight, source.height)] + : [] + ), + ]; +} + +function getExactSquarePixelDimensions( + source: Dimensions, + aspect: Dimensions, + maxHeight?: number, +): Dimensions | null { + const heightCap = Math.min(maxHeight ?? source.height, source.height); + const maxScale = Math.floor(Math.min( + source.width / aspect.width, + heightCap / aspect.height, + )); + const scale = maxScale % 2 === 0 ? maxScale : maxScale - 1; + + if (scale < 2) return null; + + return { + width: aspect.width * scale, + height: aspect.height * scale, + }; +} + +function getCanonicalSquarePixelAspectRatio(source: Dimensions): Dimensions { + const ratio = source.width / source.height; + const caps = getMaxHeightCaps(source); + const maxDenominator = Math.max(1, Math.floor(Math.min(...caps) / 2)); + const exactErrorThreshold = 0.0005; + const seen = new Set(); + + let bestWithinThreshold: { + aspect: Dimensions; + error: number; + retainedArea: number; + } | null = null; + let bestFallback: { + aspect: Dimensions; + error: number; + retainedArea: number; + } | null = null; + + for (let denominator = 1; denominator <= maxDenominator; denominator++) { + const numerator = Math.max(1, Math.round(ratio * denominator)); + const divisor = gcd(numerator, denominator); + const aspect = { + width: numerator / divisor, + height: denominator / divisor, + }; + const key = `${aspect.width}:${aspect.height}`; + if (seen.has(key)) continue; + seen.add(key); + + const dimensions = caps.map((cap) => + getExactSquarePixelDimensions(source, aspect, cap) + ); + if (dimensions.some((value) => value == null)) continue; + + const retainedArea = dimensions + .map((value) => value!.width * value!.height) + .reduce((sum, area) => sum + area, 0); + const error = Math.abs((aspect.width / aspect.height) - ratio) / ratio; + const candidate = { aspect, error, retainedArea }; + + if (error <= exactErrorThreshold) { + if ( + !bestWithinThreshold || + retainedArea > bestWithinThreshold.retainedArea || + ( + retainedArea === bestWithinThreshold.retainedArea && + error < bestWithinThreshold.error + ) + ) { + bestWithinThreshold = candidate; + } + continue; + } + + if ( + !bestFallback || + error < bestFallback.error || + (error === bestFallback.error && retainedArea > bestFallback.retainedArea) + ) { + bestFallback = candidate; + } + } + + return bestWithinThreshold?.aspect ?? bestFallback?.aspect ?? source; +} + +export function getConstantAspectScaleFilter( + source: Dimensions, + maxHeight?: number, +): string | null { + const aspect = getCanonicalSquarePixelAspectRatio(source); + const dimensions = getExactSquarePixelDimensions(source, aspect, maxHeight); + if (!dimensions) return null; + if ( + dimensions.width === source.width && + dimensions.height === source.height + ) return null; + return `scale=${dimensions.width}:${dimensions.height}`; +} + export function getAv1VideoArgs( av1: Av1Preset, + source: Dimensions, videoInputArgs: string[], outDir: Path, ) { const { preset, mbitMax, crf, maxHeight, depth } = av1; const cmd = [...videoInputArgs]; - cmd.push("-c:v", "@clo/libsvtav1"); + cmd.push("-c:v", "libsvtav1"); cmd.push( "-svtav1-params", [ @@ -163,8 +277,9 @@ export function getAv1VideoArgs( "scm=2", // ScreenContentMode, adaptive ].filter(Boolean).join(":"), ); - if (maxHeight != null) { - cmd.push("-vf", `scale=-2:min(${maxHeight}\\,ih)`); + const filter = getConstantAspectScaleFilter(source, maxHeight); + if (filter) { + cmd.push("-vf", filter); } cmd.push("-y"); @@ -183,7 +298,7 @@ export function getOpusAudioArgs( cmd.push("-vn"); - cmd.push("-c:a", "@clo/libopus"); + cmd.push("-c:a", "libopus"); cmd.push("-b:a", audioKbit + "k"); cmd.push("-y"); @@ -215,16 +330,15 @@ export function getMpegDashArgs( cmd.push("-use_timeline", "1"); cmd.push("-use_template", "1"); - // calculate actual dimensions per video - const videoAdaptationSets = videos - .map((_, i) => `id=${i},streams=${i}`) - .join(" "); - - let adaptationSets = videoAdaptationSets; + let adaptationSets = videos.length > 0 + ? `id=0,streams=${videos.map((_, i) => i).join(",")}` + : ""; if (audios.length > 0) { - const audioId = videos.length; + const audioId = videos.length > 0 ? 1 : 0; const audioIndices = audios.map((_, i) => videos.length + i).join(","); - adaptationSets += ` id=${audioId},streams=${audioIndices}`; + adaptationSets += `${ + adaptationSets ? " " : "" + }id=${audioId},streams=${audioIndices}`; } cmd.push("-adaptation_sets", adaptationSets); @@ -264,16 +378,11 @@ export function getH264HlsArgs( const aIdx = i * 2 + 1; // deno-fmt-ignore cmd.push( - `-c:${vIdx}`, - "@clo/libx264", - `-b:v:${vIdx}`, - preset.videoBitrate, - `-filter:${vIdx}`, - `scale=-2:min(${preset.maxHeight}\\,ih)`, - `-c:${aIdx}`, - "aac", - `-b:a:${aIdx}`, - preset.audioBitrate, + `-c:${vIdx}`, "libx264", + `-b:v:${vIdx}`, preset.videoBitrate, + `-filter:${vIdx}`, `scale=-2:min(${preset.maxHeight}\\,ih)`, + `-c:${aIdx}`, "aac", + `-b:a:${aIdx}`, preset.audioBitrate, ); }); @@ -290,12 +399,9 @@ export function getH264HlsArgs( hlsPresets.forEach((preset, i) => { // deno-fmt-ignore cmd.push( - `-c:v:${i}`, - "@clo/libx264", - `-b:v:${i}`, - preset.videoBitrate, - `-filter:v:${i}`, - `scale=-2:min(${preset.maxHeight}\\,ih)`, + `-c:v:${i}`, "libx264", + `-b:v:${i}`, preset.videoBitrate, + `-filter:v:${i}`, `scale=-2:min(${preset.maxHeight}\\,ih)`, ); }); @@ -307,20 +413,13 @@ export function getH264HlsArgs( // deno-fmt-ignore cmd.push( - "-f", - "hls", - "-hls_time", - "2", - "-hls_list_size", - "0", - "-hls_segment_type", - "fmp4", - "-hls_allow_cache", - "1", - "-master_pl_name", - "master.m3u8", - "-hls_segment_filename", - "hls.%v.%03d.ts", + "-f", "hls", + "-hls_time", "2", + "-hls_list_size", "0", + "-hls_segment_type", "fmp4", + "-hls_allow_cache", "1", + "-master_pl_name", "master.m3u8", + "-hls_segment_filename", "hls.%v.%03d.ts", "-y", outDir.join("hls.%v.m3u8").toString(), );