commit c0bb74dd317c4e351838f642298814fb398f22f9
parent 24bcfbca9d181e42f16422a98f8fcd0655f4eddf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 01:29:55 -0400
report-to-video: the posts page names its planner itself, a deck without posts is never refused for the column, and qrencode takes -- before a URL
Review fixes. The posts page embedded postsCues by toString() and called it by
name; umtool's production build renames the module function, so the live
preview's page threw and drew nothing. embedFn writes it as a named const of a
function expression, so the page's name never depends on the module's.
validateChrome holds a deck to the posts column's fit only when the deck sets
deck.posts; validatePosts(posts, timeline, render) holds drawn posts to it, so
a narrow deck with no posts builds again. All three qrencode callers pass --
before the URL. Records corrected (As built, quirks, the spec's header).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
11 files changed, 85 insertions(+), 22 deletions(-)
diff --git a/plans/deck-posts.md b/plans/deck-posts.md
@@ -79,8 +79,15 @@ Branch `deck/posts` from `main` 2cf43a69; slices merged `--no-ff` after review.
build importing the page module pulled the composition's asset URL into umtool's bundle and
failed its `next build` while collecting page data — a failure no unit test could see.
- **The stack is planned in the page,** after the fonts load: whether a card overflows the column
- depends on measured heights. The planner's source is embedded in the page, so the page and the
- tests run one implementation.
+ depends on measured heights. The planner's source is embedded in the page under a fixed name
+ (`embedFn`: `const postsCues = (<source>)`), so the page and the tests run one implementation
+ even after umtool's production build renames the module function (the review's F1: the bare
+ `toString()` declared the minified name and the page threw, drawing nothing in umtool's live
+ preview; builds were unaffected).
+- **The column's fit is checked where posts are known** (review F2): `validateChrome` holds a deck
+ to it only when the deck sets `deck.posts`, and `validatePosts(posts, timeline, render)` holds
+ drawn posts to it. A deck with no posts is never refused for a column it does not draw.
+- `qrencode` takes `--` before the URL in all three callers: a URL is data, never an option.
- **A post's date is drawn as the day in its own string** (UTC as archived).
- The overlay of a window uses `-itsoffset`, `-reinit_filter 0`, `format=rgba` and
`eof_action=pass`, never `shortest=1`; a framemd5 test shows every frame outside a window is
diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md
@@ -236,10 +236,13 @@ frame sees the built timeline.
umtool's server bundles build-video into every report route, and Turbopack
turns `chrome-deck.mjs`'s `new URL("./assets/gsap.min.js", import.meta.url)`
into an asset URL that `fileURLToPath` refuses at module load ("Received an
-instance of URL"), failing `next build` while it collects page data. The page
-modules are reached only through compose-chrome's dynamic import; the posts
-windows' frame arithmetic the build needs (`snapWindow`) lives in `deck.mjs`
-for that reason. A capped umtool build is the gate that catches it — the unit
+instance of URL"), failing `next build` while it collects page data. So
+build-video reaches the page modules only through a dynamic import of
+compose-chrome, and the posts windows' frame arithmetic it needs
+(`snapWindow`) lives in `deck.mjs`. This is about build-video's import chain,
+not a wall around the page modules: umtool's preview helper
+(`lib/report/onscreen.mjs`) imports compose-chrome statically, as it did before
+posts, and its routes build. A capped umtool build is the gate that catches it — the unit
tests run in plain Node and pass either way.
## Rail strips and rolling counters
diff --git a/umtool/e2e/onscreen-posts.spec.ts b/umtool/e2e/onscreen-posts.spec.ts
@@ -14,10 +14,9 @@ import { postWindows } from "umtool-report-to-video/deck";
// c02 ("date"). Never built, so every timing is the estimate's. Each test puts
// the posts back to automatic and shown through the route before it starts.
//
-// The posts region's COMPOSITION is compose-chrome's (`region: "posts"`, slice
-// P1 of plans/deck-posts.md). When it is there, the preview test asserts the
-// window's page reports `posts:ready`; when it is not, the route says so per
-// window and the test asserts that refusal instead, and annotates the run.
+// The posts region's COMPOSITION is compose-chrome's (`region: "posts"`): the
+// preview test asserts every window composes and its page reports
+// `posts:ready` in the live preview.
// ---------------------------------------------------------------------------
const HERE = path.dirname(fileURLToPath(import.meta.url));
diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs
@@ -655,7 +655,7 @@ export async function updatePosts(dir, posts, { token = null } = {}) {
else delete post.hide;
}
}
- const errors = validatePosts(manifest.posts, manifest.timeline ?? []);
+ const errors = validatePosts(manifest.posts, manifest.timeline ?? [], manifest.render);
if (errors.length) throw new PostsRefused(errors);
const nextToken = await writeManifestAtomic(dir, manifest);
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -946,7 +946,8 @@ async function qrForEntry(entry, provenance, render, outDir) {
"-s", String(q.scale ?? 4),
"-m", String(q.quiet ?? 3),
"-l", q.ecc ?? "M",
- url,
+ // `--`: a URL is data, never an option, whatever it starts with.
+ "--", url,
]);
return { png, url };
}
@@ -2596,7 +2597,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// Posts are drawn only under the deck, so only the deck refuses bad ones --
// against the WHOLE timeline, where an `attachTo` has to name a clip.
if (deck) {
- const errors = validatePosts(whole.posts, whole.timeline ?? []);
+ const errors = validatePosts(whole.posts, whole.timeline ?? [], whole.render);
if (errors.length) throw new Error(`posts: ${errors.join("; ")}`);
}
// An `image` entry's `src` is relative to the MANIFEST, which is checked in
diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs
@@ -79,6 +79,18 @@ export function windowPosts(schedule, segment) {
* @returns {{ tops: number[], init: Record<string, object>,
* cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }}
*/
+/**
+ * A module function written into the page under a FIXED name. The page calls
+ * it by that name, and `fn.toString()` alone would declare whatever name the
+ * function has here -- which a bundler minifying server code renames (umtool's
+ * production build turned `postsCues` into `d`, and the page then threw a
+ * ReferenceError and drew nothing). As a named const of a parenthesised
+ * function expression, the page's name never depends on the module's.
+ */
+export function embedFn(name, fn) {
+ return `const ${name} = (${fn.toString()});`;
+}
+
export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slide = 0.35, rise = 18 }) {
const R = (v) => Math.round(v * 10000) / 10000;
const MIN = 0.001;
@@ -313,7 +325,7 @@ export function postsHtml(schedule, render, window, opts = {}) {
const P = JSON.parse(document.getElementById("posts-data").textContent);
const byK = {};
for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el;
- ${postsCues.toString()}
+ ${embedFn("postsCues", postsCues)}
const params = new URLSearchParams(location.search);
const local = (t) => Math.max(0, Math.min(P.dur, (Number(t) || 0) - P.from));
diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs
@@ -420,3 +420,14 @@ appendFileSync(${JSON.stringify(path.join(dir, "runs.log"))}, a.join(" ") + "\\n
rmSync(dir, { recursive: true, force: true });
}
});
+
+test("embedFn: the page declares the planner under its own name, whatever the bundler called it", async () => {
+ const { embedFn } = await import("./chrome-posts.mjs");
+ // A minifier renames the module function; the page must still find `postsCues`.
+ const renamed = function d(a) { return a * 2; };
+ const src = embedFn("postsCues", renamed);
+ assert.equal(new Function(`${src}; return postsCues(21);`)(), 42);
+ // And the page the module writes uses it, not a bare toString().
+ const { postsCues } = await import("./chrome-posts.mjs");
+ assert.match(embedFn("postsCues", postsCues), /^const postsCues = \(function postsCues\(/);
+});
diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs
@@ -548,7 +548,7 @@ export function chartBandHtml(manifest, totals, schedule, opts = {}) {
export async function qrPng(url, render, outPath, size) {
const q = render.qr ?? {};
const raw = `${outPath}.raw.png`;
- await run(QRENCODE, ["-o", raw, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3), "-l", q.ecc ?? "M", url]);
+ await run(QRENCODE, ["-o", raw, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3), "-l", q.ecc ?? "M", "--", url]);
await run(MAGICK, [raw, "-filter", "point", "-resize", `${size}x${size}!`, "-strip", outPath]);
await rm(raw, { force: true });
return outPath;
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -193,11 +193,9 @@ export function validateChrome(chrome, render = {}) {
// make the setting lie about what was drawn.
if (!errors.length) {
const g = deckGeometry({ ...render, chrome });
- const pp = resolveDeck({ chrome }).posts;
- if (pp.width + 2 * pp.inset > g.footage.width) {
- errors.push(`${w}.posts.width ${pp.width} with inset ${pp.inset} does not fit the ${g.footage.width}px footage box`);
- }
- if (pp.qrSize > pp.width / 2) errors.push(`${w}.posts.qrSize ${pp.qrSize} is more than half the card's width`);
+ // Only a deck that sets `posts` is held to the column's fit here; one with
+ // posts on the defaults is checked by validatePosts, which sees the posts.
+ if (d.posts !== undefined) errors.push(...postsFitErrors({ ...render, chrome }));
const room = g.H - g.deck.height;
if (g.footage.height > room) {
const max = Math.floor((room / g.H) * 1000) / 1000;
@@ -588,7 +586,7 @@ const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url",
*
* @returns {string[]}
*/
-export function validatePosts(posts, timeline = []) {
+export function validatePosts(posts, timeline = [], render = null) {
if (posts === undefined || posts === null) return [];
if (!Array.isArray(posts)) return ["posts must be a list"];
const errors = [];
@@ -617,6 +615,23 @@ export function validatePosts(posts, timeline = []) {
}
if (p.hide !== undefined && typeof p.hide !== "boolean") errors.push(`${w}.hide must be true or false`);
});
+ // Posts that will be drawn need a column that fits the footage box.
+ if (render && deckOn(render) && resolveDeck(render).posts.show && posts.some((p) => isObj(p) && !p.hide)) {
+ errors.push(...postsFitErrors(render));
+ }
+ return errors;
+}
+
+/** Why the posts column cannot be drawn in this frame, as sentences (empty: it can). */
+export function postsFitErrors(render) {
+ const w = "render.chrome.deck";
+ const g = deckGeometry(render);
+ const pp = resolveDeck(render).posts;
+ const errors = [];
+ if (pp.width + 2 * pp.inset > g.footage.width) {
+ errors.push(`${w}.posts.width ${pp.width} with inset ${pp.inset} does not fit the ${g.footage.width}px footage box`);
+ }
+ if (pp.qrSize > pp.width / 2) errors.push(`${w}.posts.qrSize ${pp.qrSize} is more than half the card's width`);
return errors;
}
diff --git a/umtool/report-to-video/deck.test.mjs b/umtool/report-to-video/deck.test.mjs
@@ -349,3 +349,18 @@ test("validatePosts and the posts settings refuse in sentences", () => {
assert.match(validateChrome({ ...CHROME, deck: { posts: { position: "middle" } } }, RENDER)[0], /position/);
assert.deepEqual(validateChrome({ ...CHROME, deck: { posts: { show: false } } }, RENDER), []);
});
+
+test("posts fit: a deck without posts is never refused for the column; drawn posts are", () => {
+ // 1280×720 at 0.5: a 640 px footage box, narrower than the default column.
+ const narrow = { width: 1280, height: 720, fps: 30, transition: 0.5, chrome: { ...CHROME, deck: { footageScale: 0.5 } } };
+ assert.deepEqual(validateChrome(narrow.chrome, narrow), []);
+ // Posts that will be drawn there are refused, with the column's sentence.
+ assert.match(validatePosts([POST("p", "2024-01-01")], CLIPS, narrow).join(" "), /does not fit the 640px footage box/);
+ // Hidden, switched off, or no render to check against: nothing to fit.
+ assert.deepEqual(validatePosts([POST("p", "2024-01-01", { hide: true })], CLIPS, narrow), []);
+ const off = { ...narrow, chrome: { ...CHROME, deck: { footageScale: 0.5, posts: { show: false } } } };
+ assert.deepEqual(validatePosts([POST("p", "2024-01-01")], CLIPS, off), []);
+ assert.deepEqual(validatePosts([POST("p", "2024-01-01")], CLIPS), []);
+ // A deck that SETS posts is still held to the fit by validateChrome.
+ assert.match(validateChrome({ ...CHROME, deck: { footageScale: 0.5, posts: { width: 600 } } }, narrow).join(" "), /does not fit/);
+});
diff --git a/umtool/report-to-video/render-cards.mjs b/umtool/report-to-video/render-cards.mjs
@@ -764,7 +764,7 @@ async function qrTileStrip(entries, provenance, render, g, outDir) {
const png = path.join(qrDir, `q${seen.size.toString().padStart(2, "0")}.png`);
await execFileP(QRENCODE, [
"-o", png, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3),
- "-l", q.ecc ?? "M", url,
+ "-l", q.ecc ?? "M", "--", url,
]);
// Nearest-neighbour to an exact box: a resampled QR blurs its module edges
// and stops scanning, and the geometry has to be known before this runs.