// 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, /