// 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"; import { verifyTeasers } from "./verify-build.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 black is whole frames at the cut's fps: the lead, the page's rise, the frame count and the cut's black agree", () => { const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 0.01, `${msg}: ${a} != ${b}`); // Already whole frames: the very number given, so nothing built from it changes. for (const [black, fps] of [[0.6, 30], [0.4, 30], [1, 30], [0, 30], [3, 30], [0.6, 25], [0.45, 60]]) { assert.ok(Object.is(dipOf(dipped(1.2, black), fps).black, black), `${black} at ${fps}`); } assert.deepEqual(dipOf(dipped()), { fade: 1.2, black: 0.6 }); // fps defaults to 30 // Between frames: the nearest one (13.5 frames → 14 at 30 fps; 11.25 → 11 at 25). assert.equal(dipOf(dipped(1, 0.45), 30).black, 0.4667); assert.equal(dipOf(dipped(1, 0.45), 25).black, 0.44); assert.equal(dipOf(dipped(1, 0.4667), 30).black, 0.4667); // snapping is idempotent // Only the black is snapped: the fade's frames are endFadeFrames', on the joined segment. assert.equal(dipOf(dipped(1.05, 0.45), 30).fade, 1.05); const e = dipped(1, 0.45); for (const fps of [25, 30]) { const black = dipOf(e, fps).black; const lead = teaserLead(e, 0.5, fps); assert.equal(lead, r4(0.5 + black)); // The lead is whole frames past the dissolve; at 30 fps (where a 0.5 s // dissolve and a card in tenths are whole frames too) so is the segment. near(lead * fps - 0.5 * fps, Math.round(black * fps), `lead at ${fps}`); const seconds = teaserSeconds(e, 0.5, fps); assert.equal(seconds, r4(lead + teaserCardSeconds(e))); if (fps === 30) near(seconds * fps, Math.round(seconds * fps), "segment at 30"); // The light comes up where the black ends: the motion and the page read the snapped lead. assert.equal(teaserMotionOf(e, 0.5, fps).first, r4(lead + DIP_RISE.before - teaserMotion(e.beat).hit)); assert.equal( teaserHtml(e, { ...RENDER, fps }, { transition: 0.5 }), teaserHtml(dipped(1, black), { ...RENDER, fps }, { transition: 0.5 }), ); // The joined cut's black ends on the frame the teaser's lead does. const joins = withCutEdits(null, 2, { dips: new Map([[0, { seconds: 1, lastFrame: 89, black }]]) }); const [w] = dipWindows(joins, [3, seconds], 0.5, fps); near(w.until - (w.last + 1), black * fps, `until at ${fps}`); } // The schedule carries the snapped black, and the estimate counts it. const s = deckSchedule({ entries: [CLIP, e], durs: [7, teaserSeconds(e, 0.5, 30)], D: 0.5, render: RENDER }); assert.deepEqual(s.segments[1].dip, { fade: 1, black: 0.4667 }); assert.equal(estimatedDuration(e, RENDER), teaserSeconds(e, 0.5, 30)); assert.equal(estimatedDuration(e, { ...RENDER, fps: 25 }), teaserSeconds(e, 0.5, 25)); }); 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>\n {10}
<\/div>\n {10}
/); 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("verify-build: a dipped teaser built --no-xfade is counted at the build's transition, not the manifest's", async () => { const dir = mkdtempSync(path.join(tmpdir(), "dip-verify-")); try { // A cut without the deck (no schedule.json) whose manifest crossfades 0.5 s. const manifest = { render: { ...RENDER, chrome: undefined }, timeline: [CLIP, dipped()] }; const frames = path.join(dir, "chrome", "teaser-fin-frames"); mkdirSync(frames, { recursive: true }); mkdirSync(path.join(dir, "segments")); // Built --no-xfade: the lead is the black alone, 7.4 s = 222 frames. const n = Math.round(teaserSeconds(dipped(), 0, 30) * 30); assert.equal(n, 222); for (let i = 1; i <= n; i += 1) writeFileSync(path.join(frames, `frame_${String(i).padStart(6, "0")}.png`), ""); writeFileSync(path.join(frames, ".key"), "k1\n"); const rec = path.join(dir, "segments", "fin.teaser.json"); const run = async (record, opts) => { writeFileSync(rec, JSON.stringify(record) + "\n"); const problems = []; const [t] = await verifyTeasers(dir, manifest, problems, opts); return { problems, t }; }; // The record names the transition it was built at: nothing to say, whatever the flag. for (const opts of [undefined, { noXfade: true }]) { const { problems, t } = await run({ key: "s", frames: "k1", hits: 4, transition: 0 }, opts); assert.deepEqual(problems, []); assert.deepEqual(t, { id: "fin", frames: 222, expectedFrames: 222, current: true }); } // An older record without it: --no-xfade says what the build did. assert.deepEqual((await run({ key: "s", frames: "k1", hits: 4 }, { noXfade: true })).problems, []); // Without either, the manifest's 0.5 s is assumed and the 15 frames are missing. const { problems } = await run({ key: "s", frames: "k1", hits: 4 }); assert.deepEqual(problems, [`${frames} holds 222 frames; the teaser fin is 237 (7.9s at 30 fps)`]); } finally { rmSync(dir, { recursive: true, force: true }); } }); 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 fade to black in yuv 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); // `fade` to black, in the stream's own yuv420p: from frame 59 over 30 frames, on from 59.5 to 104.5. assert.equal(dipVideoFilter(w, 30), "fade=t=out:st=1.966667:d=1:enable='between(t,1.983333,3.483333)'"); // In a preview window's clock. assert.equal(dipVideoFilter(w, 30, 1.5), "fade=t=out:st=0.466667:d=1:enable='between(t,0.483333,1.983333)'"); // A preview that starts inside the fade: the same blend in geq, from where it already is. const k = "clip((T-(-0.533333))/1,0,1)"; assert.equal(dipVideoFilter(w, 30, 2.5), `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,-0.516667,0.983333)'"); 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]"]); // Into the dipped teaser the outgoing segment plays the whole overlap, picture and sound. assert.match(fc(dippedArgs), /\[0:v\]\[1:v\]xfade=transition=custom:expr='A':duration=0\.5:offset=2\.500\[v1\]/); assert.match(fc(dippedArgs), /\[p0a\]\[p1a\]acrossfade=d=0\.5:c1=nofade:c2=nofade\[a1\]/); // Only that join: a three-segment cut dipping into the last keeps its first dissolve. const three = xfadeGraph([3, 3, 2.5], 0.5, [null, { hold: 0, move: null, dip: { seconds: 1, lastFrame: 89, black: 0 } }, null], RENDER); assert.match(three.parts.join(";"), /\[0:v\]\[1:v\]xfade=transition=fade:.*\[v1\]\[2:v\]xfade=transition=custom:expr='A'/); // 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)}`); // Through the overlap (frames 75–89) the clip is not dissolved into the // teaser's black: the footage and the overlays fade in step, by the dip alone. const own = ff(["-i", segs[0], "-vf", "select=eq(n\\,82)", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"]); const k = (82 - 59) / 30; const f82 = withDip.frames[82]; assert.ok(Math.abs(lumaAt(f82, 100, 60) - (16 + (lumaAt(own, 100, 60) - 16) * (1 - k))) <= 2, "footage by the dip alone"); assert.ok(Math.abs(lumaAt(f82, 160, 160) - (16 + 219 * (1 - k))) <= 2, `deck in step: ${lumaAt(f82, 160, 160)}`); // 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"); // In the overlap the clip's sound is its own fade's alone (no crossfade on top): about (1 − k) of it. assert.ok(peak(withDip.pcm, 2.8, 2.85) > 0.08 * peak(without.pcm, 1.0, 1.5), "not crossfaded away early"); 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)"); // A preview window starting inside the fade (2.5 s in, the geq form): the same pictures as the cut's. const pv = applyChromeArgs(base, "-", R, { regions, outLabel: "[hfout]" }, { start: 2.5, dur: 1.5 }, dips); assert.match(pv[pv.indexOf("-filter_complex") + 1], /\[vout\]geq=/); const pp = ff([...pv.slice(4, pv.indexOf("-filter_complex")), "-filter_complex", pv[pv.indexOf("-filter_complex") + 1], "-map", "[vdip]", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"]); const pframe = (f) => pp.subarray((f - 75) * size, (f - 74) * size); assert.equal(pp.length / size, 45); for (const [x, y] of [[160, 160], [290, 70], [100, 60]]) { assert.ok(Math.abs(lumaAt(pframe(80), x, y) - lumaAt(frame(80), x, y)) <= 1, `preview mid-fade at ${x},${y}`); } for (let f = 89; f < 105; f += 1) assert.equal(darkness(pframe(f)).worst, 0, `preview frame ${f}`); assert.ok(pframe(105).equals(frame(105))); } 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)})`); });