Archilyzer · Source

archilyzer

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

commit 0a53fb8f8941eb704f48b8301e2c723b51891c71
parent 6b8128338916d55e7b503e2c98f9bf181043cd11
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed,  7 Oct 2026 10:48:52 -0400

Merge feat/teaser-replace (teaser rows that change in place, per-line role and hold)

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

Diffstat:
Mumtool/report-to-video/README.md | 29+++++++++++++++++++++++++++--
Mumtool/report-to-video/chrome-teaser.mjs | 33++++++++++++++++++++++++++++++---
Mumtool/report-to-video/chrome-teaser.test.mjs | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mumtool/report-to-video/deck.mjs | 79++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
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;