Archilyzer · Source

archilyzer

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

commit a2012e191bf2737d0a3bd543279fc591ac029b6f
parent 0a53fb8f8941eb704f48b8301e2c723b51891c71
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed,  7 Oct 2026 11:01:27 -0400

Merge feat/teaser-together (a teaser line's second tier pops with it, on one hit)

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

Diffstat:
Mumtool/report-to-video/README.md | 8++++++--
Mumtool/report-to-video/chrome-teaser.test.mjs | 25++++++++++++++++++++++++-
Mumtool/report-to-video/deck.mjs | 35++++++++++++++++++++++++++---------
3 files changed, 56 insertions(+), 12 deletions(-)

diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -777,7 +777,7 @@ falls on the teaser. ``` - **`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, + `{ text, break, replace?, role?, hold?, together? }`, 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 @@ -807,7 +807,11 @@ falls on the teaser. 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, +- **`together`** — `true` on a line with a `break`: its second tier pops + WITH it, on the line's one hit, instead of 3/7 of a beat later on a + lighter hit of its own. For a row that steps through quote-and-date pairs, + so each step is one hit, not two. +- A teaser that uses none of these 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 diff --git a/umtool/report-to-video/chrome-teaser.test.mjs b/umtool/report-to-video/chrome-teaser.test.mjs @@ -44,7 +44,9 @@ test("a valid teaser has nothing to say; every bad shape is a sentence", () => { 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, replace, role, hold \}/); + assert.match(bad({ lines: [42] }), /string or \{ text, break, replace, role, hold, together \}/); + assert.match(bad({ lines: ["a", { text: "b", together: true }] }), /lines\[1\]\.together pops the second tier with its line, and it has no break/); + assert.match(bad({ lines: ["a", { text: "b c", break: "c", together: 1 }] }), /lines\[1\]\.together must be true or false/); 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/); @@ -476,3 +478,24 @@ test("replace: one stacked row in the page, a hit per line, the tail on the last 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"]); }); + +test("together: a line's second tier pops with it, on its one hit", () => { + const together = { + ...ESTIMATES, + lines: ESTIMATES.lines.map((l, i) => (i >= 2 && i <= 4 ? { ...l, together: true } : l)), + }; + assert.deepEqual(validateTeaser(together), []); + const lines = teaserLines(together); + assert.deepEqual(lines.map((l) => l.together ?? false), [false, false, true, true, true, false]); + const m = teaserMotion(1); + const plain = teaserCues({ lines: teaserLines(ESTIMATES), tail: "?", seconds: 14, motion: m }).beats.lines; + const { beats } = teaserCues({ lines, tail: "?", seconds: 14, motion: m }); + // The date pops at its line's pop, and the next line counts from there. + for (const i of [2, 3, 4]) assert.equal(beats.lines[i].subAt, beats.lines[i].at); + assert.equal(Math.round((beats.lines[3].at - beats.lines[2].at) * 1e4) / 1e4, m.gap); + // The title keeps its two-step: its tier a beat-fraction after it, as without. + assert.equal(beats.lines[1].subAt, plain[1].subAt); + // One hit per estimate: no lighter second-tier hit under them; the title keeps its. + const subs = teaserHits(together).filter((h) => h.role === "sub").map((h) => h.at); + assert.deepEqual(subs, [beats.lines[1].subAt]); +}); diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -1398,7 +1398,7 @@ export const TEASER_LIMITS = Object.freeze({ fit: Object.freeze({ overline: 64, title: 34, kicker: 56, sub: 66 }), }); -const LINE_KEYS = ["text", "break", "replace", "role", "hold"]; +const LINE_KEYS = ["text", "break", "replace", "role", "hold", "together"]; /** 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"]); @@ -1417,10 +1417,12 @@ export const TEASER_ROLES = Object.freeze(["overline", "title", "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. + * when set, so an entry that uses neither normalises exactly as it did. So is + * `together`: a line with a `break` whose second tier pops WITH it, on its one + * hit, rather than a beat-fraction later on a lighter hit of its own. * * @returns {Array<{ text: string, head: string, sub: string|null, role: "overline"|"title"|"kicker", - * row: number, replace?: true, hold?: number }>} + * row: number, replace?: true, hold?: number, together?: true }>} */ export function teaserLines(entry) { const lines = Array.isArray(entry?.lines) ? entry.lines : []; @@ -1441,7 +1443,11 @@ export function teaserLines(entry) { 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 } : {}) }); + const together = !!sub && isObj(l) && l.together === true; + out.push({ + text, head, sub, role, row: r, + ...(replace ? { replace: true } : {}), ...(hold ? { hold } : {}), ...(together ? { together: true } : {}), + }); }); return out; } @@ -1499,7 +1505,8 @@ export function teaserTailWait(entry) { if (entry?.tailWait !== undefined && entry?.tailWait !== null) return Number(entry.tailWait); const m = teaserMotion(entry?.beat); const lines = teaserLines(entry); - const lastSub = !!lines[lines.length - 1]?.sub; + const last = lines[lines.length - 1]; + const lastSub = !!last?.sub && !last.together; return Math.round((m.tailAfter - (lastSub ? 0 : m.hit)) * 10000) / 10000; } @@ -1544,7 +1551,8 @@ export function teaserTimes(lines, tail, m = TEASER_MOTION) { lines.forEach((l, i) => { if (i > 0) t += m.gap + (lines[i - 1].hold ?? 0); const at = t; - const subAt = l.sub ? at + m.sub : null; + // `together`: the second tier pops with its line (one pop, one hit). + const subAt = l.sub ? at + (l.together ? 0 : m.sub) : null; if (subAt != null) t = subAt; raw.push({ at, subAt }); }); @@ -1684,7 +1692,8 @@ function dippedMotion(entry, lead) { const r = (v) => Math.round(v * 10000) / 10000; if (entry?.tailWait !== undefined && entry?.tailWait !== null && teaserTail(entry)) { const lines = teaserLines(entry); - const lastSub = !!lines[lines.length - 1]?.sub; + const last = lines[lines.length - 1]; + const lastSub = !!last?.sub && !last.together; m = Object.freeze({ ...m, tailAfter: r(Number(entry.tailWait) + (lastSub ? 0 : m.hit)) }); } if (!dipOf(entry)) return m; @@ -1752,7 +1761,9 @@ export function teaserHits(entry, D = 0.5, fps = 30) { }; lines.forEach((l, i) => { out.push({ kind: "hit", at: times.lines[i].impact, role: l.role, ...HIT[l.role] }); - if (l.sub && times.lines[i].subAt != null) out.push({ kind: "hit", at: times.lines[i].subAt, role: "sub", ...HIT.sub }); + if (l.sub && !l.together && times.lines[i].subAt != null) { + out.push({ kind: "hit", at: times.lines[i].subAt, role: "sub", ...HIT.sub }); + } }); if (tail && times.tailAt != null) { out.push({ kind: "swell", at: times.tailAt, role: "tail", gain: 0.34, decay: 0.7, f0: 46, f1: 62, dur: times.tailDur }); @@ -1809,7 +1820,7 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?" 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, replace, role, hold }`); return; } + if (!isObj(l)) { errors.push(`${w} must be a string or { text, break, replace, role, hold, together }`); 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`); @@ -1818,6 +1829,12 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?" 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.together !== undefined && l.together !== null) { + if (typeof l.together !== "boolean") errors.push(`${w}.together must be true or false`); + else if (l.together && !(typeof l.break === "string" && l.break.trim())) { + errors.push(`${w}.together pops the second tier with its line, and it has no break`); + } + } 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`); }