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:
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(