commit a85549b3d5669764ead5ead1b0311dd169431af9
parent dacea8bc1e3af286de25babd6225055c1c76bf79
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 16:21:30 -0400
report-to-video: a teaser row longer than fits the frame at its role's floor is refused (title 34, kicker 56, overline 64, second tier 66)
An 80-character title line was allowed and, shrunk to its 56 px floor, ran
past both edges of the frame. The counts are measured in the face at each
floor on ordinary words in capitals, a little under; the tail counts on the
row that carries it. The README records them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 66 insertions(+), 2 deletions(-)
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -448,6 +448,15 @@ 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.
+- **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
+ **kicker** 56, an **overline** 64, a `break`'s second tier 66; the tail and
+ the space before it count on the row that carries it. An 80-character title
+ spilled past both edges of the frame. A longer title fits by setting its end
+ as a `break`. The counts were measured in the face on ordinary words in
+ capitals (a title holds 36–37 there); a row of only wide capitals (M, W) can
+ still spill.
- **`seconds`** — 3 to 20. The pops land 0.55 s in (after the incoming
dissolve) and 0.7 s apart; the tail starts 0.8 s after the last and fades in
over 1.7 s; the last 1.2 s are a still hold for the end fade. A card too
diff --git a/umtool/report-to-video/chrome-teaser.test.mjs b/umtool/report-to-video/chrome-teaser.test.mjs
@@ -44,6 +44,23 @@ 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/);
+ // 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/);
+ 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: ["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/);
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 }] });
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -1004,8 +1004,22 @@ export function muteSegmentSeconds({ entry, record = null, render = {}, seconds
// is a title.
// ---------------------------------------------------------------------------
-/** The teaser's limits: lines, seconds, characters per line, the tail's length. */
-export const TEASER_LIMITS = Object.freeze({ lines: [1, 5], seconds: [3, 20], chars: 80, tail: 8 });
+/**
+ * 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
+ * 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
+ * 36–37 there, the kicker 58–60, the overline 65–67, the second tier 69–71;
+ * each limit is a little under. A row of only wide capitals (M, W) can still
+ * spill at these counts.
+ */
+export const TEASER_LIMITS = Object.freeze({
+ lines: [1, 5], seconds: [3, 20], chars: 80, tail: 8,
+ fit: Object.freeze({ overline: 64, title: 34, kicker: 56, sub: 66 }),
+});
const LINE_KEYS = ["text", "break"];
@@ -1180,6 +1194,30 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?"
errors.push(`${where}.tail is ${entry.tail.trim().length} characters (at most ${TEASER_LIMITS.tail})`);
}
}
+ // What fits the frame, row by row, once every line and the tail are sound.
+ if (!errors.length) {
+ const rows = teaserLines(entry);
+ const tail = teaserTail(entry);
+ const fit = TEASER_LIMITS.fit;
+ rows.forEach((l, i) => {
+ const w = `${where}.lines[${i}]`;
+ const tailHere = tail && i === rows.length - 1 ? tail.length + 1 : 0;
+ const head = l.head.length + (l.sub ? 0 : tailHere);
+ if (head > fit[l.role]) {
+ errors.push(
+ `${w} ${l.sub ? "before its break " : ""}is ${head} characters${!l.sub && tailHere ? " with the tail" : ""}, ` +
+ `and at most ${fit[l.role]} fit the frame as the ${l.role}` +
+ (l.sub ? "" : " -- shorten it, or set its end as a break"),
+ );
+ }
+ if (l.sub && l.sub.length + tailHere > fit.sub) {
+ errors.push(
+ `${w}.break is ${l.sub.length + tailHere} characters${tailHere ? " with the tail" : ""}, ` +
+ `and at most ${fit.sub} fit the frame as the second tier`,
+ );
+ }
+ });
+ }
return errors;
}