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:
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`);