// 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 }); } });