commit d101f2885c62ac5ce596a99775ea839a204ede64
parent e0a48018b7e0687370aaa8859beee233128c49d2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 20:41:26 -0400
report-to-video: the dip's riser at gain 0.22, about 6 dB under the first hit (the teaser measures -19.7 LUFS, peak -6.0 dBFS); dip.test.mjs -- validation, the lead and card arithmetic, the hits and riser, the schedule's instant hide, the page out of black, the graphs with and without a dip, and real ffmpeg runs: a dipped crossfade and hard cut are black across the whole frame (stand-in deck and feed included) and silent from the clip's last frame through the black, and frame-identical before the fade
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 517 insertions(+), 1 deletion(-)
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -1297,7 +1297,7 @@ export const DIP_LIMITS = Object.freeze({ fade: Object.freeze([0.3, 4]), black:
export const DIP_RISE = Object.freeze({ before: 0.35, after: 0.3 });
/** The riser under the black: at most `seconds` long, ending on the first hit; a noise swell over a low sub. */
-export const DIP_RISER = Object.freeze({ seconds: 1, gain: 0.5, f0: 30, f1: 55 });
+export const DIP_RISER = Object.freeze({ seconds: 1, gain: 0.22, f0: 30, f1: 55 });
const DIP_KEYS = ["fade", "black"];
diff --git a/umtool/report-to-video/dip.test.mjs b/umtool/report-to-video/dip.test.mjs
@@ -0,0 +1,516 @@
+// The dip: a teaser's `dip: { fade, black }` takes the cut to black before it.
+// Its validation; the arithmetic (the lead, the card, the hits and the riser
+// after it, the schedule's instant hide); the page that rises out of the
+// black; the graphs as strings, unchanged without a dip; and real ffmpeg runs
+// showing a dipped join reaches black across the WHOLE frame -- a stand-in
+// deck and feed overlaid on it included -- and silence for the black span,
+// while everything before the fade is the undipped cut's own, frame for frame.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import {
+ applyChromeArgs, chromeOverlayChain, concatRecordText, cutJoins, cutOffsets, dipParts, dipVideoFilter, dipWindows,
+ endFadeAudioFilter, hardCutFilterArgs, joinInputChain, teaserAudioGraph, withCutEdits, xfadeConcatArgs, xfadeGraph,
+} from "./build-video.mjs";
+import {
+ deckChoreography, deckSchedule, DIP_LIMITS, DIP_RISE, DIP_RISER, dipHideAt, dipOf, estimatedDuration,
+ estimateSchedule, TEASER_MOTION, teaserCardSeconds, teaserHits, teaserLead, teaserLines, teaserMotion, teaserMotionOf,
+ teaserSeconds, teaserTimes, validateCutEdits, validateDip, validateTeaser, validateTeasers,
+} from "./deck.mjs";
+import { teaserCues, teaserHtml } from "./chrome-teaser.mjs";
+import { composeChrome } from "./compose-chrome.mjs";
+
+const have = spawnSync("ffmpeg", ["-version"]).status === 0;
+const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" };
+const RENDER = {
+ width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, audioRate: 48000, audioChannels: 2,
+ chrome: { engine: "hyperframes", layout: "deck", deck: {} },
+};
+// The ferret finale at the operator's beat, no `seconds`: as long as its beats need.
+const FIN = Object.freeze({
+ type: "teaser", id: "fin", beat: 1.05,
+ lines: ["Pirate Software", { text: "The Largest Ferret Rescue in the United States", break: "in the United States" }, "February 2027"],
+ tail: "?",
+});
+const dipped = (fade = 1.2, black = 0.6, e = FIN) => ({ ...e, dip: { fade, black } });
+const CLIP = { id: "c20", type: "clip", video: "B36", start: 24022.6, end: 24029.6 };
+const r4 = (v) => Math.round(v * 10000) / 10000;
+
+// ---- validation -------------------------------------------------------------
+
+test("validateDip: only a teaser, { fade, black } in their ranges, no other key; absent is fine", () => {
+ assert.deepEqual(validateDip(FIN), []);
+ assert.deepEqual(validateDip(dipped()), []);
+ assert.deepEqual(validateDip(dipped(DIP_LIMITS.fade[0], DIP_LIMITS.black[0])), []);
+ assert.deepEqual(validateDip(dipped(DIP_LIMITS.fade[1], DIP_LIMITS.black[1])), []);
+ const bad = (dip, e = FIN) => validateDip({ ...e, dip }, "fin").join(" | ");
+ assert.match(bad({ fade: 0.2, black: 0.5 }), /^fin\.dip\.fade must be from 0\.3 to 4 seconds$/);
+ assert.match(bad({ fade: 4.5, black: 0.5 }), /fade must be from 0\.3 to 4/);
+ assert.match(bad({ fade: 1, black: -0.1 }), /^fin\.dip\.black must be from 0 to 3 seconds$/);
+ assert.match(bad({ fade: 1, black: 3.5 }), /black must be from 0 to 3/);
+ assert.match(bad({ fade: 1 }), /black must be from 0 to 3/);
+ assert.match(bad({ fade: "1", black: 0 }), /fade must be/);
+ assert.match(bad({ fade: 1, black: 0, colour: "red" }), /^fin\.dip\.colour is not a dip setting \(fade, black\)$/);
+ assert.match(bad(1.2), /^fin\.dip must be \{ fade, black \} in seconds$/);
+ assert.match(bad([1, 0]), /must be \{ fade, black \}/);
+ // Only a teaser dips; dipOf reads only a sound one.
+ assert.match(bad({ fade: 1, black: 0 }, CLIP), /^fin\.dip: only a teaser dips to black before it -- move the dip onto the teaser that follows$/);
+ assert.equal(dipOf({ ...CLIP, dip: { fade: 1, black: 0 } }), null);
+ assert.equal(dipOf({ ...FIN, dip: { fade: 9, black: 0 } }), null);
+ assert.deepEqual(dipOf(dipped(0.9, 0.4)), { fade: 0.9, black: 0.4 });
+});
+
+test("the timeline: a teaser's dip in validateTeaser(s), anywhere else in validateCutEdits; never on the first entry", () => {
+ assert.deepEqual(validateTeaser(dipped()), []);
+ assert.match(validateTeaser({ ...FIN, dip: { fade: 9, black: 0 } }).join(" | "), /dip\.fade must be from 0\.3 to 4/);
+ const ok = { render: RENDER, timeline: [CLIP, dipped()] };
+ assert.deepEqual(validateTeasers(ok), []);
+ assert.deepEqual(validateCutEdits(ok), []);
+ assert.deepEqual(validateTeasers({ timeline: [dipped(), CLIP] }), [
+ "timeline[0] (fin).dip: a dip fades the entry before the teaser to black, and the first entry has none before it",
+ ]);
+ assert.deepEqual(validateCutEdits({ timeline: [{ ...CLIP, dip: { fade: 1, black: 0 } }, FIN] }), [
+ "timeline[0] (c20).dip: only a teaser dips to black before it -- move the dip onto the teaser that follows",
+ ]);
+ // validateCutEdits leaves a teaser's own dip to validateTeasers: one sentence, not two.
+ assert.deepEqual(validateCutEdits({ timeline: [CLIP, { ...FIN, dip: { fade: 9, black: 0 } }] }), []);
+});
+
+test("with a dip, `seconds` is the card's, from where the light comes up: the first line no longer waits for a dissolve", () => {
+ // Without a dip the ferret lines at beat 1.05 need 7.2 s; 7 is refused.
+ assert.match(validateTeaser({ ...FIN, seconds: 7 }).join(" | "), /seconds is 7, and at a beat of 1\.05s its lines need 7\.2s/);
+ // Out of black the first line lands DIP_RISE.before − hit after the black
+ // (0.15 s, not 0.55): the card needs 0.4 s less, and 7 is enough.
+ assert.deepEqual(validateTeaser({ ...dipped(), seconds: 7 }), []);
+ assert.match(
+ validateTeaser({ ...dipped(), seconds: 6.5 }).join(" | "),
+ /seconds is 6\.5, and at a beat of 1\.05s its lines need 6\.8s \(the last pop, the tail's fade and 1\.2s still for the end fade, counted from the end of the dip's black\)/,
+ );
+});
+
+// ---- the arithmetic -----------------------------------------------------------
+
+test("the lead: the dissolve plus the black; the segment is the lead plus the card, auto or set", () => {
+ assert.equal(teaserLead(FIN, 0.5), 0);
+ assert.equal(teaserLead(dipped(0.9, 0.4), 0.5), 0.9);
+ assert.equal(teaserLead(dipped(1.2, 0.6), 0.5), 1.1);
+ assert.equal(teaserLead(dipped(1.6, 1.0), 0.5), 1.5);
+ assert.equal(teaserLead(dipped(1.2, 0.6), 0), 0.6); // a hard cut has no dissolve to hide
+ assert.equal(teaserLead(dipped(1.2, 0), 0), 0);
+ // No dip: the card, as it always was, whatever the transition.
+ assert.equal(teaserSeconds(FIN), 7.2);
+ assert.equal(teaserSeconds(FIN, 0), 7.2);
+ assert.equal(teaserCardSeconds(FIN), 7.2);
+ // A dip: the card (6.8 s, the same for every dip and transition) after the lead.
+ for (const [fade, black, total] of [[0.9, 0.4, 7.7], [1.2, 0.6, 7.9], [1.6, 1, 8.3]]) {
+ assert.equal(teaserCardSeconds(dipped(fade, black)), 6.8);
+ assert.equal(teaserSeconds(dipped(fade, black), 0.5), total);
+ }
+ assert.equal(teaserSeconds(dipped(1.2, 0.6), 0), 7.4);
+ // A set `seconds` is the card's.
+ assert.equal(teaserSeconds({ ...dipped(1.2, 0.6), seconds: 7 }, 0.5), 8.1);
+ // The schedule's estimate counts the lead, at the cut's transition (or a hard cut's).
+ assert.equal(estimatedDuration(dipped(), RENDER), 7.9);
+ assert.equal(estimatedDuration(dipped(), { ...RENDER, transition: 0 }), 7.4);
+ assert.equal(estimatedDuration(dipped(), RENDER, 0), 7.4);
+ assert.equal(estimatedDuration(FIN, RENDER), 7.2);
+});
+
+test("the motion: every line after the lead, the first's impact DIP_RISE.before after the black; nothing else moves", () => {
+ const plain = teaserTimes(teaserLines(FIN), "?", teaserMotion(1.05));
+ assert.deepEqual(teaserMotionOf(FIN, 0.5), teaserMotion(1.05));
+ const m = teaserMotionOf(dipped(1.2, 0.6), 0.5);
+ assert.deepEqual({ ...m, first: 0 }, { ...teaserMotion(1.05), first: 0 });
+ assert.equal(m.first, r4(1.1 + DIP_RISE.before - TEASER_MOTION.hit)); // 1.25
+ const t = teaserTimes(teaserLines(FIN), "?", m);
+ assert.equal(t.lines[0].impact, 1.45);
+ // Every time is the undipped one moved by lead − (first − before + hit) = 1.1 − 0.4.
+ const k = 0.7;
+ t.lines.forEach((l, i) => {
+ assert.equal(r4(l.at - plain.lines[i].at), k);
+ assert.equal(r4(l.impact - plain.lines[i].impact), k);
+ assert.equal(l.subAt == null ? null : r4(l.subAt - plain.lines[i].subAt), plain.lines[i].subAt == null ? null : k);
+ });
+ assert.equal(r4(t.tailAt - plain.tailAt), k);
+ assert.ok(t.need <= teaserSeconds(dipped(1.2, 0.6), 0.5) + 1e-9);
+});
+
+test("the hits: the undipped ones moved by the lead, and a riser before the first, ending on its impact", () => {
+ const plain = teaserHits(FIN);
+ assert.deepEqual(teaserHits(FIN, 0), plain, "no dip: the transition changes nothing");
+ assert.ok(!plain.some((h) => h.kind === "riser"));
+ const hits = teaserHits(dipped(1.2, 0.6), 0.5);
+ const [riser, ...rest] = hits;
+ assert.deepEqual(rest.map((h) => ({ ...h, at: r4(h.at - 0.7) })), plain);
+ assert.deepEqual(riser, {
+ kind: "riser", at: 0.45, role: "dip", gain: DIP_RISER.gain, decay: 0.04, f0: DIP_RISER.f0, f1: DIP_RISER.f1, dur: 1,
+ });
+ assert.equal(r4(riser.at + riser.dur), rest[0].at);
+ // A short lead: the riser starts at the segment's first sample, still ending on the hit.
+ const short = teaserHits(dipped(1, 0.2), 0)[0];
+ assert.deepEqual([short.at, short.dur], [0, 0.55]);
+ assert.deepEqual(teaserHits({ ...dipped(), hits: false }), []);
+});
+
+test("the schedule names the dip on its teaser only; the deck and the feed hide in an instant on the faded frame", () => {
+ const entries = [CLIP, dipped(1.2, 0.6)];
+ const s = deckSchedule({ entries, durs: [7, 7.9], D: 0.5, render: RENDER });
+ assert.equal(s.segments[0].dip, undefined);
+ assert.deepEqual(s.segments[1].dip, { fade: 1.2, black: 0.6 });
+ assert.equal(s.segments[1].hideDeck, true);
+ assert.equal(s.total, 14.4);
+ // Into a dipped teaser: gone at the clip's last frame (6.5 + 0.5 − 1/30),
+ // which the dip has made black, not slid over the fade.
+ const { visibility } = deckChoreography(s, RENDER);
+ assert.equal(dipHideAt(s.segments[1], 0.5, 30), 6.9667);
+ assert.deepEqual(visibility, [{ i: 1, hide: true, at: [6.9667, 6.9667] }]);
+ // Without a dip: the slide over the dissolve, and no `dip` key anywhere.
+ const plain = deckSchedule({ entries: [CLIP, FIN], durs: [7, 7.2], D: 0.5, render: RENDER });
+ assert.ok(plain.segments.every((x) => !("dip" in x)));
+ assert.deepEqual(deckChoreography(plain, RENDER).visibility, [{ i: 1, hide: true, at: [6.5, 7] }]);
+ // A hard cut: the cut itself less a frame.
+ const hard = deckSchedule({ entries, durs: [7, 7.4], D: 0, render: { ...RENDER, transition: 0 } });
+ assert.deepEqual(deckChoreography(hard, { ...RENDER, transition: 0 }).visibility, [{ i: 1, hide: true, at: [6.9667, 6.9667] }]);
+ // The estimate measures the teaser with its lead.
+ const est = estimateSchedule({ render: RENDER, timeline: entries });
+ assert.equal(est.segments[1].duration, 7.9);
+});
+
+// ---- the page -----------------------------------------------------------------
+
+test("the page out of black: bars closed and dark, a veil lifting around the first impact, the leak after the lead", () => {
+ const e = dipped(1.2, 0.6);
+ const lines = teaserLines(e);
+ const seconds = teaserSeconds(e, 0.5);
+ const { init, cues, beats } = teaserCues({ lines, tail: "?", seconds, motion: teaserMotionOf(e, 0.5), dip: { lead: 1.1 } });
+ const of = (k) => cues.filter((c) => c.k === k).map(({ at, dur, from, to, why }) => ({ at, dur, from, to, why }));
+ // The bars: in place from the first frame, invisible, up with the light.
+ assert.deepEqual(init.barT, { yPercent: 0, autoAlpha: 0 });
+ assert.deepEqual(init.barB, { yPercent: 0, autoAlpha: 0 });
+ assert.ok(!cues.some((c) => c.why === "letterbox"), "no bars closing in from black");
+ assert.deepEqual(of("barT"), [{ at: 1.1, dur: 0.65, from: { autoAlpha: 0 }, to: { autoAlpha: 1 }, why: "letterbox up" }]);
+ // The veil: black until the lead ends, then slowly to the hit, then the bloom.
+ assert.equal(beats.lines[0].impact, 1.45);
+ assert.deepEqual(init.veil, { autoAlpha: 1 });
+ assert.deepEqual(of("veil"), [
+ { at: 1.1, dur: 0.35, from: { autoAlpha: 1 }, to: { autoAlpha: 0.45 }, why: "rise" },
+ { at: 1.45, dur: 0.3, from: { autoAlpha: 0.45 }, to: { autoAlpha: 0 }, why: "bloom" },
+ ]);
+ // The leak waits for the lead; the first line's slam starts 0.15 s after it.
+ assert.deepEqual(of("leak").map((c) => [c.at, c.dur, c.why]), [[1.1, 1.4, "leak in"], [2.5, r4(seconds - 2.5), "leak drift"]]);
+ assert.equal(of("l0.o")[0].at, 1.25);
+ // Nothing of the words is up before the light: every line cue is after the lead.
+ assert.ok(cues.filter((c) => /^l\d/.test(c.k)).every((c) => c.at >= 1.1));
+ // Without a dip: the cues they always were (no veil, the bars closing in).
+ const plain = teaserCues({ lines, tail: "?", seconds: teaserSeconds(FIN), motion: teaserMotion(1.05) });
+ assert.ok(!plain.cues.some((c) => c.k === "veil") && !("veil" in plain.init));
+ assert.deepEqual(plain.init.barT, { yPercent: -100 });
+});
+
+test("the page's HTML: a veil node and its rule only with a dip; its length counts the lead at the transition given", () => {
+ const html = teaserHtml(dipped(1.2, 0.6), RENDER);
+ assert.match(html, /data-duration="7\.9"/);
+ assert.match(html, /<div class="leak" data-k="leak"><\/div>\n {10}<div class="veil" data-k="veil"><\/div>\n {10}<div class="column">/);
+ assert.match(html, /\.veil \{ position: absolute;[^}]*background: #000000; \}/);
+ assert.match(teaserHtml(dipped(1.2, 0.6), RENDER, { transition: 0 }), /data-duration="7\.4"/);
+ const plain = teaserHtml(FIN, RENDER);
+ assert.ok(!plain.includes("veil"));
+ assert.equal(teaserHtml(FIN, RENDER, { transition: 0 }), plain, "no dip: the transition is not read");
+});
+
+test("compose-chrome: a dipped teaser renders lead + card frames, keyed by the dip and the transition", async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "dip-compose-"));
+ try {
+ const mp = path.join(dir, "video.manifest.json");
+ const compose = async (entry, extra = {}) => {
+ writeFileSync(mp, JSON.stringify({ slug: "t", render: RENDER, timeline: [CLIP, entry] }));
+ return composeChrome({ manifestPath: mp, region: "teaser", segment: "fin", doRender: false, ...extra });
+ };
+ const plain = await compose(FIN);
+ const b = await compose(dipped(1.2, 0.6));
+ const c = await compose(dipped(1.6, 1));
+ const hard = await compose(dipped(1.2, 0.6), { transition: 0 });
+ assert.deepEqual([plain.frameCount, b.frameCount, c.frameCount, hard.frameCount], [216, 237, 249, 222]);
+ assert.equal(new Set([plain.key, b.key, c.key, hard.key]).size, 4);
+ assert.equal((await compose(FIN, { transition: 0 })).key, plain.key);
+ await assert.rejects(() => compose({ ...FIN, dip: { fade: 1 } }), /dip\.black must be from 0 to 3/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+// ---- the graphs, as strings -----------------------------------------------------
+
+test("the teaser's sound: a riser layer only with a dip; without one the graph it always was", () => {
+ const plain = teaserAudioGraph(teaserHits(FIN), { seconds: 7.2, render: RENDER });
+ assert.ok(!plain.includes("[tw]"));
+ assert.match(plain, /\[tb\]\[tp\]\[tr\]amix=inputs=3:normalize=0,/);
+ const g = teaserAudioGraph(teaserHits(dipped(), 0.5), { seconds: 7.9, render: RENDER });
+ assert.match(g, /,highpass=f=400,lowpass=f=6500\[tw\];\[tb\]\[tp\]\[tr\]\[tw\]amix=inputs=4:normalize=0,/);
+ // The riser's sub is in the boom layer, from 0.45 s to the hit and its release.
+ assert.match(g, /if\(between\(t,0\.45,0\.45\+1\.04\),0\.22\*0\.5\*pow\(min\(\(t-0\.45\),1\)\/1,2\)\*clip\(\(1\.04-\(t-0\.45\)\)\/0\.04,0,1\)\*sin\(2\*PI\*/);
+});
+
+test("the joins: the dip's sound on the segment before the teaser; its record names it; nothing else moves", () => {
+ const dip = { seconds: 1, lastFrame: 89, black: 0.5 };
+ assert.equal(withCutEdits(null, 2), null);
+ const joins = withCutEdits(null, 2, { dips: new Map([[0, dip]]) });
+ assert.deepEqual(joins, [{ hold: 0, move: null, dip }, null]);
+ // The sound fades over the segment's last second, silent at its last frame; the picture has no chain.
+ const c = joinInputChain(0, joins[0], RENDER);
+ assert.equal(endFadeAudioFilter(dip, RENDER), "afade=t=out:st=1.9667:d=1");
+ assert.deepEqual(c, { parts: ["[0:a]afade=t=out:st=1.9667:d=1[j0a]"], v: "[0:v]", a: "[j0a]" });
+ // A hold and a dip on one input: the dip is last.
+ const both = withCutEdits([{ hold: 0.5, move: null }, null], 2, { dips: new Map([[0, dip]]) });
+ assert.match(joinInputChain(0, both[0], RENDER).parts[1], /^\[0:a\]apad=pad_dur=0\.5,afade=t=out:/);
+ // The hard-cut record names the dip, so a changed one is never reused.
+ assert.match(concatRecordText(["a.mp4", "b.mp4"], joins), /# join 0 \{"hold":0,"move":null,"dip":\{"seconds":1,"lastFrame":89,"black":0\.5\}\}/);
+});
+
+test("dipWindows and dipVideoFilter: the cut's frames, a yuv blend to black after every overlay; none without a dip", () => {
+ const joins = withCutEdits(null, 2, { dips: new Map([[0, { seconds: 1, lastFrame: 89, black: 0.5 }]]) });
+ // a is 3 s (frames 0–89), the teaser starts at 2.5 s: frames 59 (untouched) → 89 (black), black to 104.
+ assert.deepEqual(dipWindows(joins, [3, 2.5], 0.5, 30), [{ segment: 0, s: 59, last: 89, until: 105 }]);
+ // A held segment in the middle, fade clamped to nothing more than it, black 0.
+ const mid = [null, { hold: 0.5, move: null, dip: { seconds: 0.5, lastFrame: 104, black: 0 } }, null];
+ assert.deepEqual(dipWindows(mid, [3, 3.5, 2], 0.5, 30), [{ segment: 1, s: 164, last: 179, until: 180 }]);
+ assert.equal(dipWindows(null, [3], 0.5, 30), null);
+ assert.equal(dipWindows([{ hold: 1, move: null }], [3], 0.5, 30), null);
+ const w = dipWindows(joins, [3, 2.5], 0.5, 30);
+ const k = "clip((T-1.9667)/1,0,1)";
+ assert.equal(dipVideoFilter(w, 30),
+ `geq=lum='lum(X,Y)+(16-lum(X,Y))*${k}+0.5':cb='cb(X,Y)+(128-cb(X,Y))*${k}+0.5':cr='cr(X,Y)+(128-cr(X,Y))*${k}+0.5'` +
+ ":enable='between(t,1.9833,3.4833)'");
+ // In a preview window's clock.
+ assert.match(dipVideoFilter(w, 30, 1.5), /clip\(\(T-0\.4667\)\/1,0,1\).*enable='between\(t,0\.4833,1\.9833\)'$/);
+ assert.equal(dipVideoFilter(null, 30), null);
+ assert.deepEqual(dipParts("[x]", null, 30), { parts: [], label: "[x]" });
+});
+
+test("every concat path lays the dips last, and writes the graph it always did without one", () => {
+ const segs = ["a.mp4", "t.mp4"];
+ const regions = [{ name: "deck", frames: "/f/deck-frames", x: 0, y: 890, width: 1920, height: 190 }];
+ const chrome = { regions, outLabel: "[hfout]" };
+ const joins = withCutEdits(null, 2, { dips: new Map([[0, { seconds: 1, lastFrame: 89, black: 0.5 }]]) });
+ const dips = dipWindows(joins, [3, 2.5], 0.5, 30);
+ const fc = (args) => args[args.indexOf("-filter_complex") + 1];
+ const maps = (args) => args.filter((_, i) => args[i - 1] === "-map");
+ // The crossfade: the xfades, the overlay, then the dip.
+ const plain = xfadeConcatArgs({ segments: segs, durs: [3, 2.5], render: RENDER, outPath: "o.mp4", chrome });
+ const g = xfadeGraph([3, 2.5], 0.5, null, RENDER);
+ assert.equal(fc(plain), [...g.parts, chromeOverlayChain(RENDER, regions, g.vlab, 2, { outLabel: "[hfout]", final: true }).chain].join(";"));
+ assert.deepEqual(maps(plain), ["[vout]", g.alab]);
+ const dippedArgs = xfadeConcatArgs({ segments: segs, durs: [3, 2.5], render: RENDER, outPath: "o.mp4", chrome, joins });
+ assert.ok(fc(dippedArgs).endsWith(`format=yuv420p[vout];[vout]${dipVideoFilter(dips, 30)}[vdip]`));
+ assert.deepEqual(maps(dippedArgs), ["[vdip]", "[a1]"]);
+ // A base for the rail: no dip (the rail pass lays it).
+ assert.ok(!fc(xfadeConcatArgs({ segments: segs, durs: [3, 2.5], render: RENDER, outPath: "o", joins, dip: false })).includes("geq"));
+ // The hard cut that is the cut: the dip on the joined picture.
+ assert.equal(maps(hardCutFilterArgs(segs, joins, RENDER, "o.mp4"))[0], "[vc]");
+ const hc = hardCutFilterArgs(segs, joins, RENDER, "o.mp4", dips);
+ assert.ok(fc(hc).endsWith(`concat=n=2:v=1:a=1[vc][ac];[vc]${dipVideoFilter(dips, 30)}[vdip]`));
+ assert.deepEqual(maps(hc), ["[vdip]", "[ac]"]);
+ // The overlay pass over a hard cut: unchanged without dips; with them, after the overlay, in a preview's clock.
+ const ac0 = applyChromeArgs("in.mp4", "o.mp4", RENDER, chrome);
+ assert.equal(fc(ac0), chromeOverlayChain(RENDER, regions, "[0:v]", 1, { final: true }).chain);
+ assert.deepEqual(maps(ac0), ["[vout]", "0:a"]);
+ const ac = applyChromeArgs("in.mp4", "o.mp4", RENDER, chrome, { start: 1.5, dur: 3 }, dips);
+ assert.ok(fc(ac).endsWith(`[vout]${dipVideoFilter(dips, 30, 1.5)}[vdip]`));
+ assert.deepEqual(maps(ac), ["[vdip]", "0:a"]);
+});
+
+// ---- real ffmpeg ------------------------------------------------------------------
+
+const ff = (args, opts = {}) => {
+ const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...args], { maxBuffer: 1 << 28, ...opts });
+ assert.equal(r.status, 0, String(r.stderr));
+ return r.stdout;
+};
+
+/**
+ * Two lossless segments at 320×180: `a`, 3 s of bright test pattern and a
+ * tone; `t`, a stand-in teaser whose first `lead` seconds are black and
+ * silent, then a pattern and another tone. And a bright, opaque PNG sequence
+ * for each of a stand-in deck (the bottom band) and feed (a right column), as
+ * long as the cut -- never hidden, so only the dip can darken them.
+ */
+function material(dir, { lead, frames }) {
+ const make = (name, seconds, src, hz, dark) => {
+ const f = path.join(dir, `${name}.mov`);
+ ff([
+ "-f", "lavfi", "-i", `${src}=s=320x180:r=30:d=${seconds}`,
+ "-f", "lavfi", "-i", `sine=frequency=${hz}:sample_rate=48000:duration=${seconds}`,
+ "-filter_complex",
+ `[0:v]format=yuv420p${dark ? `,drawbox=x=0:y=0:w=iw:h=ih:color=black:t=fill:enable='lt(t,${dark})'` : ""}[v];` +
+ `[1:a]aformat=channel_layouts=stereo${dark ? `,volume=enable='lt(t,${dark})':volume=0` : ""}[a]`,
+ "-map", "[v]", "-map", "[a]", "-c:v", "ffv1", "-c:a", "pcm_s16le", f,
+ ]);
+ return f;
+ };
+ const a = make("a", 3, "testsrc2", 440, 0);
+ const t = make("t", 2.5, "smptebars", 660, lead);
+ const seq = (name, w, h) => {
+ const d = path.join(dir, `${name}-frames`);
+ mkdirSync(d);
+ ff(["-f", "lavfi", "-i", `color=c=white:s=${w}x${h}:r=30`, "-frames:v", String(frames), "-start_number", "1", path.join(d, "frame_%06d.png")]);
+ return d;
+ };
+ const regions = [
+ { name: "deck", frames: seq("deck", 320, 40), x: 0, y: 140, width: 320, height: 40 },
+ { name: "feed", frames: seq("feed", 60, 140), x: 260, y: 0, width: 60, height: 140 },
+ ];
+ return { segs: [a, t], regions };
+}
+
+/** Run argv's graph: the picture as raw yuv420p frames, the sound as mono s16 PCM. */
+function run(args) {
+ const inputs = args.slice(4, args.indexOf("-filter_complex"));
+ const fc = args[args.indexOf("-filter_complex") + 1];
+ const [v, a] = args.filter((_, i) => args[i - 1] === "-map");
+ const pic = ff([...inputs, "-filter_complex", `${fc};${a}anullsink`, "-map", v, "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"]);
+ const pcm = ff([...inputs, "-filter_complex", `${fc};${v}nullsink`, "-map", a, "-f", "s16le", "-ac", "1", "-ar", "48000", "-"]);
+ const size = 320 * 180 * 1.5;
+ const frames = [];
+ for (let o = 0; o + size <= pic.length; o += size) frames.push(pic.subarray(o, o + size));
+ return { frames, pcm };
+}
+
+/** A frame's worst distance from black (Y 16, Cb/Cr 128) over EVERY pixel, and its mean luma. */
+function darkness(frame) {
+ const Y = 320 * 180;
+ let worst = 0;
+ let sum = 0;
+ for (let i = 0; i < frame.length; i += 1) {
+ const d = Math.abs(frame[i] - (i < Y ? 16 : 128));
+ if (d > worst) worst = d;
+ if (i < Y) sum += frame[i];
+ }
+ return { worst, mean: sum / Y };
+}
+const lumaAt = (frame, x, y) => frame[y * 320 + x];
+const peak = (pcm, a, b) => {
+ let m = 0;
+ for (let i = Math.round(a * 48000); i < Math.min(pcm.length / 2, Math.round(b * 48000)); i += 1) m = Math.max(m, Math.abs(pcm.readInt16LE(i * 2)));
+ return m;
+};
+
+test("ffmpeg: a dipped crossfade -- the whole frame, overlays included, black from the clip's last frame for the black span; silent there; untouched before",
+ { skip: !have && "no ffmpeg" }, async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "dip-xfade-"));
+ try {
+ // fade 1 s, black 0.5 s, dissolve 0.5 s: the stand-in's lead is 1.0 s.
+ const black = 0.5;
+ const D = 0.5;
+ const { segs, regions } = material(dir, { lead: D + black, frames: 150 });
+ const entries = [{ type: "clip", id: "a" }, { type: "teaser", id: "t", lines: ["x"], dip: { fade: 1, black } }];
+ const joins = await cutJoins({ entries, segments: segs, render: RENDER });
+ assert.deepEqual(joins, [{ hold: 0, move: null, dip: { seconds: 1, lastFrame: 89, black } }, null]);
+ const { durs } = await cutOffsets(segs, D, 30, joins);
+ assert.deepEqual(durs, [3, 2.5]);
+ const chrome = { regions, outLabel: "[hfout]" };
+ const R = { ...RENDER, width: 320, height: 180 };
+ const without = run(xfadeConcatArgs({ segments: segs, durs, render: R, outPath: "-", chrome }));
+ const withDip = run(xfadeConcatArgs({ segments: segs, durs, render: R, outPath: "-", chrome, joins }));
+ assert.equal(withDip.frames.length, 150);
+ assert.equal(without.frames.length, 150, "the dip changes no length");
+ assert.equal(withDip.pcm.length, without.pcm.length);
+ const [w] = dipWindows(joins, durs, D, 30);
+ assert.deepEqual(w, { segment: 0, s: 59, last: 89, until: 105 });
+ // Before the fade: the undipped cut, frame for frame.
+ for (let f = 0; f <= 59; f += 1) assert.ok(withDip.frames[f].equals(without.frames[f]), `frame ${f} untouched`);
+ // Half way: the footage AND the stand-in deck (white, Y 235) half way to black.
+ const mid = withDip.frames[74];
+ assert.ok(Math.abs(lumaAt(mid, 160, 160) - (16 + (235 - 16) * 0.5)) <= 2, `deck at mid-fade: ${lumaAt(mid, 160, 160)}`);
+ assert.ok(Math.abs(lumaAt(mid, 290, 70) - (16 + (235 - 16) * 0.5)) <= 2, `feed at mid-fade: ${lumaAt(mid, 290, 70)}`);
+ // The clip's last frame through the black: every pixel black, the lit overlays with it.
+ for (let f = 89; f < 105; f += 1) {
+ const d = darkness(withDip.frames[f]);
+ assert.equal(d.worst, 0, `frame ${f} is black across the whole frame (worst ${d.worst})`);
+ assert.ok(darkness(without.frames[f]).worst > 200, `frame ${f} is lit without the dip`);
+ }
+ // The dip ends where the black does: the frame after is the undipped one.
+ assert.ok(withDip.frames[105].equals(without.frames[105]));
+ assert.ok(darkness(withDip.frames[105]).mean > 60);
+ // The sound: the same up to the fade, faded by the clip's last frame, silent through the black.
+ const st = Math.round((59 / 30) * 48000) * 2;
+ assert.ok(withDip.pcm.subarray(0, st).equals(without.pcm.subarray(0, st)), "the same sound before the fade");
+ assert.ok(peak(withDip.pcm, 2.5, 2.6) < peak(without.pcm, 2.5, 2.6) * 0.75, "fading");
+ assert.equal(peak(withDip.pcm, 89 / 30, 105 / 30), 0, "digital silence from the last frame through the black");
+ assert.ok(peak(without.pcm, 89 / 30, 3) > 100, "the clip still sounds there without the dip");
+ assert.ok(peak(withDip.pcm, 3.6, 4.9) > 1000, "the teaser's own sound after its lead");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+test("ffmpeg: the hard cut and the overlay pass over it -- black across the whole frame, then the teaser",
+ { skip: !have && "no ffmpeg" }, async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "dip-hard-"));
+ try {
+ // No dissolve: the lead is the black alone.
+ const black = 0.5;
+ const { segs, regions } = material(dir, { lead: black, frames: 165 });
+ const R = { ...RENDER, width: 320, height: 180, transition: 0 };
+ const entries = [{ type: "clip", id: "a" }, { type: "teaser", id: "t", lines: ["x"], dip: { fade: 1, black } }];
+ const joins = await cutJoins({ entries, segments: segs, render: R });
+ const { durs } = await cutOffsets(segs, 0, 30, joins);
+ const dips = dipWindows(joins, durs, 0, 30);
+ assert.deepEqual(dips, [{ segment: 0, s: 59, last: 89, until: 105 }]);
+ // The concat is the cut: the dip goes on it.
+ const cut = run(hardCutFilterArgs(segs, joins, R, "-", dips));
+ assert.equal(cut.frames.length, 165);
+ for (let f = 89; f < 105; f += 1) assert.equal(darkness(cut.frames[f]).worst, 0, `frame ${f}`);
+ assert.ok(darkness(cut.frames[105]).mean > 60);
+ assert.equal(peak(cut.pcm, 89 / 30, 105 / 30), 0);
+ // The overlay pass over a hard-cut base (no dip in it): the stand-ins go black with the picture.
+ const base = path.join(dir, "base.mov");
+ const hb = hardCutFilterArgs(segs, joins, R, base);
+ ff([...hb.slice(4, hb.indexOf("-map")), "-map", "[vc]", "-map", "[ac]", "-c:v", "ffv1", "-c:a", "pcm_s16le", base]);
+ const over = applyChromeArgs(base, "-", R, { regions, outLabel: "[hfout]" }, null, dips);
+ const pic = ff([...over.slice(4, over.indexOf("-filter_complex")), "-filter_complex", over[over.indexOf("-filter_complex") + 1],
+ "-map", "[vdip]", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"]);
+ const size = 320 * 180 * 1.5;
+ const frame = (f) => pic.subarray(f * size, (f + 1) * size);
+ assert.ok(lumaAt(frame(30), 160, 160) > 200, "the stand-in deck is lit before the dip");
+ for (let f = 89; f < 105; f += 1) assert.equal(darkness(frame(f)).worst, 0, `overlay frame ${f}`);
+ assert.ok(lumaAt(frame(110), 160, 160) > 200, "and lit after it (a real deck has hidden by then)");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+test("ffmpeg: the dipped teaser's sound -- silence until the riser, the riser rising into the first hit, under the ceiling",
+ { skip: !have && "no ffmpeg" }, () => {
+ const e = dipped(1.2, 0.6);
+ const seconds = teaserSeconds(e, 0.5);
+ const hits = teaserHits(e, 0.5);
+ const graph = teaserAudioGraph(hits, { seconds, render: RENDER });
+ const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-filter_complex", graph, "-map", "[ta]", "-f", "f32le", "-ac", "2", "-"], { maxBuffer: 1 << 26 });
+ assert.equal(r.status, 0, String(r.stderr));
+ // Channel 0 (a downmix to mono would sum the two at −3 dB each).
+ const both = new Float32Array(r.stdout.buffer, r.stdout.byteOffset, r.stdout.length / 4);
+ const f = both.filter((_, i) => i % 2 === 0);
+ assert.ok(Math.abs(f.length / 48000 - seconds) < 0.001);
+ const rms = (a, b) => {
+ let s = 0;
+ const i0 = Math.round(a * 48000);
+ const i1 = Math.round(b * 48000);
+ for (let i = i0; i < i1; i += 1) s += f[i] * f[i];
+ return Math.sqrt(s / (i1 - i0));
+ };
+ let pk = 0;
+ for (const v of f) pk = Math.max(pk, Math.abs(v));
+ const [riser, first] = hits;
+ let lead = 0;
+ for (let i = 0; i < Math.round(riser.at * 48000) - 48; i += 1) lead = Math.max(lead, Math.abs(f[i]));
+ assert.equal(lead, 0, "digital silence before the riser");
+ assert.ok(rms(first.at - 0.15, first.at) > 4 * rms(riser.at, riser.at + 0.3), "the riser builds into the hit");
+ assert.ok(rms(first.at, first.at + 0.15) > rms(first.at - 0.15, first.at), "and the hit lands on top of it");
+ assert.ok(pk <= 0.52, `under −6 dBFS (peak ${pk.toFixed(3)})`);
+ });