Archilyzer · Source

archilyzer

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

commit 480e7db7cab03b022cc6de4d3be19b3b021b1634
parent f123ee6bf23ca62b92460129e766e6f2095aff7d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  2 Oct 2026 11:03:22 -0400

report-to-video: the teaser's tail hangs off its row -- the last line is centred by its own words, the tail in a zero-width box at its gap, and both the validator and the page's fit count the tail on both sides of the centre (the ferret page re-pinned); `tailWait` (0.3-6 s, only with a tail) sets the seconds from the last hit to the tail, the swell and the card's length following it, and without it the beat's wait stands

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

Diffstat:
Mumtool/report-to-video/chrome-teaser.mjs | 19+++++++++++++++----
Mumtool/report-to-video/chrome-teaser.test.mjs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mumtool/report-to-video/deck.mjs | 56+++++++++++++++++++++++++++++++++++++++++++++++++-------
3 files changed, 116 insertions(+), 19 deletions(-)

diff --git a/umtool/report-to-video/chrome-teaser.mjs b/umtool/report-to-video/chrome-teaser.mjs @@ -259,9 +259,11 @@ export function teaserHtml(entry, render, opts = {}) { const rules = l.role === "overline" ? `<div class="rules" data-k="${k}.rules"><span class="rule l"></span><span class="rule r"></span></div>` : ""; + // The tail hangs off the right of its row in a zero-width box, so the + // row is centred by its own words and the tail adds nothing to it. const tailHtml = isLast && tail - ? `<span class="tail" data-k="tail"><span class="tail-glow" data-k="tail.glow"></span>` + - `<span class="tail-t" data-k="tail.t">${esc(tail)}</span></span>` + ? `<span class="tail-hang"><span class="tail" data-k="tail"><span class="tail-glow" data-k="tail.glow"></span>` + + `<span class="tail-t" data-k="tail.t">${esc(tail)}</span></span></span>` : ""; return ( `<div class="line ${l.role}" data-line="${i}" data-role="${l.role}" data-k="${k}.o">` + @@ -350,7 +352,10 @@ export function teaserHtml(entry, render, opts = {}) { .streak { position: absolute; left: -24%; right: -24%; top: 52%; height: 3px; margin-top: -1px; background: linear-gradient(90deg, ${rgba(pal.accent, 0)} 0%, ${rgba(pal.accent, 0.9)} 30%, ${rgba(pal.fg, 0.95)} 50%, ${rgba(pal.accent, 0.9)} 70%, ${rgba(pal.accent, 0)} 100%); box-shadow: 0 0 18px 3px ${rgba(pal.accent, 0.55)}; transform-origin: 50% 50%; mix-blend-mode: screen; } - /* The tail: apart from the words, in the accent, arriving on its own. */ + /* The tail: apart from the words, in the accent, arriving on its own -- + hung in a zero-width box on the row's baseline, so the words are + centred alone and the tail sits off their right at its gap. */ + .tail-hang { display: inline-block; width: 0; white-space: nowrap; } .tail { position: relative; display: inline-block; margin-left: 0.32em; transform-origin: 30% 60%; } .tail-t { font-weight: 900; font-stretch: 112%; letter-spacing: 0; color: ${pal.accent}; font-size: 1.18em; line-height: 1; } .tail-glow { position: absolute; left: -140%; right: -140%; top: -90%; bottom: -90%; @@ -415,7 +420,13 @@ export function teaserHtml(entry, render, opts = {}) { const role = row.parentElement.classList.contains("sub") ? "sub" : row.closest(".line").dataset.role; let s = parseFloat(getComputedStyle(row).fontSize); const floor = D.floors[role] || 12; - while (s > floor && row.scrollWidth > D.maxW + 0.5) { + // A row carrying the tail is centred by its words: it needs the + // tail and its gap on both sides of the centre. + const hang = row.querySelector(".tail"); + const need = () => hang + ? row.firstElementChild.offsetWidth + 2 * (hang.offsetWidth + parseFloat(getComputedStyle(hang).marginLeft)) + : row.scrollWidth; + while (s > floor && need() > D.maxW + 0.5) { s -= 1; row.style.fontSize = s + "px"; } diff --git a/umtool/report-to-video/chrome-teaser.test.mjs b/umtool/report-to-video/chrome-teaser.test.mjs @@ -12,7 +12,7 @@ import { createHash } from "node:crypto"; import { CARD_TYPES, deckChoreography, deckSchedule, deckText, estimatedDuration, hidesDeck, resolveDeck, TEASER_BEAT, - TEASER_MOTION, teaserHits, teaserLines, teaserMotion, teaserSeconds, teaserTail, teaserTimes, teaserTitle, + TEASER_MOTION, teaserHits, teaserLines, teaserMotion, teaserMotionOf, teaserSeconds, teaserTail, teaserTimes, teaserTitle, validateTeaser, validateTeasers, } from "./deck.mjs"; import { teaserCues, teaserHtml } from "./chrome-teaser.mjs"; @@ -53,23 +53,34 @@ test("a valid teaser has nothing to say; every bad shape is a sentence", () => { assert.match(bad({ tail: "" }), /tail must be a short string/); assert.match(bad({ tail: "?????????" }), /tail is 9 characters/); assert.match(bad({ hits: "yes" }), /hits must be true or false/); + // tailWait: seconds from the last hit to the tail, only with a tail. + assert.deepEqual(validateTeaser({ ...FERRET, seconds: undefined, tailWait: 2 }), []); + assert.match(bad({ tailWait: 2 }), /seconds is 7, and at a beat of 0\.7s its lines need 7\.4s/); + assert.match(bad({ tailWait: 0.2 }), /tailWait must be from 0\.3 to 6 seconds, or absent/); + assert.match(bad({ tailWait: 6.5 }), /tailWait must be from 0\.3 to 6/); + assert.match(bad({ tailWait: "2" }), /tailWait must be/); + assert.match(bad({ tailWait: 2, tail: undefined }), /tailWait is the wait before the tail, and there is no tail/); // What fits the frame at each role's floor (TEASER_LIMITS.fit): an // 80-character title spilled past both edges. const T80 = "The Largest Ferret Rescue Operation Ever Attempted Anywhere in the United States"; assert.match(bad({ lines: ["Pirate Software", T80, "February 2027"] }), /lines\[1\] is 80 characters, and at most 34 fit the frame as the title -- shorten it, or set its end as a break/); assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "x".repeat(34), "February 2027"] }), []); - // The tail counts on the row that carries it: the last, or its second tier. - assert.match(bad({ lines: ["Pirate Software", "x".repeat(34)] }), /lines\[1\] is 36 characters with the tail/); + // The tail counts on the row that carries it -- the last, or its second + // tier -- TWICE: the row is centred by its own words and the tail hangs off + // the right, so it needs the tail and its gap on both sides of the centre. + assert.match(bad({ lines: ["Pirate Software", "x".repeat(34)] }), /lines\[1\] is 38 characters with the tail/); + assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "Title", "y".repeat(52)] }), []); + assert.match(bad({ lines: ["Pirate Software", "Title", "y".repeat(53)] }), /lines\[2\] is 57 characters with the tail, and at most 56 fit the frame as the kicker/); assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "x".repeat(34)], tail: undefined }), []); - assert.match(bad({ lines: ["Pirate Software", "Title", "y".repeat(56)] }), /lines\[2\] is 58 characters with the tail.*as the kicker/); + assert.match(bad({ lines: ["Pirate Software", "Title", "y".repeat(56)] }), /lines\[2\] is 60 characters with the tail.*as the kicker/); assert.match(bad({ lines: ["o".repeat(65), "Title", "Date"] }), /lines\[0\] is 65 characters.*as the overline/); // A break: the head in the line's role, the break in the second tier. assert.deepEqual(validateTeaser({ ...FERRET, lines: ["P", { text: T80, break: "Operation Ever Attempted Anywhere in the United States" }, "D"] }), []); assert.match(bad({ lines: ["P", { text: T80, break: "in the United States" }, "D"] }), /lines\[1\] before its break is 59 characters, and at most 34 fit the frame as the title$/m); assert.match(bad({ lines: ["P", { text: `A ${"s".repeat(66)}`, break: "s".repeat(66) }] }), - /lines\[1\]\.break is 68 characters with the tail, and at most 66 fit the frame as the second tier/); + /lines\[1\]\.break is 70 characters with the tail, and at most 66 fit the frame as the second tier/); assert.match(bad({ id: "../x" }), /id must be letters/); // The manifest's validator names where. const errs = validateTeasers({ timeline: [{ type: "clip", id: "c1" }, { ...FERRET, seconds: 1 }] }); @@ -235,13 +246,20 @@ test("the length: `seconds` when set, else what the beats need, up to a tenth an assert.ok(t.tailAt + t.tailDur <= 8.1 - TEASER_MOTION.endRoom + 1e-9); }); -test("a teaser without a beat composes the page it did before beats existed, byte for byte", () => { +test("a teaser without a beat or a tailWait composes one page, byte for byte", () => { const sha = (s) => createHash("sha256").update(s).digest("hex"); - // The ferret teaser's page at 07d1fa08, before `beat`: its render key and frames are these. - const BEFORE = "a683c6414b5cff9816608829b71d8b3e09f8755e86e3b9b0e7300c132a413810"; + // The ferret teaser's page. Re-pinned on purpose when the tail stopped + // counting in its row's centring (it hangs off the right in a zero-width + // box, and the fit measures the words plus the tail on both sides): the + // year now sits on the frame's centre. Before that, at 07d1fa08, it was + // a683c641…; `beat` and `tailWait` left at their defaults keep this one. + const BEFORE = "7560538c8eeee0b5f5ad34bd4d67a5aede07b4f312fc2307764cf70b2ca01e90"; assert.equal(sha(teaserHtml(FERRET, RENDER)), BEFORE); assert.equal(sha(teaserHtml({ ...FERRET, beat: TEASER_MOTION.gap }, RENDER)), BEFORE); assert.notEqual(sha(teaserHtml({ ...FERRET, beat: 0.9 }, RENDER)), BEFORE); + // A tailWait equal to the beat's own (1.0 s from the kicker's impact at 0.7) is the same page. + assert.equal(sha(teaserHtml({ ...FERRET, tailWait: 0.6 }, RENDER)), BEFORE); + assert.notEqual(sha(teaserHtml({ ...FERRET, tailWait: 2 }, RENDER)), BEFORE); // The page's data carries the times it always did, and nothing new. const json = JSON.parse(teaserHtml(FERRET, RENDER).match(/<script id="teaser-data" type="application\/json">([\s\S]*?)<\/script>/)[1]); assert.deepEqual(Object.keys(json.beats).sort(), ["end", "lines", "scale", "tailAt", "tailDur"]); @@ -356,3 +374,29 @@ test("the cache key: the composed page changes with the words, the segment key w rmSync(dir, { recursive: true, force: true }); } }); + +test("tailWait: the tail enters that long after the last hit; the swell and the card's length follow; without it, the beat's wait", async () => { + const { teaserTailWait } = await import("./deck.mjs"); + const free = { ...FERRET, seconds: undefined, beat: 1.05 }; + // The beat's own wait, from the kicker's impact: 1.2 − 0.2 at 1.05, 0.8 − 0.2 at the default. + assert.equal(teaserTailWait(free), 1); + assert.equal(teaserTailWait(FERRET), 0.6); + assert.equal(teaserTailWait({ ...FERRET, tail: undefined }), null); + // A last line with a break: its hit is the second tier's pop, so the wait is tailAfter itself. + assert.equal(teaserTailWait({ ...FERRET, lines: ["A", { text: "B c", break: "c" }] }), 0.8); + const t = teaserTimes(teaserLines(free), "?", teaserMotionOf({ ...free, tailWait: 2 })); + assert.equal(t.lines[2].impact, 3.3); + assert.equal(t.tailAt, 5.3); + assert.equal(t.end, 7); + assert.equal(teaserSeconds({ ...free, tailWait: 2 }), 8.2); + assert.equal(teaserSeconds(free), 7.2); + const swell = teaserHits({ ...free, tailWait: 2 }).find((h) => h.kind === "swell"); + assert.equal(swell.at, 5.3); + // From a second tier's pop when the last line has one. + const brk = { type: "teaser", id: "x", lines: ["A", { text: "B c", break: "c" }], tail: "?", tailWait: 1.5 }; + const tb = teaserTimes(teaserLines(brk), "?", teaserMotionOf(brk)); + assert.equal(tb.tailAt, tb.lines[1].subAt + 1.5); + // The page hangs the tail in a zero-width box. + 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; \}/); +}); diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -1205,8 +1205,9 @@ export function muteSegmentSeconds({ entry, record = null, render = {}, seconds // "tail": "?", "hits": true } // // A line is a string, or `{ text, break }`: `break` is the END of `text` set -// as a smaller second tier under the rest, a beat later. The tail is appended -// to the last line and fades in on its own. `hits` (default true) puts a +// as a smaller second tier under the rest, a beat later. The tail hangs off +// the right of the last line -- which is centred by its own words -- and +// fades in on its own, `tailWait` (optional) after the last hit. `hits` (default true) puts a // trailer hit under each pop and a swell under the tail; false is silence. // `beat` (optional) is the seconds from one line's pop to the next // (`teaserMotion`); `seconds` (optional) is the card's length, and without it @@ -1222,7 +1223,9 @@ export function muteSegmentSeconds({ entry, record = null, render = {}, seconds * The teaser's limits: lines, seconds, characters per line, the tail's length, * and `fit`: the characters one ROW may hold in its role -- the first tier * (`head`) in the line's role, a `break` in `sub`, the tail and its gap - * counted on the row that carries it. A row wider than 80 % of the frame + * counted TWICE on the row that carries it (the row is centred by its words + * and the tail hangs off the right, so it needs that room on both sides of + * the centre). A row wider than 80 % of the frame * shrinks to its role's floor (TEASER_TYPE in chrome-teaser.mjs) and no * further, so a longer row spilled past the frame's edges. Measured at the * floor in the face, on ordinary headline words in capitals: the title holds @@ -1293,6 +1296,25 @@ export const TEASER_MOTION = Object.freeze({ /** The beats an entry's `beat` may be: from 0.4 s (packed) to 2.5 s (a pause between each). */ export const TEASER_BEAT = Object.freeze({ min: 0.4, max: 2.5 }); +/** An entry's `tailWait` may be: seconds from the last hit to the tail's entrance. */ +export const TEASER_TAIL_WAIT = Object.freeze({ min: 0.3, max: 6 }); + +/** + * The seconds from a teaser's last hit -- its last line's impact, or that + * line's second tier's pop when it has one (where its hit sounds) -- to the + * tail's entrance: the entry's `tailWait`, else what the beat gives + * (`tailAfter` counted from the last pop, less the slam's `hit` when the last + * pop is a slam). Null without a tail. The ferret kicker at beat 1.05: 1.0 s. + */ +export function teaserTailWait(entry) { + if (!teaserTail(entry)) return null; + 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; + return Math.round((m.tailAfter - (lastSub ? 0 : m.hit)) * 10000) / 10000; +} + /** * The motion at an entry's beat: `gap` is the beat, and the two waits that * read as part of it scale with it in the default's proportion -- the second @@ -1456,11 +1478,22 @@ export function teaserMotionOf(entry, D = 0.5, fps = 30) { return dippedMotion(entry, teaserLead(entry, D, fps)); } -/** `teaserMotion(beat)`, its first line moved to `lead + before − hit` when the entry dips. */ +/** + * `teaserMotion(beat)`, its first line moved to `lead + before − hit` when the + * entry dips, and its `tailAfter` set from the entry's `tailWait` when it has + * one (`teaserTailWait`, counted from the last hit). Without either it is + * `teaserMotion(beat)` itself. + */ function dippedMotion(entry, lead) { - const m = teaserMotion(entry?.beat); + let m = teaserMotion(entry?.beat); + 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; + m = Object.freeze({ ...m, tailAfter: r(Number(entry.tailWait) + (lastSub ? 0 : m.hit)) }); + } if (!dipOf(entry)) return m; - return Object.freeze({ ...m, first: Math.round((lead + DIP_RISE.before - m.hit) * 10000) / 10000 }); + return Object.freeze({ ...m, first: r(lead + DIP_RISE.before - m.hit) }); } /** @@ -1550,6 +1583,13 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?" if (hasBeat && !numIn(entry.beat, TEASER_BEAT.min, TEASER_BEAT.max)) { errors.push(`${where}.beat must be from ${TEASER_BEAT.min} to ${TEASER_BEAT.max} seconds, or absent`); } + if (entry?.tailWait !== undefined && entry?.tailWait !== null) { + if (!numIn(entry.tailWait, TEASER_TAIL_WAIT.min, TEASER_TAIL_WAIT.max)) { + errors.push(`${where}.tailWait must be from ${TEASER_TAIL_WAIT.min} to ${TEASER_TAIL_WAIT.max} seconds, or absent`); + } else if (!(typeof entry.tail === "string" && entry.tail.trim())) { + errors.push(`${where}.tailWait is the wait before the tail, and there is no tail`); + } + } const oneLine = (s, w) => { if (typeof s !== "string" || !s.trim()) { errors.push(`${w} must be words, not empty`); return false; } if (/[\r\n]/.test(s)) { errors.push(`${w} must be one line`); return false; } @@ -1591,9 +1631,11 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?" const rows = teaserLines(entry); const tail = teaserTail(entry); const fit = TEASER_LIMITS.fit; + // The row that carries the tail is centred by its own words and the tail + // hangs off its right: the row needs the tail and its gap on BOTH sides. rows.forEach((l, i) => { const w = `${where}.lines[${i}]`; - const tailHere = tail && i === rows.length - 1 ? tail.length + 1 : 0; + const tailHere = tail && i === rows.length - 1 ? 2 * (tail.length + 1) : 0; const head = l.head.length + (l.sub ? 0 : tailHere); if (head > fit[l.role]) { errors.push(