commit 87a01399559b16038556d483f71e7c44c0b79a84
parent 76f67173648eeebc3eb17834b89917ff6059ce21
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 01:50:21 -0400
Merge chunked-xfade (report-to-video assembles a long cut in crossfaded batches, then joins them: bounded memory, same frames)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 440 insertions(+), 16 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **A long report video no longer runs out of memory while its clips are crossfaded.** `build-video.mjs` used to join every segment of a cut in one ffmpeg command, which grows with the number of segments: a cut of a few hundred clips could use more memory than the machine had and be stopped. Past 24 segments the build now crossfades them in batches of consecutive segments, each into a file under `out/<variant>/xfade-batches/`, then crossfades those files together with the same transition and lays the on-screen deck, the rail and the dips over them. Every transition, the deck's schedule and the chapters land on the same frames as before, at the cost of one more video encode on such a cut. The batch size is `render.xfadeBatch` in the manifest or `REPORT_VIDEO_XFADE_BATCH` in the environment (which wins); `0` never batches. A batch file is reused while its segments, their holds and moves and the encode settings are unchanged, so a `--chrome-only` run that moves no footage redoes only the last pass.
- **Auto-download no longer tries a video the metadata scan already found members-only or private.** The scan records why it could not read a video, but only a failed download used to take a video out of the auto-download queue, so each members-only video the scan had found was still downloaded once: four yt-dlp requests, two with browser cookies, about 30 seconds each. A members-only or private answer from the scan now keeps the video out of the queue and counts it under the channel's members-only or private exclusions, and it stays in **Needs cookies** for a manual cookie run. A scan that reads the video later lifts this. A video the scan saw only as "Video unavailable" is still tried, since YouTube gives that answer when it is throttling too.
- **X post fetches stop on Drain, and an account with no posts is not searched.** Draining a `fetch-posts` job used to do nothing until gallery-dl finished its whole run. Now the timeline fetch stops at the next page boundary (at once when gallery-dl is between pages or waiting out a rate limit), the older-posts walk stops its current window's search at once and never starts the 45–120 second pause between windows, and both keep their resume point: the job ends done, not failed, and the log says "Drained; the next run resumes …". An older-posts walk is refused when nothing is archived and the last timeline fetch finished having read no posts, since it would only repeat empty searches; `"force": true` (`--force` on `archilyzer posts fetch`) walks anyway. A walk with nothing archived that finds nothing ends after two empty three-month windows instead of four, and records why; a walk that has posts keeps the year-of-empty-windows rule. Capture-posts already stopped between posts on Drain.
- **Capturing an X post that is an Article also saves the article.** An X Article (a long-form post) is archived as nothing but its link, and gallery-dl cannot read its body. When `pnpm ops capture-posts` meets a post whose archived text, or whose card on the page, links to an article, it now opens the article in the same X profile, after the same 4–10 second pause, and saves beside the post's capture: `article.json` (the title, author, date and every heading, paragraph, quote, list item, image, link and embedded post in reading order, an embedded post by its URL), `article.md` (the same as readable text), `article.png` (the whole article as shown, cut off at 16,000 pixels tall and marked `trimmed` when longer), `article.html` (the article as X served it, so it can be read again without going back to X) and the article's pictures as `article-img-1.jpg`, `article-img-2.png`, … at full size, fetched through the same browser session. `capture.json` records the article's state, title, block count and every file's size and SHA-256. `"articles": false` leaves articles alone; `"shots": false, "media": false, "articles": true` reads only the articles. An article already captured, deleted or unavailable is not opened again unless `"force": true`; one that failed is tried on the next run. If X asks to log in on the article page, the job stops there as it does for a post. The post viewer's capture panel in the editor shows the article's title with a link to `article.md`.
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -645,7 +645,7 @@ strip whose marker vanishes for six seconds reads as a bug.
Clips also carry `section` and (auto-set) `sectionEnter`. Card styles — `title`,
`timeline`, `status`, `bullets`, `sources` — still work, but the ferret-rescue cut
uses none of them. `render` holds resolution, fps, fonts, palette and the knobs
-(`fetchPad`, `snapWindow`, `silenceRelDb`, `transition`, `slideSeconds`,
+(`fetchPad`, `snapWindow`, `silenceRelDb`, `transition`, `xfadeBatch`, `slideSeconds`,
`headerHeight`, `footerHeight`, and the optional `rail`); `provenance` holds the
sweep's scope and counts. Two further entry `type`s, `scroll` and `chart`, close a
cut off a top-level `ledger[]` — see [The claim rail](#the-claim-rail-renderrail).
@@ -1001,6 +1001,23 @@ parsing — wrapping the expression in single quotes is what protects them.
re-encode of the timeline via `xfade`/`acrossfade` — the concat demuxer can only
stream-copy hard cuts. Pass `--no-xfade` for a fast hard-cut build while
iterating; the last pass can add the transitions back.
+- **A long cut is crossfaded in batches.** One ffmpeg that opens every segment at
+ once grows with the segment count: a 223-segment 1080p cut reached 8 GB and was
+ killed, where 46 segments were fine. Past 24 segments (`render.xfadeBatch`, or
+ `REPORT_VIDEO_XFADE_BATCH` in the environment, which wins: memory is the
+ machine's; `0` never batches) the build crossfades each run of consecutive
+ segments, at most that many, into `out/<variant>/xfade-batches/batch-<key>.mov`
+ (the cut's own video encode, PCM sound), then crossfades the batch files
+ together with the same transition, laying the deck, the rail and the dips over
+ them in that last pass. A batch starts where its first segment does in one
+ pass, so every dissolve, the schedule, the chapters and the dips land on the
+ same frames; the cost is one more video encode generation on a batched cut. A
+ batch's key is its whole ffmpeg command and each input's size and modified
+ time (and its segments' holds and moves are in the command), so `--chrome-only`,
+ `--rail-only` and a rebuild that changed a few segments redo only the batches
+ those changes touch, then the last pass, and files of a stale key are
+ removed when the next batched build starts. Past 24² segments the batches grow
+ instead, so the last pass stays 24 inputs wide.
## Two cuts from one manifest (`--variant`)
@@ -1039,10 +1056,13 @@ out/
availability.json SHARED — a fact about the manifest, not about a cut
<slug>.mp4 sourced
<slug>-full.mp4 full
- sourced/{cards,segments,qr,chrome,schedule.json}
- full/{cards,segments,qr,chrome,schedule.json}
+ sourced/{cards,segments,qr,chrome,xfade-batches,schedule.json}
+ full/{cards,segments,qr,chrome,xfade-batches,schedule.json}
```
+`xfade-batches/` exists only for a cut long enough to be crossfaded in batches
+(see "Segments crossfade" above).
+
`clips-raw` is shared deliberately: `sourced`'s clips are a subset of `full`'s, so
no clip is ever fetched twice. `sourced` writes `out/<slug>.mp4` because that is
the path umtool's build probe already looks for.
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 });
+ }
+});