Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit bafe227ab86d59dc89074d4ec7b2f7022d18c0ad
parent 76f67173648eeebc3eb17834b89917ff6059ce21
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 01:48:23 -0400

report-to-video: a long cut is crossfaded in batches, then the batch files are crossfaded together

Past xfadeBatchSize segments (REPORT_VIDEO_XFADE_BATCH, else
render.xfadeBatch, else 24; 0 never batches) concatWithXfade crossfades
each run of consecutive segments into out/<variant>/xfade-batches/
batch-<key>.mov (the cut's video encode, PCM sound), then crossfades the
batch files together with the same transition, the chrome, the rail and
the dips in the last pass. The batches start where their first segments
do in a single pass, so every dissolve, the schedule, the chapters and
the dips land on the same frames. A batch is reused while its key (argv,
inputs' size and mtime) matches; files of any other key are removed.

Tests: the batch plan (N=1, under and at the threshold, threshold+1, 223,
past size squared), the offsets at batch joins against the single pass,
the join pass's dips and dip dissolves, and a real ffmpeg run of seven
two-second segments in batches of three against one pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/report-to-video/build-video.mjs | 204++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Aumtool/report-to-video/xfade-batch.test.mjs | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 416 insertions(+), 13 deletions(-)

diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -83,7 +83,7 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { promisify } from "node:util"; -import { mkdir, writeFile, readFile, access, readdir, rename, stat } from "node:fs/promises"; +import { mkdir, writeFile, readFile, access, readdir, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; import { @@ -2999,10 +2999,12 @@ export async function cutOffsets(segments, D, fps, joins = null) { * The crossfade concat's filtergraph from the cut's segment lengths (`durs`, * holds included) and the per-input joins. Without joins, the graph it always was. */ -export function xfadeGraph(durs, D, joins = null, render = null) { +export function xfadeGraph(durs, D, joins = null, render = null, { joined = false } = {}) { const parts = []; const ins = durs.map((d, i) => { - const c = joinInputChain(i, joins?.[i] ?? null, render); + // `joined`: the inputs already carry their joins (a batched build's batch + // files), so only each join's `dip` is read -- for the transition after it. + const c = joinInputChain(i, joined ? null : joins?.[i] ?? null, render); parts.push(...c.parts); // The sound is pinned to the picture's length before it is crossfaded. // `xfade` places segment i+1 by the PICTURE's length, `acrossfade` by the @@ -3072,21 +3074,195 @@ export function hardCutFilterArgs(segments, joins, render, outPath, dips = null) // stays available for quick iteration. `joins` (the deck's holds and moves, // `segmentJoins`) go on their inputs before the join; null leaves the graph // exactly as it was. -async function concatWithXfade(segments, render, outPath, railPlan, chrome = null, joins = null, { dip = true } = {}) { +// +// A long cut is joined in BATCHES (xfadeBatches): one ffmpeg holding every +// segment open at once grows with the segment count -- a 223-segment 1080p +// cut reached 8 GB and was killed -- so past `xfadeBatchSize` segments each +// run of consecutive segments is crossfaded into a file of its own +// (`<workDir>/xfade-batches/`, cached by key), and the batch files are +// crossfaded together, with the chrome, the rail and the dips, in the last +// pass. Every join is the same dissolve at the same second either way. +export async function concatWithXfade(segments, render, outPath, railPlan, chrome = null, joins = null, { dip = true, workDir = path.dirname(outPath) } = {}) { const D = render.transition ?? 0.5; const { durs } = await cutOffsets(segments, D, render.fps, joins); - await execFileP(FFMPEG, xfadeConcatArgs({ segments, durs, render, outPath, railPlan, chrome, joins, dip }), { maxBuffer: 1 << 26 }); + const plan = xfadeBatches(segments.length, xfadeBatchSize(render)); + const batches = plan + ? await buildXfadeBatches({ segments, durs, batches: plan, render, joins, dir: path.join(workDir, "xfade-batches") }) + : null; + await execFileP(FFMPEG, xfadeConcatArgs({ segments, durs, render, outPath, railPlan, chrome, joins, dip, batches }), { maxBuffer: 1 << 26 }); +} + +// ---- batched crossfade ------------------------------------------------------- + +/** Segments one crossfade pass may join before the cut is joined in batches. */ +export const XFADE_BATCH_DEFAULT = 24; + +/** + * The batch size this build uses: `REPORT_VIDEO_XFADE_BATCH` (the machine's + * memory is the machine's), else `render.xfadeBatch`, else 24. 0 never batches. + */ +export function xfadeBatchSize(render = {}, env = process.env) { + const fromEnv = env.REPORT_VIDEO_XFADE_BATCH != null && env.REPORT_VIDEO_XFADE_BATCH !== ""; + const raw = fromEnv ? env.REPORT_VIDEO_XFADE_BATCH : render?.xfadeBatch; + if (raw == null) return XFADE_BATCH_DEFAULT; + const n = Number(raw); + if (!Number.isInteger(n) || (n !== 0 && n < 2)) { + throw new Error( + `${fromEnv ? "REPORT_VIDEO_XFADE_BATCH" : "render.xfadeBatch"} must be 0 (never batch) or a whole number ≥ 2, ` + + `got ${JSON.stringify(raw)}`, + ); + } + return n; +} + +/** + * The batches a crossfade of `n` segments is joined in: null when one pass + * does (`n` ≤ `size`, or `size` 0), else consecutive `{ from, to }` ranges + * (`to` exclusive) of near-equal length. Neither pass opens more than `size` + * inputs while there are at most size² segments; past that the batches grow, + * so the join pass stays `size` wide. + */ +export function xfadeBatches(n, size) { + if (!size || n <= size) return null; + const count = Math.ceil(n / Math.max(size, Math.ceil(n / size))); + return Array.from({ length: count }, (_, b) => ({ + from: Math.floor((b * n) / count), + to: Math.floor(((b + 1) * n) / count), + })); +} + +/** + * Each batch's length in the cut: its segments' lengths less one transition + * per join inside it. Crossfaded together with the same transition, the + * batches start where their first segments do in a single pass, and the cut + * is as long: Σ batch − (B − 1)·D = Σ segment − (N − 1)·D. + */ +export function xfadeBatchLengths(durs, D, batches) { + return batches.map(({ from, to }) => { + let len = durs[from]; + for (let i = from + 1; i < to; i += 1) len = len + durs[i] - D; + return len; + }); +} + +/** + * The join pass's joins, one per batch: only the dissolve OUT of its last + * segment matters there (a `dip` before a teaser plays the outgoing picture + * untouched); the segments' own chains were applied inside the batch. + */ +export function xfadeBatchJoins(joins, batches) { + const out = batches.map(({ to }) => (joins?.[to - 1]?.dip ? { dip: joins[to - 1].dip } : null)); + return out.some(Boolean) ? out : null; +} + +// A batch file is read once more, by the join pass: its picture is the cut's +// own encode (codec, preset, crf, frame rate), its sound PCM -- no AAC +// generation, and no encoder padding for the join to pin away. +const batchEncodeArgs = (render) => [ + "-c:v", "libx264", + "-preset", render.preset ?? "medium", + "-crf", String(render.crf ?? 20), + "-pix_fmt", "yuv420p", + "-r", String(render.fps), + "-c:a", "pcm_s16le", + "-ar", String(render.audioRate), + "-ac", String(render.audioChannels), +]; + +/** + * One batch's ffmpeg argv: its segments crossfaded exactly as the single pass + * crossfades them (xfadeGraph, each segment's joins on its input), no chrome, + * no rail, no dips -- those are the join pass's, over the whole cut. + */ +export function xfadeBatchArgs({ segments, durs, batch, render, joins = null, outPath }) { + const D = render.transition ?? 0.5; + const { from, to } = batch; + const segs = segments.slice(from, to); + const js = joins ? joins.slice(from, to) : null; + const g = xfadeGraph(durs.slice(from, to), D, js, render); + let { vlab } = g; + // A batch of one with no chain maps an INPUT's picture, which -map cannot + // name as a filter label. + if (/^\[\d+:v\]$/.test(vlab)) { + g.parts.push(`${vlab}null[bv]`); + vlab = "[bv]"; + } + return [ + "-nostdin", "-v", "error", "-y", + ...segs.flatMap((s) => ["-i", s]), + "-filter_complex", g.parts.join(";"), + "-map", vlab, "-map", g.alab, + ...batchEncodeArgs(render), + outPath, + ]; +} + +/** + * Crossfade each batch into `dir/batch-<key>.mov`, reusing a file whose key + * matches: the key is the batch's whole argv (graph, joins, encode) and each + * input's size and mtime, so a rebuilt segment or a changed join redoes only + * its own batch, and `--chrome-only` redoes only the join pass. Files of any + * other key are removed first. Written under a temporary name and renamed, + * so an interrupted batch is never taken for a finished one. + * + * @returns {{ files: string[], durs: number[], joins: Array|null }} the join pass's inputs + */ +export async function buildXfadeBatches({ segments, durs, batches, render, joins = null, dir }) { + const D = render.transition ?? 0.5; + const lens = xfadeBatchLengths(durs, D, batches); + await mkdir(dir, { recursive: true }); + const planned = []; + for (const batch of batches) { + const argv = xfadeBatchArgs({ segments, durs, batch, render, joins, outPath: "" }).slice(0, -1); + const ins = segments.slice(batch.from, batch.to); + const stats = await Promise.all(ins.map(async (s) => { + const st = await stat(s); + return [path.resolve(s), st.size, st.mtimeMs]; + })); + const key = createHash("sha256") + .update(JSON.stringify({ argv: argv.map((a) => (ins.includes(a) ? path.resolve(a) : a)), stats })) + .digest("hex") + .slice(0, 16); + planned.push(path.join(dir, `batch-${key}.mov`)); + } + const keep = new Set(planned.map((f) => path.basename(f))); + for (const name of await readdir(dir)) { + if (!keep.has(name)) await rm(path.join(dir, name), { force: true }); + } + for (const [b, batch] of batches.entries()) { + const file = planned[b]; + const label = `crossfade batch ${b + 1}/${batches.length}: segments ${batch.from + 1}–${batch.to}`; + const got = (await exists(file)) ? await probeDuration(file, render.fps).catch(() => null) : null; + if (got != null && Math.abs(got - lens[b]) <= 1.5 / render.fps) { + EMIT("note", { message: `${label} (cached)` }); + continue; + } + EMIT("note", { message: `${label}…` }); + const tmp = file.replace(/\.mov$/, ".partial.mov"); + await execFileP(FFMPEG, xfadeBatchArgs({ segments, durs, batch, render, joins, outPath: tmp }), { maxBuffer: 1 << 26 }); + await assertConcatLength(tmp, lens[b], render.fps, label); + await rename(tmp, file); + } + return { files: planned, durs: lens, joins: xfadeBatchJoins(joins, batches) }; } /** * concatWithXfade's ffmpeg argv, from the cut's segment lengths (`durs`, * `cutOffsets`'): the crossfades, the chrome, the rail, then the dips over all * of it. Pure, so a test can run the very graph the build does. + * + * `batches` (buildXfadeBatches') makes it the join pass of a batched build: + * the batch files are its inputs, crossfaded at their own lengths, while the + * dips are still placed from the segments' `durs` and `joins` -- the cut's + * clock is the same. */ -export function xfadeConcatArgs({ segments, durs, render, outPath, railPlan = null, chrome = null, joins = null, dip = true }) { +export function xfadeConcatArgs({ segments, durs, render, outPath, railPlan = null, chrome = null, joins = null, dip = true, batches = null }) { const D = render.transition ?? 0.5; - const inputs = segments.flatMap((s) => ["-i", s]); - const { parts, vlab, alab } = xfadeGraph(durs, D, joins, render); + const main = batches ? batches.files : segments; + const inputs = main.flatMap((s) => ["-i", s]); + const { parts, vlab, alab } = batches + ? xfadeGraph(batches.durs, D, batches.joins, render, { joined: true }) + : xfadeGraph(durs, D, joins, render); // The rail attaches to the LAST xfade node, so it runs after every dissolve // and sees an absolute, continuous `t`. One encode, not two. @@ -3100,13 +3276,13 @@ export function xfadeConcatArgs({ segments, durs, render, outPath, railPlan = nu const rc = railPlan ? railFilterChain( render.rail, railPlan.assets, railPlan.times, render, - railIn, segments.length, railPlan.total + 2, + railIn, main.length, railPlan.total + 2, { hideAt: railPlan.hideAt }, ) : null; const railInputs = rc ? rc.inputs.filter((a) => a === "-i").length : 0; const hf = chromeIn - ? chromeOverlayChain(render, chromeIn.regions, vlab, segments.length + railInputs, { + ? chromeOverlayChain(render, chromeIn.regions, vlab, main.length + railInputs, { outLabel: chromeIn.outLabel, final: !rc, }) @@ -3685,6 +3861,8 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // A `render.chrome` that cannot be built is refused here, before a single // fetch is spent. Absent, validateChrome has nothing to say. if (render.chrome !== undefined && render.chrome !== null) assertChrome(render.chrome, render); + // A batch size that cannot be read is refused as early (the env or the manifest's). + xfadeBatchSize(render); // A clip's `muteFrom` and `render.endFade`, checked against the WHOLE // manifest, deck or not: both are made where the cut is joined. { @@ -3900,7 +4078,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segs.length }); // A base for the rail: its dips are laid with the rail, over it. if (D === 0) await concatHardCut(segs, outDir, prerail, { joins, render }); - else await concatWithXfade(segs, render, prerail, null, null, joins, { dip: false }); + else await concatWithXfade(segs, render, prerail, null, null, joins, { dip: false, workDir: outDir }); } const railPlan = await buildRailPlan(manifest, render, entries, segs, D, outDir); await assertConcatLength(prerail, railPlan.total, render.fps, @@ -3998,7 +4176,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat"); await applyChrome(prerail, dirs.final, render, plan, null, await cutDips(segs, joins)); } else { - await concatWithXfade(segs, render, dirs.final, null, plan, joins); + await concatWithXfade(segs, render, dirs.final, null, plan, joins, { workDir: outDir }); } await assertConcatLength(dirs.final, schedule.total, render.fps, "deck build"); // The overlay re-encodes, so the chapters on the previous final are gone. @@ -4149,7 +4327,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly await concatHardCut(segments, outDir, final, { joins, render, dips: await cutDips(segments, joins) }); } } else { - await concatWithXfade(segments, render, final, railPlan, chromePlan, joins); + await concatWithXfade(segments, render, final, railPlan, chromePlan, joins, { workDir: outDir }); } // The deck's sequence is laid with shortest=1, so a sequence a frame short // would shorten the cut without a word; the schedule is the length to hold. diff --git a/umtool/report-to-video/xfade-batch.test.mjs b/umtool/report-to-video/xfade-batch.test.mjs @@ -0,0 +1,225 @@ +// A long cut is crossfaded in batches: each run of consecutive segments into +// a file of its own, then the batch files crossfaded together with the same +// dissolve. These pin the plan (when it batches, and how), the offset +// arithmetic at the batch joins (every dissolve lands where the single pass +// put it), the join pass's graph (dips placed from the segments, not the +// batches), and one real ffmpeg run of seven tiny segments in batches of +// three against the single-pass build of the same segments. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + XFADE_BATCH_DEFAULT, buildXfadeBatches, concatWithXfade, xfadeBatchArgs, xfadeBatchJoins, + xfadeBatchLengths, xfadeBatchSize, xfadeBatches, xfadeConcatArgs, +} from "./build-video.mjs"; +import { scheduleFrom } from "./deck.mjs"; + +const D = 0.5; +const RENDER = { width: 64, height: 36, fps: 10, transition: D, audioRate: 48000, audioChannels: 2, preset: "ultrafast" }; + +/** Deterministic, uneven segment lengths in whole frames of 30 fps. */ +const lengths = (n) => Array.from({ length: n }, (_, i) => (90 + ((i * 37) % 211)) / 30); + +/** Every `xfade` offset in an argv's graph, in order. */ +const offsets = (argv) => [...argv[argv.indexOf("-filter_complex") + 1].matchAll(/xfade=[^;]*?offset=([\d.]+)/g)].map((m) => m[1]); + +test("xfadeBatches: one pass up to the threshold, near-equal consecutive batches past it", () => { + assert.equal(xfadeBatches(1, 24), null); + assert.equal(xfadeBatches(10, 24), null); + assert.equal(xfadeBatches(24, 24), null); + assert.equal(xfadeBatches(500, 0), null, "0 never batches"); + + assert.deepEqual(xfadeBatches(25, 24), [{ from: 0, to: 12 }, { from: 12, to: 25 }]); + + const check = (n, size, maxWide) => { + const b = xfadeBatches(n, size); + assert.equal(b[0].from, 0); + assert.equal(b.at(-1).to, n); + for (let i = 1; i < b.length; i += 1) assert.equal(b[i].from, b[i - 1].to, "consecutive"); + const widths = b.map((x) => x.to - x.from); + assert.ok(Math.max(...widths) - Math.min(...widths) <= 1, "near-equal"); + assert.ok(Math.max(...widths) <= maxWide, `batches at most ${maxWide} wide`); + assert.ok(b.length <= maxWide, `the join pass at most ${maxWide} wide`); + return b; + }; + const big = check(223, 24, 24); + assert.equal(big.length, 10); + check(24 * 24, 24, 24); + // Past size², the batches grow so the join pass does not. + check(1000, 24, Math.ceil(1000 / 24)); + check(3, 2, 2); +}); + +test("xfadeBatchSize: env over the manifest over the default; 0 never batches; nonsense refused", () => { + assert.equal(xfadeBatchSize({}, {}), XFADE_BATCH_DEFAULT); + assert.equal(xfadeBatchSize({ xfadeBatch: 8 }, {}), 8); + assert.equal(xfadeBatchSize({ xfadeBatch: 8 }, { REPORT_VIDEO_XFADE_BATCH: "12" }), 12); + assert.equal(xfadeBatchSize({ xfadeBatch: 8 }, { REPORT_VIDEO_XFADE_BATCH: "" }), 8); + assert.equal(xfadeBatchSize({ xfadeBatch: 0 }, {}), 0); + assert.throws(() => xfadeBatchSize({ xfadeBatch: 1 }, {}), /render\.xfadeBatch/); + assert.throws(() => xfadeBatchSize({ xfadeBatch: 2.5 }, {}), /render\.xfadeBatch/); + assert.throws(() => xfadeBatchSize({}, { REPORT_VIDEO_XFADE_BATCH: "lots" }), /REPORT_VIDEO_XFADE_BATCH/); +}); + +for (const [n, size] of [[1, 24], [10, 24], [25, 24], [223, 24], [7, 3]]) { + test(`offsets at the batch joins are the single pass's (N=${n}, size ${size})`, () => { + const durs = lengths(n); + const segments = durs.map((_, i) => `seg${i}.mp4`); + const single = xfadeConcatArgs({ segments, durs, render: RENDER, outPath: "out.mp4" }); + const plan = xfadeBatches(n, size); + if (!plan) { + // Under the threshold the build is the single pass, unchanged. + assert.ok(n <= size); + assert.equal(single.filter((a) => a === "-i").length, n); + return; + } + const lens = xfadeBatchLengths(durs, D, plan); + const one = scheduleFrom(durs, D); + const many = scheduleFrom(lens, D); + assert.ok(Math.abs(one.total - many.total) < 1e-9, "the same length"); + plan.forEach((b, k) => assert.ok(Math.abs(many.starts[k] - one.starts[b.from]) < 1e-9, `batch ${k} starts with its first segment`)); + + // The graphs: the join pass's offsets are exactly the single pass's at + // the batch boundaries, and each batch's are its own segments' less the + // batch's start. + const singleOffs = offsets(single); + const joined = xfadeConcatArgs({ + segments, durs, render: RENDER, outPath: "out.mp4", + batches: { files: plan.map((_, k) => `b${k}.mov`), durs: lens, joins: null }, + }); + assert.deepEqual(offsets(joined), plan.slice(1).map((b) => singleOffs[b.from - 1])); + plan.forEach((b) => { + const own = offsets(xfadeBatchArgs({ segments, durs, batch: b, render: RENDER, outPath: "b.mov" })); + const want = singleOffs.slice(b.from, b.to - 1).map((o) => (Number(o) - one.starts[b.from]).toFixed(3)); + assert.equal(own.length, want.length); + own.forEach((o, i) => assert.ok(Math.abs(Number(o) - Number(want[i])) <= 0.0011, `${o} vs ${want[i]}`)); + }); + }); +} + +test("the join pass: a dip at a batch boundary keeps its untouched dissolve; dips are placed from the segments", () => { + const durs = lengths(7); + const segments = durs.map((_, i) => `seg${i}.mp4`); + const dip = { seconds: 0.5, lastFrame: Math.round(durs[3] * 30) - 1, black: 0.3 }; + const joins = [null, { hold: 0, move: null, dip: { ...dip, lastFrame: Math.round(durs[1] * 30) - 1 } }, null, + { hold: 0, move: null, dip }, null, null, null]; + const plan = xfadeBatches(7, 3); // [0,2) [2,4) [4,7) + assert.deepEqual(plan.map((b) => [b.from, b.to]), [[0, 2], [2, 4], [4, 7]]); + const bj = xfadeBatchJoins(joins, plan); + // Segment 1 ends batch 0 and segment 3 ends batch 1: both boundaries dip. + assert.deepEqual(bj.map((j) => !!j?.dip), [true, true, false]); + assert.equal(xfadeBatchJoins([null, null, { hold: 1, move: null }], [{ from: 0, to: 2 }, { from: 2, to: 3 }]), null); + + const render = { ...RENDER, fps: 30 }; + const single = xfadeConcatArgs({ segments, durs, render, outPath: "o.mp4", joins }); + const joined = xfadeConcatArgs({ + segments, durs, render, outPath: "o.mp4", joins, + batches: { files: ["b0.mov", "b1.mov", "b2.mov"], durs: xfadeBatchLengths(durs, D, plan), joins: bj }, + }); + const g = joined[joined.indexOf("-filter_complex") + 1]; + // No segment chain is applied twice: the batch files already carry them. + assert.doesNotMatch(g, /afade/); + assert.equal((g.match(/transition=custom:expr='A'/g) ?? []).length, 2); + // The picture's dips, over the whole cut: the same filter as the single pass's. + const dips = (argv) => argv[argv.indexOf("-filter_complex") + 1].split(";").map((p) => p.replace(/^\[[^\]]+\]/, "")).filter((p) => p.startsWith("fade=t=out")); + assert.deepEqual(dips(joined), dips(single)); + assert.equal(dips(single).length, 1); +}); + +test("xfadeBatchArgs: a batch of one maps its picture through a filter label", () => { + const argv = xfadeBatchArgs({ segments: ["a.mp4", "b.mp4", "c.mp4"], durs: [3, 3, 3], batch: { from: 2, to: 3 }, render: RENDER, outPath: "x.mov" }); + assert.equal(argv[argv.indexOf("-map") + 1], "[bv]"); + assert.deepEqual(argv.filter((a) => a.endsWith(".mp4")), ["c.mp4"]); + assert.equal(argv[argv.indexOf("-c:a") + 1], "pcm_s16le"); +}); + +// ---- real ffmpeg ------------------------------------------------------------- + +const have = spawnSync("ffmpeg", ["-version"]).status === 0; +const COLOURS = ["red", "green", "blue", "yellow", "magenta", "cyan", "white"]; + +function segment(dir, i) { + const out = path.join(dir, `s${i}.mp4`); + const r = spawnSync("ffmpeg", [ + "-v", "error", "-y", + "-f", "lavfi", "-i", `color=c=${COLOURS[i]}:s=64x36:r=10:d=2`, + "-f", "lavfi", "-i", `sine=f=${300 + 100 * i}:r=48000:d=2`, + "-map", "0:v", "-map", "1:a", "-ac", "2", + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", + out, + ]); + assert.equal(r.status, 0, String(r.stderr)); + return out; +} + +/** Every frame of a file as one averaged RGB pixel. */ +function pixels(file) { + const r = spawnSync("ffmpeg", ["-v", "error", "-i", file, "-vf", "scale=1:1:flags=area", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { maxBuffer: 1 << 24 }); + assert.equal(r.status, 0, String(r.stderr)); + return Array.from({ length: r.stdout.length / 3 }, (_, f) => [...r.stdout.subarray(f * 3, f * 3 + 3)]); +} + +const audioSeconds = (file) => Number(spawnSync("ffprobe", ["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=duration", "-of", "default=nw=1:nk=1", file]).stdout.toString().trim()); + +test("seven segments in batches of three: the same frames as one pass", { skip: !have && "no ffmpeg" }, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "xfade-batch-")); + try { + const segments = COLOURS.map((_, i) => segment(dir, i)); + const durs = segments.map(() => 2); + const render = { ...RENDER, xfadeBatch: 3 }; + + const onePath = path.join(dir, "one.mp4"); + const r = spawnSync("ffmpeg", xfadeConcatArgs({ segments, durs, render, outPath: onePath })); + assert.equal(r.status, 0, String(r.stderr)); + + const manyPath = path.join(dir, "many.mp4"); + const work = path.join(dir, "work"); + await concatWithXfade(segments, render, manyPath, null, null, null, { workDir: work }); + const batchDir = path.join(work, "xfade-batches"); + const files = readdirSync(batchDir).sort(); + assert.equal(files.length, 3, files.join(", ")); + assert.ok(files.every((f) => /^batch-[0-9a-f]{16}\.mov$/.test(f))); + + const one = pixels(onePath); + const many = pixels(manyPath); + // 7 × 2 s less 6 × 0.5 s = 11 s at 10 fps. + assert.equal(one.length, 110); + assert.equal(many.length, one.length); + // Each segment's middle, and every frame of every dissolve -- the two + // batch joins (into the third and fifth segments) among them -- within the encode's + // noise of the single pass. A frame early or late would be a fifth of + // the way along the dissolve off: far outside it. + const { starts } = scheduleFrom(durs, D); + const at = []; + starts.forEach((s, i) => { + at.push(Math.round((s + 1) * 10)); + if (i > 0) for (let f = 0; f < D * 10; f += 1) at.push(Math.round(s * 10) + f); + }); + for (const f of at) { + const diff = Math.max(...one[f].map((v, c) => Math.abs(v - many[f][c]))); + assert.ok(diff <= 12, `frame ${f}: ${one[f]} vs ${many[f]}`); + } + assert.ok(Math.abs(audioSeconds(manyPath) - audioSeconds(onePath)) < 0.05); + + // A re-run reuses every batch: same files, untouched. + const mtimes = files.map((f) => statSync(path.join(batchDir, f)).mtimeMs); + await concatWithXfade(segments, render, manyPath, null, null, null, { workDir: work }); + assert.deepEqual(readdirSync(batchDir).sort(), files); + assert.deepEqual(files.map((f) => statSync(path.join(batchDir, f)).mtimeMs), mtimes); + + // A changed segment redoes its own batch only; the stale file is removed. + segment(dir, 6); + await buildXfadeBatches({ segments, durs, batches: xfadeBatches(7, 3), render, dir: batchDir }); + const after = readdirSync(batchDir).sort(); + assert.equal(after.length, 3); + assert.deepEqual(after.filter((f) => files.includes(f)).length, 2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});