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:
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`);
}