commit b5d7d404da629e70e23036141b8e4487037c264b
parent 6b8128338916d55e7b503e2c98f9bf181043cd11
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 7 Oct 2026 10:48:52 -0400
report-to-video: teaser rows that say several things in turn (replace), a line's own role, hold
A teaser line with "replace": true takes the row of the line before it: it
slams in where that one was and pushes it out (rises, shrinks, blurs, fades
over 0.3 s from the new pop). The frame holds 5 ROWS of up to 8 lines; roles
follow the row's position, a replacing line inherits its row's role. A line's
"role" (overline/title/kicker) overrides its size, fit and hit; "hold" waits
0-4 s after a line before the next. Lines sharing a row are stacked in one
grid cell so the layout never moves. A teaser using none of it composes the
same page byte for byte (pinned), so no existing cut re-renders.
Pilot for the ferret finale: his estimates pushing each other out under the
full title, then 2027?.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 202 insertions(+), 21 deletions(-)
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -776,8 +776,8 @@ falls on the teaser.
"dip": { "fade": 1.2, "black": 0.6 } } // optional: go to black before it (below)
```
-- **`lines`** — 1 to 5, each one line of at most 80 characters: a string, or
- `{ text, break }`, where `break` is the END of `text` drawn as a smaller,
+- **`lines`** — 1 to 8 in at most 5 rows (below), each one line of at most 80 characters: a string, or
+ `{ text, break, replace?, role?, hold? }`, where `break` is the END of `text` drawn as a smaller,
wide-tracked second tier under the rest that pops 3/7 of a beat after it
(0.3 s at the default `beat`).
The words are data: they are drawn uppercase, and kept as written everywhere
@@ -785,6 +785,31 @@ falls on the teaser.
small wide-tracked overline between two accent rules, the last a mid-size
kicker (a date), and everything between a big heavy title; two lines are an
overline and a title; one is a title.
+- **Rows that say several things in turn: `replace`.** A line written
+ `{ "text": …, "replace": true }` takes the ROW of the line before it: it
+ slams in where that one was, and the one it replaces rises 64 px, shrinks
+ to 0.9×, blurs and fades over 0.3 s from its pop (`TEASER_MOTION.out*`),
+ gone before the new line's impact. So one row can step through a sequence
+ — his estimates, each pushing out the last — and a last line with
+ `replace` can resolve it (`"2027"` pushing out the last estimate). The
+ frame holds at most **5 rows** (`TEASER_LIMITS.rows`) of at most **8 lines**;
+ roles follow the ROW's position as they followed the line's, and a
+ replacing line takes the role of the line it replaces. The lines of a row
+ are stacked in one grid cell (`.slot`), so the row is as tall as its
+ tallest line and the layout never moves as they change. The first line
+ cannot replace.
+- **A line's own size: `role`.** `"overline"`, `"title"` or `"kicker"` on a
+ line overrides the role its row's position gives it — its type, its fit
+ and its hit. A row of estimates at `"kicker"` keeps the title above them
+ the biggest words on the card; `"2027"` at `"title"` makes the answer
+ outweigh them. A kicker-sized row above a title gets 44 px of air.
+- **`hold`** — 0 to 4 seconds on a line: held after its pop (after its
+ second tier's) before the next line pops, so a line about to be pushed out
+ — or the last before the kicker — can be read. On the last line it holds
+ the tail back as well.
+- A teaser that uses none of the three composes the page it always did,
+ byte for byte (`chrome-teaser.test.mjs` pins it), so no existing cut
+ re-renders.
- **What fits the frame** (`TEASER_LIMITS.fit`): a row wider than 80 % of the
frame shrinks to its role's floor and no further, so each row is also held to
what fits at that floor — a **title** at most **34** characters, a
diff --git a/umtool/report-to-video/chrome-teaser.mjs b/umtool/report-to-video/chrome-teaser.mjs
@@ -164,6 +164,14 @@ export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION,
put(`l${i}.rules`, { scaleX: 0 });
add(`l${i}.rules`, hit - T(0.04), T(0.6), { scaleX: 1 }, "expo.out", `${why} rules`);
}
+ // Pushed out: the line after it takes its row (`replace`), and as that
+ // one slams in this one rises, shrinks a little, blurs and is gone.
+ if (b.outAt != null) {
+ put(`l${i}.o`, { y: 0 });
+ add(`l${i}.o`, b.outAt - T(0.02), T(m.out), { autoAlpha: 0, y: -m.outRise }, "power2.in", `${why} pushed out`);
+ add(`l${i}`, b.outAt - T(0.02), T(m.out), { scale: m.outScale }, "power2.in", `${why} pushed out (scale)`);
+ add(`l${i}.t`, b.outAt - T(0.02), T(m.out), { "--blur": `${m.outBlur}px` }, "power2.in", `${why} pushed out (blur)`);
+ }
if (l.sub && b.subAt != null) {
put(`l${i}.sub`, { autoAlpha: 0, y: 16, scale: 1.12 });
put(`l${i}.subt`, { "--blur": "10px" });
@@ -252,7 +260,7 @@ export function teaserHtml(entry, render, opts = {}) {
const maxW = Math.round(W * 0.8);
const ty = TEASER_TYPE;
- const lineHtml = lines
+ const lineHtmls = lines
.map((l, i) => {
const k = `l${i}`;
const isLast = i === lines.length - 1;
@@ -277,8 +285,18 @@ export function teaserHtml(entry, render, opts = {}) {
: "") +
`</div>`
);
- })
+ });
+ // A row that several lines take in turn (`replace`) stacks them in one
+ // cell, so the row is as tall as its tallest and each pushes the last out
+ // in place. A row of one line is the line itself, as it always was.
+ const rowsOf = [];
+ lines.forEach((l, i) => { (rowsOf[l.row] ??= []).push(i); });
+ const lineHtml = rowsOf
+ .map((members) => members.length === 1
+ ? lineHtmls[members[0]]
+ : `<div class="slot r-${lines[members[0]].role}">\n ${members.map((i) => lineHtmls[i]).join("\n ")}\n </div>`)
.join("\n ");
+ const slotted = rowsOf.some((m) => m.length > 1);
const data = {
seconds,
@@ -339,7 +357,16 @@ export function teaserHtml(entry, render, opts = {}) {
.overline { margin-bottom: 38px; }
.title + .title { margin-top: 10px; }
.title .sub { margin-top: 14px; }
- .kicker { margin-top: 70px; }
+ .kicker { margin-top: 70px; }${slotted || lines.some((l, i) => l.role === "kicker" && i < lines.length - 1) ? `
+ /* Rows several lines take in turn: one grid cell, the lines stacked in
+ it; the row carries the margins its lines would have. */
+ .slot { position: relative; display: grid; justify-items: center; align-items: center; }
+ .slot > .line { grid-area: 1 / 1; margin: 0; }
+ .line.title + .slot.r-title, .slot.r-title + .line.title, .slot + .slot { margin-top: 10px; }
+ .slot.r-kicker { margin-top: 70px; }
+ .slot.r-overline { margin-bottom: 38px; }
+ /* A kicker-sized row above a title (a line's own role) keeps air between them. */
+ .kicker + .title, .slot.r-kicker + .title { margin-top: 44px; }` : ""}
/* An overline between two hairline rules in the accent. */
.rules { position: absolute; left: -132px; right: -132px; top: 50%; height: 2px; transform-origin: 50% 50%; }
.rule { position: absolute; top: 0; width: 96px; height: 2px; border-radius: 1px; }
diff --git a/umtool/report-to-video/chrome-teaser.test.mjs b/umtool/report-to-video/chrome-teaser.test.mjs
@@ -33,15 +33,22 @@ test("a valid teaser has nothing to say; every bad shape is a sentence", () => {
assert.deepEqual(validateTeaser(FERRET), []);
assert.deepEqual(validateTeaser({ ...FERRET, tail: undefined, hits: false }), []);
const bad = (patch) => validateTeaser({ ...FERRET, ...patch }).join(" | ");
- assert.match(bad({ lines: [] }), /lines must be a list of 1 to 5/);
- assert.match(bad({ lines: ["a", "b", "c", "d", "e", "f"] }), /1 to 5/);
+ assert.match(bad({ lines: [] }), /lines must be a list of 1 to 8/);
+ assert.match(bad({ lines: ["a", "b", "c", "d", "e", "f", "g", "h", "i"] }), /1 to 8/);
+ // Six ROWS do not fit; six lines in five rows do.
+ assert.match(bad({ lines: ["a", "b", "c", "d", "e", "f"], seconds: undefined }), /make 6 rows, and at most 5 fit/);
+ assert.deepEqual(validateTeaser({ ...FERRET, seconds: undefined, lines: ["a", "b", "c", { text: "c2", replace: true }, "d", "e"] }), []);
assert.match(bad({ lines: ["ok", ""] }), /lines\[1\] must be words/);
assert.match(bad({ lines: ["two\nlines"] }), /one line/);
assert.match(bad({ lines: ["x".repeat(81)] }), /81 characters/);
assert.match(bad({ lines: [{ text: "Abc", brk: "c" }] }), /lines\[0\]\.brk is not a teaser line field/);
assert.match(bad({ lines: [{ text: "The big one", break: "small" }] }), /must be the end of its text/);
assert.match(bad({ lines: [{ text: "whole", break: "whole" }] }), /leaves nothing for the first tier/);
- assert.match(bad({ lines: [42] }), /string or \{ text, break \}/);
+ assert.match(bad({ lines: [42] }), /string or \{ text, break, replace, role, hold \}/);
+ assert.match(bad({ lines: [{ text: "First", replace: true }] }), /lines\[0\]\.replace: the first line has no line before it/);
+ assert.match(bad({ lines: ["a", { text: "b", replace: "yes" }] }), /lines\[1\]\.replace must be true or false/);
+ assert.match(bad({ lines: ["a", { text: "b", role: "huge" }] }), /lines\[1\]\.role must be one of overline, title, kicker/);
+ assert.match(bad({ lines: ["a", { text: "b", hold: 5 }] }), /lines\[1\]\.hold must be from 0 to 4 seconds/);
assert.match(bad({ seconds: 2 }), /seconds must be from 3 to 20/);
assert.match(bad({ seconds: "7" }), /seconds must be from 3 to 20, or absent/);
assert.match(bad({ beat: 0.3 }), /beat must be from 0\.4 to 2\.5 seconds/);
@@ -400,3 +407,72 @@ test("tailWait: the tail enters that long after the last hit; the swell and the
assert.match(teaserHtml(FERRET, RENDER), /<span class="tail-hang"><span class="tail" data-k="tail">/);
assert.match(teaserHtml(FERRET, RENDER), /\.tail-hang \{ display: inline-block; width: 0; white-space: nowrap; \}/);
});
+
+// ---- rows several lines take in turn (`replace`), a line's own `role`, `hold` ----
+
+const ESTIMATES = Object.freeze({
+ type: "teaser", id: "fin", beat: 1,
+ lines: [
+ "Pirate Software",
+ { text: "The Largest Ferret Rescue in the United States", break: "in the United States" },
+ { text: "“Months” March 2024", break: "March 2024" },
+ { text: "“This year” January 2025", break: "January 2025", replace: true },
+ { text: "“Within the next six months” August 2026", break: "August 2026", replace: true, hold: 0.8 },
+ "2027",
+ ],
+ tail: "?",
+});
+
+test("replace: a line takes the row before it; roles follow rows; a line's own role wins", () => {
+ assert.deepEqual(validateTeaser(ESTIMATES), []);
+ const lines = teaserLines(ESTIMATES);
+ assert.deepEqual(lines.map((l) => l.row), [0, 1, 2, 2, 2, 3]);
+ // Four rows: overline, two titles, the kicker -- the estimates inherit their row's.
+ assert.deepEqual(lines.map((l) => l.role), ["overline", "title", "title", "title", "title", "kicker"]);
+ assert.deepEqual(lines.map((l) => l.replace ?? false), [false, false, false, true, true, false]);
+ // A replacing line keeps a role the line it replaces was given.
+ const small = { ...ESTIMATES, lines: ESTIMATES.lines.map((l, i) => (i === 2 ? { ...l, role: "kicker" } : l)) };
+ assert.deepEqual(teaserLines(small).map((l) => l.role), ["overline", "title", "kicker", "kicker", "kicker", "kicker"]);
+ // An entry that uses none of it normalises exactly as before: no new keys.
+ for (const l of teaserLines(FERRET)) assert.deepEqual(Object.keys(l).sort(), ["head", "role", "row", "sub", "text"]);
+});
+
+test("replace: the pushed line leaves as the next pops; hold waits before the next line", () => {
+ const lines = teaserLines(ESTIMATES);
+ const m = teaserMotion(1);
+ const { beats, cues, init } = teaserCues({ lines, tail: "?", seconds: 14, motion: m });
+ const b = beats.lines;
+ // Only a replaced line has an outAt, at its replacer's pop.
+ assert.deepEqual(b.map((x) => x.outAt ?? null), [null, null, b[3].at, b[4].at, null, null]);
+ // The hold: the kicker pops a beat AND the hold after the last estimate's second tier.
+ assert.equal(Math.round((b[5].at - b[4].subAt) * 1e4) / 1e4, Math.round((m.gap + 0.8) * 1e4) / 1e4);
+ // Pushed out: gone (autoAlpha 0) and risen, by the replacer's impact.
+ for (const i of [2, 3]) {
+ const out = cues.find((c) => c.why === `line ${i} pushed out`);
+ assert.deepEqual(out.to, { autoAlpha: 0, y: -m.outRise });
+ assert.ok(out.at + out.dur <= b[i + 1].impact + 0.12, `line ${i} is gone as the next lands`);
+ }
+ // Seek-safe and every from stated, as for any teaser.
+ const state = JSON.parse(JSON.stringify(init));
+ for (const c of cues) {
+ for (const [p, v] of Object.entries(c.from)) assert.deepEqual(v, state[c.k][p], `${c.k}.${p} at ${c.at}`);
+ Object.assign(state[c.k], c.to);
+ }
+ // The end: the replaced lines gone, the rest shown.
+ assert.deepEqual([0, 1, 2, 3, 4, 5].map((i) => state[`l${i}.o`].autoAlpha), [1, 1, 0, 0, 1, 1]);
+});
+
+test("replace: one stacked row in the page, a hit per line, the tail on the last", () => {
+ const html = teaserHtml(ESTIMATES, RENDER);
+ assert.equal((html.match(/<div class="slot r-title">/g) ?? []).length, 1);
+ // The three estimates are in that row, in order.
+ const slot = html.slice(html.indexOf('<div class="slot'), html.indexOf('data-line="5"'));
+ assert.ok(slot.indexOf('data-line="2"') < slot.indexOf('data-line="3"') && slot.indexOf('data-line="3"') < slot.indexOf('data-line="4"'));
+ assert.ok(html.indexOf('data-line="5"') < html.indexOf('data-k="tail"'));
+ assert.match(html, /\.slot > \.line \{ grid-area: 1 \/ 1; margin: 0; \}/);
+ // A teaser without a stacked row carries none of the slot's style.
+ assert.ok(!teaserHtml(FERRET, RENDER).includes(".slot"));
+ // Every line sounds its own hit, the estimates as titles.
+ const hits = teaserHits(ESTIMATES).filter((h) => h.kind === "hit" && h.role !== "sub");
+ assert.deepEqual(hits.map((h) => h.role), ["overline", "title", "title", "title", "title", "kicker"]);
+});
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -1394,32 +1394,56 @@ export function muteSegmentSeconds({ entry, record = null, render = {}, seconds
* spill at these counts.
*/
export const TEASER_LIMITS = Object.freeze({
- lines: [1, 5], seconds: [3, 20], chars: 80, tail: 8,
+ lines: [1, 8], rows: 5, seconds: [3, 20], chars: 80, tail: 8, hold: 4,
fit: Object.freeze({ overline: 64, title: 34, kicker: 56, sub: 66 }),
});
-const LINE_KEYS = ["text", "break"];
+const LINE_KEYS = ["text", "break", "replace", "role", "hold"];
+
+/** The roles a line may be drawn in; a line's `role` names one of them. */
+export const TEASER_ROLES = Object.freeze(["overline", "title", "kicker"]);
/**
- * A teaser's lines, normalised: `{ text, head, sub, role }` each, `head` the
- * part drawn on the first tier and `sub` the second tier (`break`) or null.
+ * A teaser's lines, normalised: `{ text, head, sub, role, row }` each, `head`
+ * the part drawn on the first tier and `sub` the second tier (`break`) or null.
* Trims; assumes `validateTeaser` passed.
*
- * @returns {Array<{ text: string, head: string, sub: string|null, role: "overline"|"title"|"kicker" }>}
+ * ROWS, NOT LINES, HAVE POSITIONS. A line with `replace: true` takes the row
+ * of the line before it -- it slams in where that one was and pushes it out
+ * (`teaserTimes` gives the pushed line its `outAt`) -- so a row can say
+ * several things in turn: an estimate, then the next one. `row` is the line's
+ * row, and roles follow the ROW's position as they always followed the line's
+ * (with three or more rows the first is the overline, the last the kicker,
+ * the rest titles). A line's own `role` overrides that; a replacing line
+ * without one takes the role of the line it replaces. `replace` and `hold`
+ * (seconds held after the line before the next one pops) are present only
+ * when set, so an entry that uses neither normalises exactly as it did.
+ *
+ * @returns {Array<{ text: string, head: string, sub: string|null, role: "overline"|"title"|"kicker",
+ * row: number, replace?: true, hold?: number }>}
*/
export function teaserLines(entry) {
const lines = Array.isArray(entry?.lines) ? entry.lines : [];
- const n = lines.length;
- return lines.map((l, i) => {
+ let row = -1;
+ const rowOf = lines.map((l, i) => (i > 0 && isObj(l) && l.replace === true ? row : (row += 1)));
+ const n = row + 1;
+ const out = [];
+ lines.forEach((l, i) => {
const text = String(isObj(l) ? l.text ?? "" : l ?? "").trim();
const brk = isObj(l) && typeof l.break === "string" ? l.break.trim() : "";
const sub = brk && text.endsWith(brk) && text.length > brk.length ? brk : null;
const head = sub ? text.slice(0, text.length - sub.length).trim() : text;
- const role = n >= 3 ? (i === 0 ? "overline" : i === n - 1 ? "kicker" : "title")
- : n === 2 ? (i === 0 ? "overline" : "title")
+ const r = rowOf[i];
+ const replace = i > 0 && isObj(l) && l.replace === true;
+ const byRow = n >= 3 ? (r === 0 ? "overline" : r === n - 1 ? "kicker" : "title")
+ : n === 2 ? (r === 0 ? "overline" : "title")
: "title";
- return { text, head, sub, role };
+ const own = isObj(l) && TEASER_ROLES.includes(l.role) ? l.role : null;
+ const role = own ?? (replace ? out[i - 1].role : byRow);
+ const hold = isObj(l) && typeof l.hold === "number" && l.hold > 0 ? l.hold : null;
+ out.push({ text, head, sub, role, row: r, ...(replace ? { replace: true } : {}), ...(hold ? { hold } : {}) });
});
+ return out;
}
/** The teaser's tail, trimmed, or "" for none. */
@@ -1451,6 +1475,10 @@ export function teaserTitle(entry) {
export const TEASER_MOTION = Object.freeze({
first: 0.55, gap: 0.7, sub: 0.3, slam: 1.42, under: 0.968, hit: 0.2, settle: 0.5,
blur: 18, tailAfter: 0.8, tailDur: 1.7, endRoom: 1.2, push: 1.065, grainHz: 12,
+ // A line pushed out of its row by the next (`replace`): over `out` seconds
+ // from that one's pop it rises `outRise` px, shrinks to `outScale` and blurs
+ // to `outBlur` px as it fades -- gone before the new line's slam lands.
+ out: 0.3, outRise: 64, outScale: 0.9, outBlur: 12,
});
/** The beats an entry's `beat` may be: from 0.4 s (packed) to 2.5 s (a pause between each). */
@@ -1514,18 +1542,25 @@ export function teaserTimes(lines, tail, m = TEASER_MOTION) {
let t = m.first;
const raw = [];
lines.forEach((l, i) => {
- if (i > 0) t += m.gap;
+ if (i > 0) t += m.gap + (lines[i - 1].hold ?? 0);
const at = t;
const subAt = l.sub ? at + m.sub : null;
if (subAt != null) t = subAt;
raw.push({ at, subAt });
});
+ // A line held after its pop holds the tail back too.
+ t += lines[lines.length - 1]?.hold ?? 0;
const tailRaw = tail ? t + m.tailAfter : null;
const endRaw = tailRaw != null ? tailRaw + m.tailDur : t + m.hit + m.settle;
const r = (v) => Math.round(v * 10000) / 10000;
const T = r;
return {
- lines: raw.map((b) => ({ at: T(b.at), impact: r(T(b.at) + T(m.hit)), subAt: b.subAt == null ? null : T(b.subAt) })),
+ // `outAt`: a line the next one replaces is pushed out as that one pops.
+ // Only on such a line, so a teaser without `replace` times as it did.
+ lines: raw.map((b, i) => ({
+ at: T(b.at), impact: r(T(b.at) + T(m.hit)), subAt: b.subAt == null ? null : T(b.subAt),
+ ...(lines[i + 1]?.replace ? { outAt: T(raw[i + 1].at) } : {}),
+ })),
tailAt: tailRaw == null ? null : T(tailRaw),
tailDur: T(m.tailDur),
end: T(endRaw),
@@ -1763,11 +1798,29 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?"
if (!Array.isArray(lines) || lines.length < lo || lines.length > hi) {
errors.push(`${where}.lines must be a list of ${lo} to ${hi} lines`);
} else {
+ // What the frame holds is ROWS: a replacing line shares the row before it.
+ const rows = lines.filter((l, i) => !(i > 0 && isObj(l) && l.replace === true)).length;
+ if (rows > TEASER_LIMITS.rows) {
+ errors.push(
+ `${where}.lines make ${rows} rows, and at most ${TEASER_LIMITS.rows} fit the frame ` +
+ `-- set "replace": true on a line to have it take the row of the line before it`,
+ );
+ }
lines.forEach((l, i) => {
const w = `${where}.lines[${i}]`;
if (typeof l === "string") { oneLine(l, w); return; }
- if (!isObj(l)) { errors.push(`${w} must be a string or { text, break }`); return; }
+ if (!isObj(l)) { errors.push(`${w} must be a string or { text, break, replace, role, hold }`); return; }
for (const k of Object.keys(l)) if (!LINE_KEYS.includes(k)) errors.push(`${w}.${k} is not a teaser line field`);
+ if (l.replace !== undefined && l.replace !== null) {
+ if (typeof l.replace !== "boolean") errors.push(`${w}.replace must be true or false`);
+ else if (l.replace && i === 0) errors.push(`${w}.replace: the first line has no line before it to replace`);
+ }
+ if (l.role !== undefined && l.role !== null && !TEASER_ROLES.includes(l.role)) {
+ errors.push(`${w}.role must be one of ${TEASER_ROLES.join(", ")}`);
+ }
+ if (l.hold !== undefined && l.hold !== null && !numIn(l.hold, 0, TEASER_LIMITS.hold)) {
+ errors.push(`${w}.hold must be from 0 to ${TEASER_LIMITS.hold} seconds`);
+ }
if (!oneLine(l.text, `${w}.text`)) return;
if (l.break === undefined || l.break === null) return;
if (!oneLine(l.break, `${w}.break`)) return;