Archilyzer · Source

archilyzer

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

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:
Mumtool/report-to-video/deck.mjs | 2+-
Aumtool/report-to-video/dip.test.mjs | 516+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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)})`); + });