Archilyzer · Source

archilyzer

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

commit 7935625a2f39b4c272cd5df670551293de239882
parent 43a72390afbe551e94516e13a462ce52f0ac3f07
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 20:29:57 -0400

report-to-video: a teaser's dip -- `dip: { fade, black }` validated (only a teaser, not the first entry, fade 0.3-4 s, black 0-3 s); its lead is the dissolve plus the black, its card and lines start after it, its hits carry a riser into the first impact; the schedule names the dip and the deck and feed hide in an instant on the dipped segment's last frame; the page opens out of black (bars closed, a veil lifting around the first impact)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/report-to-video/chrome-teaser.mjs | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mumtool/report-to-video/compose-chrome.mjs | 19++++++++++++-------
Mumtool/report-to-video/deck.mjs | 184++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mumtool/report-to-video/verify-build.mjs | 8++++++--
4 files changed, 234 insertions(+), 43 deletions(-)

diff --git a/umtool/report-to-video/chrome-teaser.mjs b/umtool/report-to-video/chrome-teaser.mjs @@ -24,7 +24,10 @@ // other number; the grain's jitter is a seeded sequence of instant sets. import { fileURLToPath } from "node:url"; -import { TEASER_MOTION, teaserLines, teaserMotion, teaserSeconds, teaserTail, teaserTimes } from "./deck.mjs"; +import { + DIP_RISE, dipOf, TEASER_MOTION, teaserLead, teaserLines, teaserMotionOf, teaserSeconds, teaserTail, teaserTimes, + transitionOf, +} from "./deck.mjs"; export { TEASER_MOTION }; import { mix, rgba } from "./chrome-deck.mjs"; @@ -76,10 +79,20 @@ export function seeded(seed) { * `l<i>.streak`, `l<i>.rules` (an overline's accent rules), `l<i>.sub` and * `l<i>.subt` (the second tier); `tail`, `tail.t`, `tail.glow`. * + * `dip` (`{ lead }`, a teaser that dips; `motion` is then `teaserMotionOf`'s, + * its lines already after the lead) opens the card out of black like a + * trailer: the letterbox is closed from the first frame and comes up with the + * light; `veil`, a black layer over the ground and the light leak (under the + * words), holds the frame black for the `lead` and lifts around the first + * line's impact (DIP_RISE: from `before` ahead of it, slowly at first, to + * `after` past it, the bloom), so the first hit is the moment the light comes + * on; the leak's entrance waits for the lead. Without `dip` the cues are + * exactly what they always were. + * * @returns {{ init: Record<string, object>, cues: Array<{ k: string, at: number, dur: number, * from: object, to: object, ease: string, why: string }>, beats: object, scale: number }} */ -export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION }) { +export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION, dip = null }) { const m = motion; // The times are deck.mjs's, the same the build places the hits by. const beats = teaserTimes(lines, tail, m); @@ -93,13 +106,32 @@ export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION } // ---- the ground: letterbox, push-in, light, grain ---------------------- put("stage", { scale: 1 }); add("stage", 0, seconds, { scale: m.push }, "none", "push-in"); - put("barT", { yPercent: -100 }); - put("barB", { yPercent: 100 }); - add("barT", T(0.15), T(0.9), { yPercent: 0 }, "power3.inOut", "letterbox"); - add("barB", T(0.15), T(0.9), { yPercent: 0 }, "power3.inOut", "letterbox"); - put("leak", { x: -420, autoAlpha: 0 }); - add("leak", 0, T(1.4), { autoAlpha: 1 }, "power1.out", "leak in"); - add("leak", T(1.4), Math.max(INSTANT, seconds - T(1.4)), { x: 420 }, "none", "leak drift"); + if (dip) { + // Out of black: the bars are closed already and come up with the light; + // the veil over the ground lifts in two strokes either side of the first + // impact -- slowly, then the bloom. + const lead = r4(dip.lead); + const hit = beats.lines[0]?.impact ?? r4(lead + DIP_RISE.before); + const up = r4(hit + DIP_RISE.after); + put("barT", { yPercent: 0, autoAlpha: 0 }); + put("barB", { yPercent: 0, autoAlpha: 0 }); + add("barT", lead, up - lead, { autoAlpha: 1 }, "power2.in", "letterbox up"); + add("barB", lead, up - lead, { autoAlpha: 1 }, "power2.in", "letterbox up"); + put("veil", { autoAlpha: 1 }); + add("veil", lead, hit - lead, { autoAlpha: 0.45 }, "power2.in", "rise"); + add("veil", hit, up - hit, { autoAlpha: 0 }, "power2.out", "bloom"); + put("leak", { x: -420, autoAlpha: 0 }); + add("leak", lead, T(1.4), { autoAlpha: 1 }, "power1.out", "leak in"); + add("leak", lead + T(1.4), Math.max(INSTANT, seconds - lead - T(1.4)), { x: 420 }, "none", "leak drift"); + } else { + put("barT", { yPercent: -100 }); + put("barB", { yPercent: 100 }); + add("barT", T(0.15), T(0.9), { yPercent: 0 }, "power3.inOut", "letterbox"); + add("barB", T(0.15), T(0.9), { yPercent: 0 }, "power3.inOut", "letterbox"); + put("leak", { x: -420, autoAlpha: 0 }); + add("leak", 0, T(1.4), { autoAlpha: 1 }, "power1.out", "leak in"); + add("leak", T(1.4), Math.max(INSTANT, seconds - T(1.4)), { x: 420 }, "none", "leak drift"); + } const rnd = seeded(0x7ea5e); put("grain", { x: 0, y: 0 }); const steps = Math.floor(seconds * m.grainHz); @@ -194,14 +226,19 @@ export function teaserHtml(entry, render, opts = {}) { const pal = render.palette; const W = render.width ?? 1920; const H = render.height ?? 1080; - const seconds = teaserSeconds(entry); + // The cut's transition, read only for a dip: its lead is the dissolve and the black. + const D = opts.transition ?? transitionOf(render); + const seconds = teaserSeconds(entry, D); if (!(seconds > 0)) throw new Error(`teaser ${entry.id}: seconds must be positive`); const font = opts.font ?? TEASER_FONT_ASSET; const gsapSrc = opts.gsap ?? "assets/gsap.min.js"; const lines = teaserLines(entry); if (!lines.length) throw new Error(`teaser ${entry.id}: no lines`); const tail = teaserTail(entry); - const { init, cues, beats } = teaserCues({ lines, tail, seconds, motion: teaserMotion(entry.beat) }); + const dipped = !!dipOf(entry); + const { init, cues, beats } = teaserCues({ + lines, tail, seconds, motion: teaserMotionOf(entry, D), dip: dipped ? { lead: teaserLead(entry, D) } : null, + }); // The ground: the palette's bg, lifted a touch toward the accent at the // centre and falling toward black at the edges. @@ -323,7 +360,10 @@ export function teaserHtml(entry, render, opts = {}) { .vignette { position: absolute; inset: 0; background: radial-gradient(ellipse 75% 70% at 50% 50%, rgba(0, 0, 0, 0) 55%, rgba(0, 0, 0, 0.55) 100%); } .grain { position: absolute; left: -240px; top: -160px; width: ${W + 480}px; height: ${H + 320}px; - opacity: 0.11; mix-blend-mode: overlay; } + opacity: 0.11; mix-blend-mode: overlay; }${dipped ? ` + /* The dip's veil: pure black over the ground and the light, under the + words; the stage's push only ever grows it past the frame. */ + .veil { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; background: #000000; }` : ""} </style> </head> <body> @@ -332,7 +372,7 @@ export function teaserHtml(entry, render, opts = {}) { <div id="teaser-clip" class="clip" data-start="0" data-duration="${r4(seconds)}" data-track-index="1"> <div class="ground"></div> <div class="stage" data-k="stage"> - <div class="leak" data-k="leak"></div> + <div class="leak" data-k="leak"></div>${dipped ? '\n <div class="veil" data-k="veil"></div>' : ""} <div class="column"> ${lineHtml} </div> diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs @@ -39,7 +39,8 @@ import path from "node:path"; import { ledgerTotals, dateKey } from "./ledger-totals.mjs"; import { selectVariant } from "./build-video.mjs"; import { - chromeCacheKey, deckLayout, frameCount, hyperframesCommand, postWindows, resolveDeck, sha256, teaserSeconds, validateTeaser, + chromeCacheKey, deckLayout, frameCount, hyperframesCommand, postWindows, resolveDeck, sha256, teaserSeconds, transitionOf, + validateTeaser, } from "./deck.mjs"; import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs"; import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs"; @@ -614,7 +615,7 @@ async function copyFonts(render, assetsDir, names, { strict }) { * `projDir/assets`. The chart's branch is the band as it shipped; only where * its GSAP comes from has changed. */ -async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule, duration, from, window, teaser }) { +async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule, duration, from, window, teaser, transition }) { if (region === "chart") { const sched = schedule ?? JSON.parse(await readFile(path.join(base, "schedule.json"), "utf8")); const fonts = await copyFonts(manifest.render, assetsDir, { regular: "regular", bold: "bold" }, { strict: false }); @@ -659,7 +660,7 @@ async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule // unless a teaser is being composed (docs/quirks.md). const { teaserHtml, TEASER_FONT_FILE, TEASER_FONT_ASSET } = await import("./chrome-teaser.mjs"); await copyFile(TEASER_FONT_FILE, path.join(projDir, TEASER_FONT_ASSET)); - return teaserHtml(teaser, manifest.render, { font: TEASER_FONT_ASSET }); + return teaserHtml(teaser, manifest.render, { font: TEASER_FONT_ASSET, transition }); } throw new Error(`unknown chrome region: ${region}`); } @@ -733,7 +734,10 @@ function runRenderer(cmd, args) { * frames `chrome/teaser-<id>-frames/` and their `.key`, cached as the deck's * are -- the key hashes the page, so changed words are a new render; * - the build encodes the frames into `segments/<id>.mp4` (build-video's - * `buildTeaserSegment`). + * `buildTeaserSegment`); + * - `transition` is the cut's crossfade, read only by a teaser that dips (its + * lead is the dissolve and the black): the build passes its own, so a + * `--no-xfade` build composes the lead it plays; default the manifest's. * * `schedule` (an object) overrides reading `out/<variant>/schedule.json`. * @@ -745,7 +749,7 @@ export async function composeChrome({ manifestPath, outDir = null, variant = "sourced", region = "chart", schedule = null, preview = false, doRender = false, fps = null, workers = null, quality = "high", format = "png-sequence", - still = null, png = null, from = 0, duration = null, window = null, segment = null, + still = null, png = null, from = 0, duration = null, window = null, segment = null, transition = null, }) { // The variant's view, and its own out directory. Handed the whole manifest // the band would draw claims this cut never makes, and the deck would name @@ -778,7 +782,8 @@ export async function composeChrome({ if (region === "feed" && sched.layout !== "feed") { throw new Error("the feed region needs a feed's schedule (layout \"feed\": posts.layout \"feed\" and posts to draw)"); } - const total = teaser ? teaserSeconds(teaser) : keyed ? sched.total : null; + const D = transition ?? transitionOf(manifest.render); + const total = teaser ? teaserSeconds(teaser, D) : keyed ? sched.total : null; const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30); const windowed = (region === "deck" || region === "feed") && @@ -814,7 +819,7 @@ export async function composeChrome({ const html = await regionHtml(region, { manifest, base, projDir, assetsDir, schedule: sched, - duration: duration != null ? Number(duration) : null, from, window: win, teaser, + duration: duration != null ? Number(duration) : null, from, window: win, teaser, transition: D, }); await writeFile(path.join(projDir, "index.html"), html, "utf8"); await writeFile(path.join(projDir, "hyperframes.json"), HF_JSON + "\n", "utf8"); diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -402,13 +402,14 @@ export function scheduleFrom(durs, D) { * snapping moves the real one by a fraction of a second, which is why a * schedule built from these says `estimated: true`. */ -export function estimatedDuration(entry, render = {}) { +export function estimatedDuration(entry, render = {}, D = transitionOf(render)) { if (entry.type === "clip") { const { from, to } = playWindow(entry, render); return Math.max(1, to - from); } if (entry.type === "image") return Number(entry.seconds ?? 4); - if (entry.type === "teaser") return teaserSeconds(entry); + // A teaser's dip makes its segment longer by the dissolve and the black (`teaserLead`). + if (entry.type === "teaser") return teaserSeconds(entry, D); return Number(entry.seconds ?? 5); } @@ -551,6 +552,8 @@ export function deckSchedule({ subtitle, qrUrl: deck.qr.show ? deckQrUrl(e, provenance) : null, hideDeck: hidesDeck(e, deck), + // Only on a teaser that dips, so a cut without one writes the schedule it always did. + ...(dipOf(e) ? { dip: dipOf(e) } : {}), }; }), // Present only when there are posts to draw, so a cut without them writes @@ -574,11 +577,12 @@ export function roundPosts(placed) { export function estimateSchedule(manifest, { metas = [], noXfade = false } = {}) { const render = manifest.render ?? {}; const entries = manifest.timeline ?? []; + const D = transitionOf(render, { noXfade }); return deckSchedule({ entries, posts: manifest.posts ?? [], - durs: entries.map((e) => estimatedDuration(e, render)), - D: transitionOf(render, { noXfade }), + durs: entries.map((e) => estimatedDuration(e, render, D)), + D, render, provenance: manifest.provenance ?? {}, metas, @@ -623,8 +627,13 @@ export function deckChoreography(schedule, render) { const a = segs[i - 1].hideDeck; const b = segs[i].hideDeck; if (a !== b) { - // Gone before the card is fully up; back once the footage is. - const at = D > 0 ? [segs[i].start, segs[i].start + slide] : b ? [m - slide, m] : [m, m + slide]; + // Gone before the card is fully up; back once the footage is. Into a + // teaser that dips, the deck is gone in an instant at the frame the + // dip has made black -- the previous segment's last (`dipHideAt`) -- + // so nothing slides over the fade or the rise. + const at = b && segs[i].dip + ? [dipHideAt(segs[i], D, schedule.fps), dipHideAt(segs[i], D, schedule.fps)] + : D > 0 ? [segs[i].start, segs[i].start + slide] : b ? [m - slide, m] : [m, m + slide]; visibility.push({ i, hide: b, at }); continue; } @@ -1049,10 +1058,17 @@ export function validateEndFade(render) { return []; } -/** Every `muteFrom` in the timeline and `render.endFade`, checked: the build refuses with these before it fetches. */ +/** + * Every `muteFrom` in the timeline, a `dip` on anything but a teaser, and + * `render.endFade`, checked: the build refuses with these before it fetches. + */ export function validateCutEdits(manifest) { const errors = []; - (manifest?.timeline ?? []).forEach((e, i) => errors.push(...validateMuteFrom(e, `timeline[${i}] (${e?.id ?? "?"})`))); + (manifest?.timeline ?? []).forEach((e, i) => { + errors.push(...validateMuteFrom(e, `timeline[${i}] (${e?.id ?? "?"})`)); + // A teaser's dip is validateTeasers'; a dip anywhere else is refused here. + if (e?.type !== "teaser") errors.push(...validateDip(e, `timeline[${i}] (${e?.id ?? "?"})`)); + }); errors.push(...validateEndFade(manifest?.render)); return errors; } @@ -1256,19 +1272,124 @@ export function teaserTimes(lines, tail, m = TEASER_MOTION) { }; } +// ---- the dip: the cut goes to black before a teaser ------------------------ +// +// { "type": "teaser", "id": "fin", "dip": { "fade": 1.2, "black": 0.6 }, … } +// +// The previous segment's last `fade` seconds -- the WHOLE frame as the cut +// plays it, footage and every overlay, and its sound -- ease to black and +// silence, ending on its last frame; then `black` seconds of black; then the +// teaser comes up out of it. The black is the teaser's own LEAD +// (`teaserLead`): its page and its sound start with the dissolve into it and +// the black, both dark, so the dissolve is black on black and no hold is +// needed anywhere. The fade is made where the cut is joined (build-video's +// `dipWindows`), so `--chrome-only` changes it without touching a clip. + +/** A dip's limits, in seconds: the fade out, and the black after it. */ +export const DIP_LIMITS = Object.freeze({ fade: Object.freeze([0.3, 4]), black: Object.freeze([0, 3]) }); + +/** + * The rise out of a dip, in seconds around the first line's impact: the veil + * over the ground starts lifting `before` it, as the black ends, and is gone + * `after` it -- so the first hit is the moment the light comes on. The first + * line's slam starts `before − hit` after the black (0.15 s). + */ +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 }); + +const DIP_KEYS = ["fade", "black"]; + +/** + * Why one entry's `dip` cannot be built, as sentences. Only a teaser dips (for + * now); `fade` and `black` are both required, in DIP_LIMITS. Absent is fine. + */ +export function validateDip(entry, where = `timeline entry ${entry?.id ?? "?"}`) { + const v = entry?.dip; + if (v === undefined || v === null) return []; + if (entry.type !== "teaser") { + return [`${where}.dip: only a teaser dips to black before it -- move the dip onto the teaser that follows`]; + } + if (!isObj(v)) return [`${where}.dip must be { fade, black } in seconds`]; + const errors = []; + for (const k of Object.keys(v)) if (!DIP_KEYS.includes(k)) errors.push(`${where}.dip.${k} is not a dip setting (fade, black)`); + const [flo, fhi] = DIP_LIMITS.fade; + const [blo, bhi] = DIP_LIMITS.black; + if (!numIn(v.fade, flo, fhi)) errors.push(`${where}.dip.fade must be from ${flo} to ${fhi} seconds`); + if (!numIn(v.black, blo, bhi)) errors.push(`${where}.dip.black must be from ${blo} to ${bhi} seconds`); + return errors; +} + +/** An entry's dip, `{ fade, black }`, or null: only a teaser's, and only a sound one. */ +export function dipOf(entry) { + if (entry?.type !== "teaser" || !isObj(entry.dip)) return null; + const { fade, black } = entry.dip; + if (!numIn(fade, ...DIP_LIMITS.fade) || !numIn(black, ...DIP_LIMITS.black)) return null; + return { fade, black }; +} + /** - * A teaser's length in seconds: its `seconds` when it sets one, else what its - * beats need (`teaserTimes(...).need`: the last pop, the tail's fade, the end - * fade's still room), rounded UP to a tenth of a second and at least the - * shortest a teaser may be. Assumes `validateTeaser` passed. + * The dark start of a teaser that dips, in its own clock: the dissolve into it + * (`D`, the cut's transition) plus the dip's black. Its page is black and its + * sound silent (but for the riser) until then; 0 without a dip. */ -export function teaserSeconds(entry) { +export function teaserLead(entry, D = 0.5) { + const dip = dipOf(entry); + return dip ? Math.round((D + dip.black) * 10000) / 10000 : 0; +} + +/** + * The cut second the deck and the feed are gone at, over a teaser that dips: + * the previous segment's last frame (the dissolve's end less a frame), which + * the dip has made black. `seg` is the teaser's schedule segment. + */ +export function dipHideAt(seg, D, fps = 30) { + return Math.round((seg.start + D - 1 / fps) * 10000) / 10000; +} + +/** + * The motion of an entry's teaser in its SEGMENT's clock: `teaserMotion` of its + * beat, and with a dip the first line's start moved to `lead + before − hit` + * -- after the black, timed to the rise (DIP_RISE) instead of the incoming + * dissolve. Without a dip it is `teaserMotion(beat)` itself. + */ +export function teaserMotionOf(entry, D = 0.5) { + return dippedMotion(entry, teaserLead(entry, D)); +} + +/** `teaserMotion(beat)`, its first line moved to `lead + before − hit` when the entry dips. */ +function dippedMotion(entry, lead) { + const m = teaserMotion(entry?.beat); + if (!dipOf(entry)) return m; + return Object.freeze({ ...m, first: Math.round((lead + DIP_RISE.before - m.hit) * 10000) / 10000 }); +} + +/** + * The card's length -- from where the light comes up, after any dip's black: + * its `seconds` when it sets one, else what its beats need (`teaserTimes(...).need`: + * the last pop, the tail's fade, the end fade's still room), rounded UP to a + * tenth of a second and at least the shortest a teaser may be. The same + * whatever the transition. Assumes `validateTeaser` passed. + */ +export function teaserCardSeconds(entry) { if (entry?.seconds !== undefined && entry?.seconds !== null) return Number(entry.seconds); - const need = teaserTimes(teaserLines(entry), teaserTail(entry), teaserMotion(entry?.beat)).need; + const need = teaserTimes(teaserLines(entry), teaserTail(entry), dippedMotion(entry, 0)).need; return Math.max(TEASER_LIMITS.seconds[0], Math.ceil(need * 10 - 1e-6) / 10); } /** + * A teaser's length in seconds: its lead (`teaserLead`: the dissolve and a + * dip's black, 0 without a dip) and its card (`teaserCardSeconds`). `D` is + * the cut's transition, read only for a dip. Without a dip it is + * `teaserCardSeconds`, as it always was. Assumes `validateTeaser` passed. + */ +export function teaserSeconds(entry, D = 0.5) { + const lead = teaserLead(entry, D); + return lead ? Math.round((lead + teaserCardSeconds(entry)) * 10000) / 10000 : teaserCardSeconds(entry); +} + +/** * The teaser's sound design, as data: one trailer hit under each pop, at the * moment the composition says it lands, and a low swell under the tail's * slow entrance. Empty when `hits: false`. @@ -1277,15 +1398,26 @@ export function teaserSeconds(entry) { * overline's and a kicker's a little smaller, a second tier's lighter and * shorter. The build turns this into one ffmpeg graph (`teaserAudioGraph`). * - * @returns {Array<{ kind: "hit"|"swell", at: number, role: string, gain: number, + * A teaser that dips (`D` is the cut's transition, read only then) has every + * time in its segment's clock, after the lead (`teaserMotionOf`), and a riser + * first: DIP_RISER's sub and noise swelling up through the black for at most + * `seconds`, ending on the first line's impact. + * + * @returns {Array<{ kind: "hit"|"swell"|"riser", at: number, role: string, gain: number, * decay: number, f0: number, f1: number, dur?: number }>} */ -export function teaserHits(entry) { +export function teaserHits(entry, D = 0.5) { if (entry?.hits === false) return []; const lines = teaserLines(entry); const tail = teaserTail(entry); - const times = teaserTimes(lines, tail, teaserMotion(entry.beat)); + const times = teaserTimes(lines, tail, teaserMotionOf(entry, D)); const out = []; + if (dipOf(entry) && times.lines.length) { + const end = times.lines[0].impact; + const at = Math.round(Math.max(0, end - DIP_RISER.seconds) * 10000) / 10000; + const { gain, f0, f1 } = DIP_RISER; + out.push({ kind: "riser", at, role: "dip", gain, decay: 0.04, f0, f1, dur: Math.round((end - at) * 10000) / 10000 }); + } const HIT = { title: { gain: 1, decay: 0.42, f0: 92, f1: 40 }, overline: { gain: 0.72, decay: 0.34, f0: 96, f1: 44 }, @@ -1348,6 +1480,7 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?" }); } if (entry?.hits !== undefined && typeof entry.hits !== "boolean") errors.push(`${where}.hits must be true or false`); + errors.push(...validateDip(entry, where)); if (entry?.tail !== undefined && entry?.tail !== null) { if (typeof entry.tail !== "string" || !entry.tail.trim()) errors.push(`${where}.tail must be a short string, or absent`); else if (/[\r\n]/.test(entry.tail)) errors.push(`${where}.tail must be one line`); @@ -1382,15 +1515,18 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?" // The length the beats need, once the lines, the tail and the beat are // sound: never squeezed, so a `seconds` short of it is refused with it, and // a card that needs more than a teaser may run says so. + // With a dip, `seconds` and the need are the card's, from where the light + // comes up -- the lead before it is the dip's, not the lines'. if (!errors.length) { - const m = teaserMotion(entry.beat); + const m = dippedMotion(entry, 0); const need = teaserTimes(teaserLines(entry), teaserTail(entry), m).need; - const least = teaserSeconds({ ...entry, seconds: undefined }); + const least = teaserCardSeconds({ ...entry, seconds: undefined }); const at = `at a beat of ${m.gap}s`; if (hasSeconds && entry.seconds < need - 1e-9) { errors.push( `${where}.seconds is ${entry.seconds}, and ${at} its lines need ${least}s ` + - `(the last pop, the tail's fade and ${TEASER_MOTION.endRoom}s still for the end fade) -- ` + + `(the last pop, the tail's fade and ${TEASER_MOTION.endRoom}s still for the end fade` + + `${dipOf(entry) ? ", counted from the end of the dip's black" : ""}) -- ` + `set it to ${least} or more, or leave it out for exactly that`, ); } else if (!hasSeconds && least > shi) { @@ -1404,7 +1540,13 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?" export function validateTeasers(manifest) { const errors = []; (manifest?.timeline ?? []).forEach((e, i) => { - if (e?.type === "teaser") errors.push(...validateTeaser(e, `timeline[${i}] (${e.id ?? "?"})`)); + if (e?.type !== "teaser") return; + const where = `timeline[${i}] (${e.id ?? "?"})`; + errors.push(...validateTeaser(e, where)); + // A dip fades the segment before the teaser: the first entry has none. + if (i === 0 && e.dip !== undefined && e.dip !== null) { + errors.push(`${where}.dip: a dip fades the entry before the teaser to black, and the first entry has none before it`); + } }); return errors; } diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs @@ -19,7 +19,7 @@ import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { postsRegions, selectVariant, variantPaths } from "./build-video.mjs"; -import { deckGeometry, deckOn, frameCount, postsGeometry, resolveDeck, teaserSeconds } from "./deck.mjs"; +import { deckGeometry, deckOn, frameCount, postsGeometry, resolveDeck, teaserSeconds, transitionOf } from "./deck.mjs"; const execFileP = promisify(execFile); const FFPROBE = process.env.FFPROBE_BIN ?? "ffprobe"; @@ -175,12 +175,16 @@ export async function verifyDeck(variantDir, render, file, problems) { */ export async function verifyTeasers(variantDir, manifest, problems) { const fps = Number(manifest.render?.fps ?? 30); + // A dip's lead counts the cut's transition: the one the build measured + // (its schedule, under the deck), else the manifest's. + const sched = await readFile(path.join(variantDir, "schedule.json"), "utf8").then(JSON.parse, () => null); + const D = Number.isFinite(sched?.transition) ? sched.transition : transitionOf(manifest.render); const out = []; for (const e of manifest.timeline ?? []) { if (e.type !== "teaser") continue; const dir = path.join(variantDir, "chrome", `teaser-${e.id}-frames`); const frames = await readdir(dir).then((fs) => fs.filter((f) => /^frame_\d+\.png$/.test(f)).length, () => 0); - const seconds = teaserSeconds(e); + const seconds = teaserSeconds(e, D); const want = frameCount(seconds, fps); const key = await readFile(path.join(dir, ".key"), "utf8").then((s) => s.trim(), () => null); const seg = path.join(variantDir, "segments", `${e.id}.mp4`);