commit 381f9ead853dde3a6e7de5e6fd85ee691b11a4bd
parent bb5af7f85708fea334ee14d18ce7026ff22e43bb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 00:47:46 -0400
report-to-video: posts on the deck — the posts region and its overlay
chrome-posts.mjs draws one window's cards (date, @handle · platform, the
words in paragraphs clamped to maxLines, a QR of the post's url) in the
deck's palette and faces. The page measures the cards once the faces are in
and runs postsCues — the module's own function, by its source — for the
stack: cards enter at postSchedule's appear, stack top-down, the oldest slide
up and out when the next would overflow, and all leave over out.
composeChrome({region: "posts", window}) composes chrome/posts-<segment>/
and renders chrome/posts-<segment>-frames/ with its own cache key; a preview
composes posts-preview-<segment>/. Windows are snapped outward to the frame
grid (snapWindow). The build passes manifest.posts to writeChromeSchedule,
refuses invalid posts under the deck before any fetch, and lays each window
after the deck's region with -itsoffset, -reinit_filter 0, format=rgba and
eof_action=pass (never shortest=1) in the crossfade concat, applyChrome,
--chrome-only and --chrome-preview. verify-build checks each window's frame
count. Without posts every chain, schedule and segment is as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 1011 insertions(+), 32 deletions(-)
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -80,8 +80,11 @@ import { createCueSource, siteOriginFromManifest } from "./cues.mjs";
// and live in deck.mjs. This file only frames segments into its box and writes
// the schedule down -- it never has a copy of the arithmetic.
import {
- assertChrome, deckGeometry, deckOn, deckSchedule, frameCount, resolveDeck, scheduleFrom,
+ assertChrome, deckGeometry, deckOn, deckSchedule, frameCount, postsGeometry, postWindows, resolveDeck,
+ scheduleFrom, validatePosts,
} from "./deck.mjs";
+// The posts windows' frame arithmetic, which the overlay and verify-build share.
+import { snapWindow } from "./chrome-posts.mjs";
// The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table,
// in common, plain JS so bare `node` can load it.
import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs";
@@ -296,6 +299,7 @@ const HUMAN = {
// The deck's steps. `phase` is schedule | compose | render | cached | overlay.
chrome: (e) =>
`chrome ${e.phase}` +
+ (e.region ? ` ${e.region}${e.segment ? ` ${e.segment}` : ""}` : "") +
(e.segments !== undefined ? `: ${e.segments} segment(s)` : "") +
(e.total !== undefined ? `, ${Number(e.total).toFixed(3)}s` : "") +
(e.duration !== undefined ? ` window ${e.from}s +${e.duration}s` : "") +
@@ -1724,9 +1728,21 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
// whichever kind of frame comes first. The `format` filter alone does not
// stop the reinit. The deck only: the chart band's chain and inputs stay
// byte-for-byte as they shipped.
+ //
+ // A posts window (`name: "posts"`) is a SHORT sequence laid over the cut at
+ // its own second: `-itsoffset` puts its frame 1 at `offset` (negative in a
+ // preview whose clock starts after the window does), and the overlay
+ // passes the main frames through untouched before it starts and after it
+ // ends (`eof_action=pass`). NOT `shortest=1`, which would end the whole
+ // cut when the window ends. A window is never longer than the cut
+ // (snapWindow), and nothing past the main's end is drawn: the deck's
+ // `shortest=1` overlay ahead of it already ends the stream there. Same
+ // frame-kind trap as the deck, same two guards.
const deck = r.name === "deck";
+ const posts = r.name === "posts";
inputs.push(
- ...(deck ? ["-reinit_filter", "0"] : []),
+ ...(deck || posts ? ["-reinit_filter", "0"] : []),
+ ...(posts ? ["-itsoffset", offsetArg(r.offset)] : []),
"-framerate", String(render.fps),
"-start_number", "1",
"-i", path.join(r.frames, "frame_%06d.png"),
@@ -1735,11 +1751,11 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
const last = i === regions.length - 1;
const out = last && !final ? outLabel : `[hf${i}]`;
let src = `[${idx}:v]`;
- if (deck) {
+ if (deck || posts) {
parts.push(`${src}format=rgba[hfa${i}]`);
src = `[hfa${i}]`;
}
- parts.push(`${lab}${src}overlay=x=${r.x}:y=${r.y}:format=yuv444:shortest=1${out}`);
+ parts.push(`${lab}${src}overlay=x=${r.x}:y=${r.y}:format=yuv444:${posts ? "eof_action=pass" : "shortest=1"}${out}`);
lab = out;
});
if (final) parts.push(`${lab}format=yuv420p[vout]`);
@@ -1751,6 +1767,37 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
};
}
+/** A window's start, as ffmpeg's `-itsoffset` reads it: seconds, to the microsecond. */
+const offsetArg = (v) => {
+ const s = (Math.round(Number(v) * 1e6) / 1e6).toFixed(6);
+ return s === "-0.000000" ? "0.000000" : s;
+};
+
+/**
+ * The posts windows as overlay regions: one per clip that carries posts
+ * (`postWindows`), snapped to the frame grid (`snapWindow`), at
+ * `postsGeometry`. `offset` is the second the window's frame 1 lands on in the
+ * BASE's clock -- the cut's, or a preview's that starts `shift` seconds in.
+ * `clip` (`{at, dur}`) keeps only the windows that intersect it. No posts, no
+ * regions: the deck's overlay is then exactly what it was.
+ */
+export function postsRegions(render, outDir, schedule, { shift = 0, clip = null } = {}) {
+ const fps = Number(schedule.fps ?? render.fps);
+ const g = postsGeometry(render);
+ return postWindows(schedule)
+ .map((w) => ({ w, s: snapWindow(w, { fps, total: schedule.total }) }))
+ .filter(({ s }) => !clip || (s.from < clip.at + clip.dur && s.to > clip.at))
+ .map(({ w, s }) => ({
+ name: "posts",
+ segment: s.segment,
+ window: w,
+ frames: path.join(outDir, "chrome", `posts-${s.segment}-frames`),
+ x: g.x, y: g.y, width: g.width, height: g.height,
+ offset: s.from - shift,
+ frameCount: s.frames,
+ }));
+}
+
/**
* Where each rendered chrome region sits in the frame.
*
@@ -2276,7 +2323,9 @@ export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, re
/**
* Compose and render the deck (cached by compose-chrome's key), and check the
- * sequence is as long as the cut -- or the window -- it will be laid over.
+ * sequence is as long as the cut -- or the window -- it will be laid over;
+ * then the posts windows the schedule carries, the same way. Returns the
+ * overlay plan: the deck's region first, then each posts window's.
* Dynamic import: compose-chrome imports this file.
*/
async function renderDeck({ manifestPath, render, outDir, variant, schedule, from = 0, duration = null }) {
@@ -2301,6 +2350,34 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro
seconds: Number(((Date.now() - t0) / 1000).toFixed(1)),
});
const regions = chromeRegions(render, outDir).map((g) => ({ ...g, frames: r.frames }));
+
+ // Then the posts: one short sequence per clip that carries them, each with
+ // its own cache, laid over the deck at its own second. A preview window
+ // (`duration` given) takes only the windows it intersects, shifted into its
+ // own clock; their frames are the build's, so a preview renders nothing a
+ // build would not.
+ const clip = duration != null ? { at: from, dur: duration } : null;
+ for (const pr of postsRegions(render, outDir, schedule, { shift: clip ? clip.at : 0, clip })) {
+ EMIT("chrome", { phase: "compose", region: "posts", segment: pr.segment });
+ const t1 = Date.now();
+ const p = await composeChrome({
+ manifestPath, outDir, variant, region: "posts", window: pr.window, doRender: true,
+ fps: render.fps, workers: 2, quality: "high", format: "png-sequence",
+ });
+ if (p.frames !== pr.frames || p.frameCount !== pr.frameCount) {
+ throw new Error(
+ `the posts window for ${pr.segment} rendered ${p.frameCount} frames to ${p.frames}; ` +
+ `the overlay expects ${pr.frameCount} at ${pr.frames}`,
+ );
+ }
+ EMIT("chrome", {
+ phase: p.cached ? "cached" : "render", region: "posts", segment: pr.segment,
+ frames: p.frameCount, key: p.key, dir: p.frames,
+ seconds: Number(((Date.now() - t1) / 1000).toFixed(1)),
+ });
+ const { window: _w, frameCount: _n, ...region } = pr;
+ regions.push(region);
+ }
return { regions, outLabel: "[hfout]" };
}
@@ -2413,7 +2490,9 @@ export async function writeChromeSchedule({ manifest, entries, segments, D, outD
}).catch(() => null),
);
}
- const doc = deckSchedule({ entries, durs, D, render, provenance, metas });
+ // The variant's posts, placed on the clips this cut plays with their real
+ // upload dates. A manifest without posts writes the schedule it always did.
+ const doc = deckSchedule({ entries, durs, D, render, provenance, metas, posts: manifest.posts ?? [] });
await writeFile(path.join(outDir, "schedule.json"), JSON.stringify(doc, null, 2) + "\n");
return doc;
}
@@ -2516,6 +2595,12 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// fetch is spent. Absent, validateChrome has nothing to say.
if (render.chrome !== undefined && render.chrome !== null) assertChrome(render.chrome, render);
const deck = deckOn(render);
+ // 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 ?? []);
+ if (errors.length) throw new Error(`posts: ${errors.join("; ")}`);
+ }
// An `image` entry's `src` is relative to the MANIFEST, which is checked in
// beside the pictures it cites -- not to the cwd the build was started from.
const manifestDir = path.dirname(path.resolve(manifestPath));
diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs
@@ -0,0 +1,410 @@
+// The posts region's composition: one HyperFrames page per WINDOW -- the
+// stretch of the cut in which one clip's posts are on screen.
+//
+// PURE, like chrome-deck.mjs: a schedule, a render block and a window in, an
+// HTML string out. compose-chrome.mjs copies the assets in beside it, writes it
+// and renders it; nothing here touches a file.
+//
+// ---------------------------------------------------------------------------
+// Why a window and not the whole cut
+// ---------------------------------------------------------------------------
+// A post is on screen for a few seconds at the end of the clip it rides on. A
+// sequence for the whole cut would be ten thousand transparent frames to buy
+// fifteen seconds of cards, so each clip that carries posts gets its own short
+// sequence (`postWindows`), overlaid at its own start. Frame 1 of a window is
+// cut time `from`; the windows are snapped OUTWARD to the frame grid
+// (`snapWindow`) so frame i lands exactly on the cut's frame f0 + i.
+//
+// ---------------------------------------------------------------------------
+// Why the stack is planned in the page, by a function that lives here
+// ---------------------------------------------------------------------------
+// When the next card would overflow the column the oldest slide up and out --
+// and whether one would overflow depends on how tall each card is, which is
+// how its words wrap in the deck's own face. Only the browser knows that, and
+// only once the faces are in. So the page measures every card after the fonts
+// load and hands the heights to `postsCues` -- THIS module's function, written
+// into the page by its source text. The plan (every cue, its from, its time)
+// is the same function the tests call with heights of their choosing; the
+// browser holds no logic a test cannot see. The timeline is built, and then
+// registered, inside that fonts-loaded callback: the renderer awaits
+// document.fonts.ready before its first seek.
+//
+// Every cue is a fromTo whose FROM is stated, for the deck's reason: a render
+// is a seek per frame, from parallel workers, in any order.
+import { formatDeckDate } from "./attribution.mjs";
+import { frameCount, postsGeometry, resolveDeck } from "./deck.mjs";
+import { mix, rgba } from "./chrome-deck.mjs";
+
+const esc = (s) =>
+ String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+const r4 = (v) => Math.round(v * 10000) / 10000;
+
+/** How a platform is named on a card. */
+export const PLATFORM_LABEL = Object.freeze({ bluesky: "Bluesky", x: "X" });
+
+/**
+ * The motion. Seconds; `rise` is how far (px) a card comes up as it fades in,
+ * `gap` the space between two cards in the column.
+ */
+export const POSTS_MOTION = Object.freeze({ enter: 0.35, slide: 0.35, rise: 18, gap: 14 });
+
+/**
+ * A `postWindows` entry snapped OUTWARD to the frame grid, and kept inside the
+ * cut: `from` down to a frame, `to` up to one, never past the cut's last frame.
+ * `f0` is the cut frame a window's frame 1 lands on; `frames` is
+ * `frameCount(to − from, fps)` of the snapped window -- the sequence's length,
+ * which verify-build checks.
+ *
+ * @returns {{ segment: string, from: number, to: number, f0: number, frames: number }}
+ */
+export function snapWindow(window, { fps, total }) {
+ const f0 = Math.max(0, Math.floor(window.from * fps + 1e-6));
+ const f1 = Math.min(Math.ceil(window.to * fps - 1e-6), frameCount(total, fps));
+ if (!(f1 > f0)) throw new Error(`posts: the window for ${window.segment} is empty (${window.from}s–${window.to}s)`);
+ return { segment: window.segment, from: f0 / fps, to: f1 / fps, f0, frames: frameCount((f1 - f0) / fps, fps) };
+}
+
+/** The schedule's posts for one window's segment, in slot order. */
+export function windowPosts(schedule, segment) {
+ return (schedule.posts ?? []).filter((p) => p.segment === segment).sort((a, b) => a.slot - b.slot);
+}
+
+/**
+ * Everything the posts timeline does, as data. PURE and SELF-CONTAINED: the
+ * page carries this function's own source text and calls it with the heights
+ * it measured, so it may reference nothing outside its own body.
+ *
+ * `posts` are `[{ id, appear, out: [a, b] }]` in slot order (oldest first),
+ * times in the CUT's clock; `heights` are the cards' heights in px; `column`
+ * is the region's height. Card j enters at its `appear` (a fade and a rise of
+ * `rise` px over `enter` s); cards stack top-down `gap` apart; when card j
+ * would overflow the column, the oldest slide up and out (the whole stack
+ * moves up over `slide` s, the departing cards fading as they go); every card
+ * still up leaves over its `out` (a fade and a slight shrink).
+ *
+ * @returns {{ tops: number[], init: Record<string, object>,
+ * cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }}
+ */
+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;
+ const tops = [];
+ let acc = 0;
+ for (let j = 0; j < posts.length; j += 1) {
+ tops.push(acc);
+ acc += (heights[j] || 0) + gap;
+ }
+ const init = { stack: { y: 0 } };
+ for (let j = 0; j < posts.length; j += 1) init[`c${j}`] = { autoAlpha: 0, y: rise, scale: 1 };
+
+ const ev = [];
+ const add = (k, at, dur, to, ease, why) => ev.push({ k, at: R(at), dur: R(Math.max(MIN, dur)), to, ease, why });
+ const visible = [];
+ let shift = 0;
+ for (let j = 0; j < posts.length; j += 1) {
+ const p = posts[j];
+ const t = p.appear;
+ // The oldest go until card j fits below what is left.
+ const gone = [];
+ while (visible.length && tops[j] + (heights[j] || 0) - shift > column) {
+ gone.push(visible.shift());
+ shift = visible.length ? tops[visible[0]] : tops[j];
+ }
+ if (gone.length) {
+ add("stack", t, slide, { y: -shift }, "power2.inOut", `slide for ${p.id}`);
+ for (const g of gone) add(`c${g}`, t, slide, { autoAlpha: 0 }, "power1.in", `slide for ${p.id}`);
+ }
+ add(`c${j}`, t, enter, { autoAlpha: 1, y: 0 }, "power3.out", `enter ${p.id}`);
+ visible.push(j);
+ }
+ for (const j of visible) {
+ const [a, b] = posts[j].out;
+ add(`c${j}`, a, b - a, { autoAlpha: 0, scale: 0.97 }, "power2.in", `leave ${posts[j].id}`);
+ }
+
+ // Order, clamp (a cue never starts before the last one on its element has
+ // ended), and state every from.
+ ev.forEach((e, n) => { e.n = n; });
+ ev.sort((x, y) => x.at - y.at || x.n - y.n);
+ const state = {};
+ for (const k of Object.keys(init)) state[k] = { ...init[k] };
+ const freeAt = {};
+ const cues = [];
+ for (const e of ev) {
+ let { at, dur } = e;
+ const free = freeAt[e.k] ?? -Infinity;
+ if (at < free) {
+ const end = at + dur;
+ at = R(free);
+ dur = R(Math.max(MIN, end - at));
+ }
+ const cur = state[e.k] ?? (state[e.k] = {});
+ const from = {};
+ for (const q of Object.keys(e.to)) from[q] = cur[q];
+ Object.assign(cur, e.to);
+ freeAt[e.k] = R(at + dur);
+ cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why, n: cues.length });
+ }
+ // A clamp only ever moves a cue later on its own element, so this re-sort
+ // keeps every element's own order (and so every from).
+ cues.sort((x, y) => x.at - y.at || x.n - y.n);
+ for (const c of cues) delete c.n;
+ return { tops, init, cues };
+}
+
+/** A card's head: `@handle · Bluesky`, the separator in the deck's accent. */
+export function postWho(post) {
+ const handle = String(post.handle ?? "").trim();
+ const name = handle ? `@${handle.replace(/^@/, "")}` : String(post.author ?? "").trim();
+ const platform = PLATFORM_LABEL[post.platform] ?? String(post.platform ?? "");
+ return { name, platform };
+}
+
+/**
+ * A post's words as paragraphs: split on blank lines, single newlines kept
+ * inside each (the card draws them `pre-line`). A blank line is a gap between
+ * blocks rather than an empty line, so it costs none of `maxLines`.
+ */
+export function postParagraphs(text) {
+ return String(text ?? "")
+ .replace(/\r\n?/g, "\n")
+ .split(/\n[ \t]*\n+/)
+ .map((p) => p.replace(/^\n+|\s+$/g, ""))
+ .filter((p) => p.trim());
+}
+
+/** A post's date as the deck writes dates; a date-time is drawn as its day. */
+export function postDate(post, dateFormat = "long") {
+ return formatDeckDate(String(post.date ?? "").slice(0, 10), dateFormat);
+}
+
+/**
+ * The posts composition's HTML, for ONE window (a `snapWindow` result).
+ *
+ * `fonts` = `{ regular, bold }` asset-relative paths (DeckSans / DeckSansBold),
+ * `qrSrcs` = `{ [postId]: "assets/pqrNN.png" }`, `gsap` = the vendored script.
+ * The region is `postsGeometry(render)`, region-local and transparent outside
+ * the cards. `?still=<t>` and the preview's `deck:seek` take CUT seconds.
+ */
+export function postsHtml(schedule, render, window, opts = {}) {
+ const deck = resolveDeck(render);
+ const set = deck.posts;
+ const geo = postsGeometry(render);
+ const pal = render.palette;
+ const W = geo.width, H = geo.height;
+ const fonts = opts.fonts ?? {};
+ const qrSrcs = opts.qrSrcs ?? {};
+ const gsapSrc = opts.gsap ?? "assets/gsap.min.js";
+ const posts = windowPosts(schedule, window.segment);
+ if (!posts.length) throw new Error(`posts: no post rides on ${window.segment}`);
+ const dur = r4(window.to - window.from);
+ if (!(dur > 0)) throw new Error(`posts: the window for ${window.segment} is empty`);
+
+ const pad = 18;
+ const plateW = set.qrSize + 2 * pad;
+ const metaSize = 17;
+ const textSize = 23;
+ const lineH = Math.round(textSize * 1.36);
+ const top = mix(pal.bg, pal.fg, 0.095);
+ const bottom = mix(pal.bg, pal.fg, 0.04);
+
+ const cardHtml = posts
+ .map((p, j) => {
+ const { name, platform } = postWho(p);
+ const src = qrSrcs[p.id];
+ return (
+ `<article class="post" data-post="${esc(p.id)}" data-k="c${j}">` +
+ `<div class="edge"></div>` +
+ `<div class="body">` +
+ `<div class="meta"><span class="who"><span class="handle">${esc(name)}</span>` +
+ (platform ? `<span class="sep">·</span><span class="platform">${esc(platform)}</span>` : "") +
+ `</span><span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` +
+ `<div class="text">${postParagraphs(p.text).map((t) => `<p class="para">${esc(t)}</p>`).join("")}</div>` +
+ `</div>` +
+ `<div class="plate">` +
+ (src ? `<img src="${esc(src)}" width="${set.qrSize}" height="${set.qrSize}" alt="">` : "") +
+ `</div>` +
+ `</article>`
+ );
+ })
+ .join("\n ");
+
+ const data = {
+ segment: window.segment,
+ from: r4(window.from),
+ to: r4(window.to),
+ dur,
+ column: H,
+ maxLines: set.maxLines,
+ lineH,
+ gap: POSTS_MOTION.gap,
+ enter: POSTS_MOTION.enter,
+ slide: POSTS_MOTION.slide,
+ rise: POSTS_MOTION.rise,
+ ids: posts.map((p) => p.id),
+ posts: posts.map((p) => ({ id: p.id, appear: p.appear, out: p.out })),
+ };
+ // `</script>` inside a JSON string would close the tag; nothing in here is
+ // trusted text, but a segment id is the manifest's and costs nothing to guard.
+ const json = JSON.stringify(data).replace(/</g, "\\u003c");
+
+ return `<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=${W}, height=${H}" />
+ <script src="${esc(gsapSrc)}"></script>
+ <style>
+ /* The deck's faces, under the deck's private names -- see docs/quirks.md. */
+ @font-face { font-family: 'DeckSans'; font-weight: 400; font-style: normal;
+ src: url('${esc(fonts.regular ?? "")}'); }
+ @font-face { font-family: 'DeckSansBold'; font-weight: 400; font-style: normal;
+ src: url('${esc(fonts.bold ?? "")}'); }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body { width: ${W}px; height: ${H}px; overflow: hidden; background: transparent; }
+ body { font-family: 'DeckSans', sans-serif; font-synthesis: none; color: ${pal.fg};
+ -webkit-font-smoothing: antialiased; text-rendering: geometricPrecision; }
+ #root { position: relative; width: ${W}px; height: ${H}px; overflow: hidden; }
+ #posts-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
+ .stack { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
+ /* Hidden until the timeline places it: a frame taken before the faces
+ are in shows nothing rather than an unplaced card. */
+ .post { position: absolute; left: 0; top: 0; width: ${W}px; min-height: ${set.qrSize + 2 * pad}px;
+ visibility: hidden; opacity: 0; border-radius: 10px; overflow: hidden;
+ background: linear-gradient(180deg, ${top} 0%, ${bottom} 100%);
+ box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.1)}; transform-origin: 50% 0%; }
+ /* The deck's top edge, the same hairline: the accent in from the left,
+ settling to a quiet rule. */
+ .edge { position: absolute; left: 0; top: 0; width: ${W}px; height: 2px;
+ background: linear-gradient(90deg, ${rgba(pal.accent, 0.95)} 0px, ${rgba(pal.accent, 0.4)} ${Math.round(W * 0.28)}px,
+ ${rgba(pal.fg, 0.14)} ${Math.round(W * 0.62)}px, ${rgba(pal.fg, 0.14)} ${W}px); }
+ .body { position: relative; width: ${W - plateW}px; padding: ${pad}px ${pad + 4}px ${pad + 2}px ${pad + 4}px; }
+ .meta { display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
+ font-size: ${metaSize}px; line-height: ${Math.round(metaSize * 1.3)}px; white-space: nowrap; }
+ .who { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
+ .handle { font-family: 'DeckSansBold', sans-serif; color: ${pal.fg}; letter-spacing: 0.005em; }
+ .sep { color: ${pal.accent}; padding: 0 0.42em; font-family: 'DeckSansBold', sans-serif; }
+ .platform { color: ${pal.muted}; }
+ .date { color: ${pal.muted}; flex: none; font-variant-numeric: tabular-nums; }
+ .text { margin-top: 10px; font-size: ${textSize}px; line-height: ${lineH}px; color: ${pal.fg}; }
+ /* Each paragraph is clamped to what is left of maxLines when the page
+ measures (clampText), so the ellipsis always ends words, never a blank line. */
+ .para { white-space: pre-line; overflow-wrap: anywhere; overflow: hidden;
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${set.maxLines}; }
+ .para + .para { margin-top: ${Math.round(lineH * 0.42)}px; }
+ .para.gone { display: none; }
+ /* The source cell: the QR in a cell a shade down, as on the deck. */
+ .plate { position: absolute; right: 0; top: 0; bottom: 0; width: ${plateW}px;
+ display: flex; align-items: center; justify-content: center;
+ background: linear-gradient(180deg, ${mix(top, pal.bg, 0.55)} 0%, ${mix(bottom, pal.bg, 0.6)} 100%);
+ border-left: 1px solid ${rgba(pal.fg, 0.07)}; }
+ .plate img { display: block; width: ${set.qrSize}px; height: ${set.qrSize}px; border-radius: 6px;
+ image-rendering: pixelated;
+ box-shadow: 0 0 0 1px ${rgba(pal.fg, 0.25)}, 0 6px 18px rgba(0, 0, 0, 0.35); }
+ </style>
+ </head>
+ <body>
+ <div id="root" data-composition-id="posts" data-start="0" data-duration="${dur}"
+ data-width="${W}" data-height="${H}" data-segment="${esc(window.segment)}">
+ <div id="posts-clip" class="clip" data-start="0" data-duration="${dur}" data-track-index="1">
+ <div class="stack" data-k="stack">
+ ${cardHtml}
+ </div>
+ </div>
+ </div>
+
+ <script id="posts-data" type="application/json">${json}</script>
+ <script>
+ 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()}
+
+ const params = new URLSearchParams(location.search);
+ const local = (t) => Math.max(0, Math.min(P.dur, (Number(t) || 0) - P.from));
+ let tl = null;
+ let pending = null;
+
+ // Measure once the faces are in -- a card's height is how its words wrap
+ // in the deck's own face -- then plan, place, build and register. The
+ // renderer awaits document.fonts.ready before it seeks a frame.
+ // maxLines across a card's paragraphs: each is clamped to the lines
+ // still unspent; once they are spent the rest are dropped, and the last
+ // one drawn ends in an ellipsis when anything was dropped.
+ function clampText(card) {
+ let left = P.maxLines;
+ let shown = null;
+ let cut = false;
+ for (const p of card.querySelectorAll(".para")) {
+ if (left <= 0) { p.classList.add("gone"); cut = true; continue; }
+ p.style.webkitLineClamp = String(left);
+ const lines = Math.round(p.getBoundingClientRect().height / P.lineH);
+ if (p.scrollHeight > p.clientHeight + 1) cut = true;
+ left -= lines;
+ shown = { p, lines };
+ }
+ if (cut && shown && !(shown.p.scrollHeight > shown.p.clientHeight + 1)) {
+ // Dropped paragraphs after one that fit exactly: say so on it. The
+ // clamp it already has turns an overflowing "…" into the ellipsis.
+ shown.p.textContent = shown.p.textContent.replace(/\s+$/, "") + " …";
+ shown.p.style.webkitLineClamp = String(shown.lines);
+ }
+ }
+
+ const ready = Promise.all([
+ document.fonts.load("23px DeckSans"),
+ document.fonts.load("17px DeckSansBold"),
+ ]).catch(() => {}).then(() => {
+ const cards = P.ids.map((_, j) => byK["c" + j]);
+ cards.forEach(clampText);
+ const heights = cards.map((c) => c.offsetHeight);
+ const plan = postsCues({ posts: P.posts, heights, column: P.column, gap: P.gap,
+ enter: P.enter, slide: P.slide, rise: P.rise });
+ cards.forEach((c, j) => { c.style.top = plan.tops[j] + "px"; });
+ for (const k of Object.keys(plan.init)) if (byK[k]) gsap.set(byK[k], plan.init[k]);
+ // The cues are in the cut's clock; the window plays [from, from + dur] of it.
+ const inner = gsap.timeline({ paused: true });
+ for (const c of plan.cues) {
+ const el = byK[c.k];
+ if (!el) continue;
+ inner.fromTo(el, c.from, { ...c.to, duration: c.dur, ease: c.ease, immediateRender: false }, c.at);
+ }
+ tl = gsap.timeline({ paused: true });
+ tl.add(inner.tweenFromTo(P.from, P.from + P.dur, { duration: P.dur, ease: "none" }), 0);
+ window.__timelines = window.__timelines || {};
+ window.__timelines["posts"] = tl;
+ if (typeof window.__hfForceTimelineRebind === "function") window.__hfForceTimelineRebind();
+ document.documentElement.dataset.heights = heights.join(",");
+ tl.seek(pending ?? 0, false);
+ document.documentElement.dataset.ready = "1";
+ });
+
+ // The review still, in cut seconds.
+ const still = params.get("still");
+ if (still !== null) pending = local(still);
+
+ // umtool's live preview: the parent seeks in cut seconds, as it seeks the deck.
+ if (params.get("preview") === "1") {
+ window.addEventListener("message", (e) => {
+ const m = e.data || {};
+ if (m.type !== "deck:seek") return;
+ pending = local(m.t);
+ if (tl) tl.seek(pending, false);
+ });
+ ready.then(() => {
+ if (window.parent !== window) {
+ window.parent.postMessage({ type: "posts:ready", segment: P.segment, from: P.from, to: P.to, ids: P.ids }, "*");
+ }
+ });
+ }
+ </script>
+ </body>
+</html>
+`;
+}
diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs
@@ -0,0 +1,422 @@
+// Tests for the posts region (slice P1): the card page (chrome-posts.mjs), its
+// window arithmetic, the plan the page runs, compose-chrome's posts path, and
+// the overlay that lays each window on the cut -- including one real ffmpeg
+// run proving the frames outside a window are untouched.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+import {
+ postDate, postParagraphs, postsCues, postsHtml, postWho, snapWindow, windowPosts,
+} from "./chrome-posts.mjs";
+import { postSchedule, postsGeometry, postWindows } from "./deck.mjs";
+import { applyChromeArgs, chromeOverlayChain, chromeRegions, postsRegions } from "./build-video.mjs";
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+
+const RENDER = {
+ width: 1920,
+ height: 1080,
+ fps: 30,
+ transition: 0.5,
+ palette: { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" },
+ chrome: { engine: "hyperframes", layout: "deck", deck: {} },
+};
+
+const CLIPS = [
+ { id: "c1", type: "clip", date: "2024-09-05" },
+ { id: "c2", type: "clip", date: "2024-10-01" },
+ { id: "c3", type: "clip", date: "2025-06-01" },
+];
+const SEGS = [
+ { id: "c1", start: 0, duration: 10 },
+ { id: "c2", start: 9.5, duration: 10 },
+ { id: "c3", start: 19, duration: 9.5 },
+];
+const POST = (id, date, extra = {}) => ({
+ id, platform: "bluesky", author: "Pirate Software", handle: "piratesoftware.live", date,
+ text: `words of ${id}`, url: `https://bsky.app/profile/piratesoftware.live/post/${id}`, ...extra,
+});
+const HOSTILE = POST("evil", "2024-11-27T02:36:13.745Z", {
+ platform: "x",
+ handle: '"><img src=x onerror=alert(1)>',
+ author: "<b>bold</b>",
+ text: "</script><script>alert(1)</script>\nsecond line & 'quotes'\n\nnew paragraph",
+});
+
+/** A schedule as the build writes one, posts placed by the core. */
+function schedule(posts = [POST("a", "2024-10-19T17:01:17.640Z"), HOSTILE, POST("z", "2026-01-22")]) {
+ const placed = postSchedule({ posts, entries: CLIPS, segments: SEGS, D: 0.5, total: 28.5, render: RENDER });
+ return {
+ version: 1, kind: "deck", estimated: false, fps: 30, transition: 0.5, total: 28.5, multiChannel: false,
+ segments: SEGS.map((s) => ({ ...s, type: "clip", end: s.start + s.duration, title: "", subtitle: "", qrUrl: null, hideDeck: false })),
+ posts: placed,
+ };
+}
+
+const FONTS = { regular: "assets/DeckSans.ttf", bold: "assets/DeckSansBold.ttf" };
+const dataOf = (html) => JSON.parse(/<script id="posts-data" type="application\/json">(.*?)<\/script>/s.exec(html)[1]);
+const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-3, `${msg}: ${a} != ${b}`);
+
+function c2Page(sched = schedule()) {
+ const win = snapWindow(postWindows(sched).find((w) => w.segment === "c2"), { fps: 30, total: sched.total });
+ const qrSrcs = Object.fromEntries(windowPosts(sched, "c2").map((p, i) => [p.id, `assets/qr0${i}.png`]));
+ return { sched, win, qrSrcs, html: postsHtml(sched, RENDER, win, { fonts: FONTS, qrSrcs }) };
+}
+
+test("every post in the window has its nodes: date, handle and platform, words, QR", () => {
+ const { sched, html, qrSrcs } = c2Page();
+ const posts = windowPosts(sched, "c2");
+ assert.deepEqual(posts.map((p) => p.id), ["a", "evil"]);
+ posts.forEach((p, j) => {
+ const open = html.indexOf(`data-post="${p.id}" data-k="c${j}"`);
+ assert.ok(open > 0, `no card for ${p.id}`);
+ const next = html.indexOf("<article", open + 1);
+ const card = html.slice(open, next > 0 ? next : undefined);
+ assert.match(card, /class="handle"/);
+ assert.match(card, /class="platform"/);
+ assert.match(card, /class="date"/);
+ assert.match(card, /class="para"/);
+ assert.ok(card.includes(`<img src="${qrSrcs[p.id]}" width="120" height="120"`), `${p.id} qr`);
+ });
+ assert.ok(html.includes(">@piratesoftware.live</span>"));
+ assert.ok(html.includes('<span class="platform">Bluesky</span>'));
+ assert.ok(html.includes('<span class="platform">X</span>'));
+ // A date-time is drawn as its own day, in the deck's date format.
+ assert.ok(html.includes('<span class="date">Oct 19, 2024</span>'));
+ assert.ok(html.includes('<span class="date">Nov 27, 2024</span>'));
+ // The composition contract: the posts region, sized postsGeometry.
+ const g = postsGeometry(RENDER);
+ assert.match(html, new RegExp(`data-composition-id="posts" data-start="0" data-duration="[\\d.]+"\\s+data-width="${g.width}" data-height="${g.height}"`));
+ assert.match(html, /window\.__timelines\["posts"\] = tl/);
+});
+
+test("post strings are text: escaped in the page, in attributes and in the inline JSON", () => {
+ const { html } = c2Page();
+ assert.ok(html.includes("</script><script>alert(1)</script>\nsecond line & 'quotes'"));
+ assert.ok(html.includes("@"><img src=x onerror=alert(1)>"));
+ assert.doesNotMatch(html, /<img src=x/);
+ assert.doesNotMatch(html, /<b>bold<\/b>/);
+ // gsap, the data, the runtime -- and no fourth script.
+ assert.equal((html.match(/<\/script>/g) ?? []).length, 3);
+ assert.equal((html.match(/<script/g) ?? []).length, 3);
+ // The JSON carries no post words at all, and nothing that could close its tag.
+ const json = /<script id="posts-data" type="application\/json">(.*?)<\/script>/s.exec(html)[1];
+ assert.doesNotMatch(json, /</);
+ assert.doesNotMatch(json, /alert|words of/);
+ // A segment id is the manifest's; it is escaped where it lands.
+ const sched = schedule();
+ const odd = { ...sched, posts: sched.posts.map((p) => ({ ...p, segment: p.segment === "c2" ? 'c2"<x>' : p.segment })) };
+ const page = postsHtml(odd, RENDER, { segment: 'c2"<x>', from: 15, to: 20 }, { fonts: FONTS });
+ assert.ok(page.includes('data-segment="c2"<x>"'));
+ assert.ok(dataOf(page).segment === 'c2"<x>');
+ assert.doesNotMatch(/<script id="posts-data"[^>]*>(.*?)<\/script>/s.exec(page)[1], /</);
+});
+
+test("nothing on the page leaves the machine; the post's own link is only in its QR", () => {
+ const sched = schedule([POST("a", "2024-10-19"), POST("b", "2024-10-20")]);
+ const win = snapWindow(postWindows(sched)[0], { fps: 30, total: sched.total });
+ const html = postsHtml(sched, RENDER, win, { fonts: FONTS, qrSrcs: { a: "assets/qr00.png", b: "assets/qr01.png" } });
+ assert.doesNotMatch(html, /https?:\/\//, "a URL in the page");
+ assert.doesNotMatch(html, /(?:src|href)="\/\//, "a protocol-relative URL");
+ assert.doesNotMatch(html, /@import|fonts\.googleapis|cdn\.|<link/);
+ assert.match(html, /<script src="assets\/gsap\.min\.js"><\/script>/);
+ for (const m of html.matchAll(/font-family: ([^;]+);/g)) {
+ assert.match(m[1], /^'DeckSans(?:Bold)?'(?:, sans-serif)?$/, m[1]);
+ }
+});
+
+test("the page's times are postSchedule's: data, enter and leave cues", () => {
+ const { sched, html, win } = c2Page();
+ const d = dataOf(html);
+ const posts = windowPosts(sched, "c2");
+ assert.deepEqual(d.posts, posts.map((p) => ({ id: p.id, appear: p.appear, out: p.out })));
+ assert.equal(d.from, win.from);
+ near(d.dur, win.to - win.from, "the window's length");
+ // Whatever the heights, each card enters at its appear and leaves over its out.
+ const plan = postsCues({ posts: d.posts, heights: [180, 220], column: d.column, gap: d.gap, enter: d.enter, slide: d.slide, rise: d.rise });
+ posts.forEach((p, j) => {
+ const enter = plan.cues.find((c) => c.k === `c${j}` && c.why === `enter ${p.id}`);
+ near(enter.at, p.appear, `${p.id} enters`);
+ assert.deepEqual(enter.to, { autoAlpha: 1, y: 0 });
+ const leave = plan.cues.find((c) => c.k === `c${j}` && c.why === `leave ${p.id}`);
+ near(leave.at, p.out[0], `${p.id} leaves`);
+ near(leave.at + leave.dur, p.out[1], `${p.id} gone`);
+ });
+ // The page runs this very function, by its source.
+ assert.ok(html.includes(postsCues.toString()));
+});
+
+test("postsCues: cards stack top-down; when the next would overflow, the oldest slide up and out", () => {
+ const posts = [
+ { id: "p0", appear: 10, out: [16, 16.5] },
+ { id: "p1", appear: 12, out: [16, 16.5] },
+ { id: "p2", appear: 14, out: [16, 16.5] },
+ ];
+ const fits = postsCues({ posts, heights: [200, 200, 200], column: 838, gap: 14 });
+ assert.deepEqual(fits.tops, [0, 214, 428]);
+ assert.equal(fits.cues.filter((c) => c.k === "stack").length, 0);
+ // Three 300 px cards do not fit 838: the third pushes the first out.
+ const over = postsCues({ posts, heights: [300, 300, 300], column: 838, gap: 14 });
+ const slides = over.cues.filter((c) => c.k === "stack");
+ assert.equal(slides.length, 1);
+ near(slides[0].at, 14, "the slide starts as p2 enters");
+ assert.deepEqual(slides[0].to, { y: -314 });
+ const out0 = over.cues.find((c) => c.k === "c0" && c.why === "slide for p2");
+ assert.deepEqual(out0.to, { autoAlpha: 0 });
+ // p0 is gone, so only p1 and p2 leave at the end.
+ assert.deepEqual(over.cues.filter((c) => c.why.startsWith("leave")).map((c) => c.k), ["c1", "c2"]);
+ // A card taller than the room left pushes out everything before it.
+ const big = postsCues({ posts, heights: [300, 300, 800], column: 838, gap: 14 });
+ assert.deepEqual(big.cues.find((c) => c.k === "stack").to, { y: -628 });
+});
+
+test("postsCues: every cue states the from the one before it left, and none overlaps another on one element", () => {
+ const posts = [
+ { id: "p0", appear: 10, out: [10.5, 11] }, // too short for its enter: the leave is clamped after it
+ { id: "p1", appear: 10.2, out: [10.5, 11] },
+ ];
+ const { init, cues } = postsCues({ posts, heights: [500, 500], column: 838, gap: 14 });
+ const state = JSON.parse(JSON.stringify(init));
+ const busy = new Map();
+ for (const c of cues) {
+ for (const [p, v] of Object.entries(c.from)) assert.deepEqual(v, state[c.k]?.[p], `${c.k}.${p} at ${c.at}`);
+ Object.assign((state[c.k] ??= {}), c.to);
+ assert.ok(c.at >= (busy.get(c.k) ?? 0) - 1e-9, `${c.k} overlaps at ${c.at}`);
+ busy.set(c.k, c.at + c.dur);
+ }
+ for (let i = 1; i < cues.length; i += 1) assert.ok(cues[i].at >= cues[i - 1].at, "in time order");
+});
+
+test("snapWindow: outward to the frame grid, never past the cut", () => {
+ assert.deepEqual(snapWindow({ segment: "c06", from: 129.9, to: 136.4 }, { fps: 30, total: 350.2 }),
+ { segment: "c06", from: 129.9, to: 136.4, f0: 3897, frames: 195 });
+ const odd = snapWindow({ segment: "c03", from: 69.567, to: 74.067 }, { fps: 30, total: 350.2 });
+ assert.equal(odd.f0, 2087);
+ near(odd.from, 2087 / 30, "down to a frame");
+ near(odd.to, 2223 / 30, "up to a frame");
+ assert.equal(odd.frames, 136);
+ // The last clip's leave ends with the cut, and so does its window.
+ const end = snapWindow({ segment: "c20", from: 346.9, to: 350.2 }, { fps: 30, total: 350.2 });
+ assert.equal(end.f0 + end.frames, 10506);
+ assert.throws(() => snapWindow({ segment: "x", from: 5, to: 5 }, { fps: 30, total: 10 }), /empty/);
+});
+
+test("postsRegions: one per window, at postsGeometry, offset into the base's clock", () => {
+ const sched = schedule();
+ const regs = postsRegions(RENDER, "/o/sourced", sched);
+ const g = postsGeometry(RENDER);
+ assert.deepEqual(regs.map((r) => r.segment), postWindows(sched).map((w) => w.segment));
+ for (const r of regs) {
+ const s = snapWindow(r.window, { fps: 30, total: sched.total });
+ assert.equal(r.name, "posts");
+ assert.equal(r.frames, `/o/sourced/chrome/posts-${r.segment}-frames`);
+ assert.deepEqual([r.x, r.y, r.width, r.height], [g.x, g.y, g.width, g.height]);
+ near(r.offset, s.from, "cut clock");
+ assert.equal(r.frameCount, s.frames);
+ }
+ // A preview at 16 s for 4 s keeps only what it touches, shifted into its own clock.
+ const c2 = regs.find((r) => r.segment === "c2");
+ const pv = postsRegions(RENDER, "/o/sourced", sched, { shift: 16, clip: { at: 16, dur: 4 } });
+ assert.deepEqual(pv.map((r) => r.segment), ["c2"]);
+ near(pv[0].offset, c2.offset - 16, "preview clock");
+ assert.deepEqual(postsRegions(RENDER, "/o", sched, { clip: { at: 0, dur: 2 } }), []);
+ assert.deepEqual(postsRegions(RENDER, "/o", { ...sched, posts: undefined }), []);
+});
+
+test("chromeOverlayChain: the deck first, then each window at its second, passed through outside it", () => {
+ const sched = schedule();
+ const regions = [...chromeRegions(RENDER, "/o/sourced"), ...postsRegions(RENDER, "/o/sourced", sched)];
+ const hf = chromeOverlayChain(RENDER, regions, "[v2]", 3);
+ const g = postsGeometry(RENDER);
+ const [w0, w1] = postsRegions(RENDER, "/o/sourced", sched);
+ assert.deepEqual(hf.inputs, [
+ "-reinit_filter", "0", "-framerate", "30", "-start_number", "1", "-i", "/o/sourced/chrome/deck-frames/frame_%06d.png",
+ "-reinit_filter", "0", "-itsoffset", w0.offset.toFixed(6), "-framerate", "30", "-start_number", "1",
+ "-i", `/o/sourced/chrome/posts-${w0.segment}-frames/frame_%06d.png`,
+ "-reinit_filter", "0", "-itsoffset", w1.offset.toFixed(6), "-framerate", "30", "-start_number", "1",
+ "-i", `/o/sourced/chrome/posts-${w1.segment}-frames/frame_%06d.png`,
+ ]);
+ assert.equal(
+ hf.chain,
+ [
+ "[3:v]format=rgba[hfa0]",
+ "[v2][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0]",
+ "[4:v]format=rgba[hfa1]",
+ `[hf0][hfa1]overlay=x=${g.x}:y=${g.y}:format=yuv444:eof_action=pass[hf1]`,
+ "[5:v]format=rgba[hfa2]",
+ `[hf1][hfa2]overlay=x=${g.x}:y=${g.y}:format=yuv444:eof_action=pass[hf2]`,
+ "[hf2]format=yuv420p[vout]",
+ ].join(";"),
+ );
+ // No shortest=1 on a window: it would end the cut where the window ends.
+ assert.equal((hf.chain.match(/shortest=1/g) ?? []).length, 1);
+ // A preview's window can start before the preview does.
+ const pv = chromeOverlayChain(RENDER, [{ ...regions[1], offset: -0.25 }], "[0:v]", 1);
+ assert.deepEqual(pv.inputs.slice(0, 4), ["-reinit_filter", "0", "-itsoffset", "-0.250000"]);
+});
+
+test("without posts the deck's overlay is exactly what it was", () => {
+ const sched = { ...schedule(), posts: undefined };
+ const plain = chromeRegions(RENDER, "/o/sourced");
+ const withNone = [...plain, ...postsRegions(RENDER, "/o/sourced", sched)];
+ assert.deepEqual(chromeOverlayChain(RENDER, withNone, "[v16]", 17), chromeOverlayChain(RENDER, plain, "[v16]", 17));
+ assert.equal(
+ chromeOverlayChain(RENDER, withNone, "[v16]", 17).chain,
+ "[17:v]format=rgba[hfa0];[v16][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]",
+ );
+ const args = applyChromeArgs("/i.mp4", "/o.mp4", RENDER, { regions: withNone, outLabel: "[hfout]" });
+ assert.ok(!args.includes("-itsoffset"));
+});
+
+test("postParagraphs, postWho, postDate", () => {
+ assert.deepEqual(postParagraphs("one\ntwo\n\n\nthree \r\n\r\nfour"), ["one\ntwo", "three", "four"]);
+ assert.deepEqual(postParagraphs("\n\n \n"), []);
+ assert.deepEqual(postWho({ handle: "@a.b", platform: "x" }), { name: "@a.b", platform: "X" });
+ assert.deepEqual(postWho({ author: "Someone", platform: "bluesky" }), { name: "Someone", platform: "Bluesky" });
+ assert.equal(postDate({ date: "2026-01-22T00:49:31.418Z" }), "Jan 22, 2026");
+ assert.equal(postDate({ date: "2026-01-22T00:49:31.418Z" }, "iso"), "2026-01-22");
+});
+
+// ---------------------------------------------------------------------------
+// ffmpeg, for real: a window laid at its second leaves every other frame alone
+// and the cut's length unchanged.
+// ---------------------------------------------------------------------------
+
+const have = (b, a) => spawnSync(b, a, { stdio: "ignore" }).status === 0;
+const haveFfmpeg = have("ffmpeg", ["-version"]) && have("magick", ["-version"]);
+
+test("ffmpeg: frames outside a window are the deck-only frames, and the length holds", { skip: !haveFfmpeg }, () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "posts-ov-"));
+ try {
+ const run = (args) => {
+ const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...args], { encoding: "utf8", maxBuffer: 1 << 26 });
+ assert.equal(r.status, 0, r.stderr);
+ return r.stdout;
+ };
+ const R = { ...RENDER, fps: 30 };
+ // Two 3 s segments crossfaded, as the concat does: 5.5 s, 165 frames.
+ run(["-f", "lavfi", "-i", "testsrc2=s=320x180:r=30:d=3", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-crf", "18", path.join(dir, "a.mp4")]);
+ run(["-f", "lavfi", "-i", "smptebars=s=320x180:r=30:d=3", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-crf", "18", path.join(dir, "b.mp4")]);
+ const seq = (name, n, mixedAt = -1) => {
+ const d = path.join(dir, name);
+ mkdirSync(d);
+ for (let i = 1; i <= n; i += 1) {
+ const f = path.join(d, `frame_${String(i).padStart(6, "0")}.png`);
+ // One opaque RGB frame among RGBA ones: the renderer's mixed sequence.
+ if (i === mixedAt) spawnSync("magick", ["-size", "80x40", "xc:red", `PNG24:${f}`]);
+ else spawnSync("magick", ["-size", "80x40", "xc:none", "-fill", "rgba(0,0,255,0.7)", "-draw", "rectangle 5,5 75,35", `PNG32:${f}`]);
+ }
+ return d;
+ };
+ const deck = { name: "deck", frames: seq("deck", 165), x: 0, y: 140, width: 80, height: 40 };
+ const w1 = { name: "posts", frames: seq("w1", 15, 5), x: 200, y: 10, width: 80, height: 40, offset: 2 };
+ // A window that runs past the end of the cut.
+ const w2 = { name: "posts", frames: seq("w2", 30), x: 200, y: 60, width: 80, height: 40, offset: 5.2 };
+ const frames = (regions) => {
+ const hf = chromeOverlayChain(R, regions, "[v1]", 2);
+ const out = run([
+ "-i", path.join(dir, "a.mp4"), "-i", path.join(dir, "b.mp4"), ...hf.inputs,
+ "-filter_complex", `[0:v][1:v]xfade=transition=fade:duration=0.5:offset=2.500[v1];${hf.chain}`,
+ "-map", hf.outLabel, "-f", "framemd5", "-",
+ ]);
+ return out.split("\n").filter((l) => l && !l.startsWith("#")).map((l) => l.split(",").at(-1).trim());
+ };
+ const base = frames([deck]);
+ const laid = frames([deck, w1, w2]);
+ assert.equal(base.length, 165);
+ assert.equal(laid.length, 165, "the windows did not change the cut's length");
+ const differ = laid.map((h, i) => (h === base[i] ? null : i)).filter((i) => i !== null);
+ assert.deepEqual(differ, [...Array.from({ length: 15 }, (_, i) => 60 + i), ...Array.from({ length: 9 }, (_, i) => 156 + i)]);
+ // A preview's clock: the window starting 0.2 s before it.
+ const early = frames([deck, { ...w1, offset: -0.2 }]);
+ assert.deepEqual(early.map((h, i) => (h === base[i] ? null : i)).filter((i) => i !== null), [0, 1, 2, 3, 4, 5, 6, 7, 8]);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+// ---------------------------------------------------------------------------
+// compose-chrome's posts path, with a stub renderer.
+// ---------------------------------------------------------------------------
+
+const haveTools = have("qrencode", ["-V"]) && have("magick", ["-version"]);
+
+test("composeChrome(posts): project, frames, cache, preview, and the CLI's --segment", { skip: !haveTools }, async () => {
+ const { composeChrome } = await import("./compose-chrome.mjs");
+ const dir = mkdtempSync(path.join(tmpdir(), "posts-p1-"));
+ const prevBin = process.env.HYPERFRAMES_BIN;
+ try {
+ const stub = path.join(dir, "hf-stub.mjs");
+ writeFileSync(stub, `#!/usr/bin/env node
+import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+const a = process.argv.slice(2);
+const out = a[a.indexOf("--output") + 1], fps = Number(a[a.indexOf("--fps") + 1]), proj = a[a.length - 1];
+const dur = Number(/data-duration="([\\d.]+)"/.exec(readFileSync(path.join(proj, "index.html"), "utf8"))[1]);
+mkdirSync(out, { recursive: true });
+for (let i = 1; i <= Math.round(dur * fps); i += 1) writeFileSync(path.join(out, "frame_" + String(i).padStart(6, "0") + ".png"), "");
+appendFileSync(${JSON.stringify(path.join(dir, "runs.log"))}, a.join(" ") + "\\n");
+`);
+ chmodSync(stub, 0o755);
+ process.env.HYPERFRAMES_BIN = stub;
+ const manifest = {
+ slug: "t", title: "t", provenance: {},
+ render: {
+ ...RENDER,
+ fontRegular: path.join(HERE, "fonts", "IBMPlexMono-Regular.ttf"),
+ fontBold: path.join(HERE, "fonts", "IBMPlexMono-Bold.ttf"),
+ },
+ timeline: [],
+ };
+ const manifestPath = path.join(dir, "video.manifest.json");
+ writeFileSync(manifestPath, JSON.stringify(manifest));
+ const sched = schedule();
+ const window = postWindows(sched).find((w) => w.segment === "c2");
+ const snapped = snapWindow(window, { fps: 30, total: sched.total });
+ const runs = () => { try { return readFileSync(path.join(dir, "runs.log"), "utf8").trim().split("\n").length; } catch { return 0; } };
+ const chrome = path.join(dir, "out", "sourced", "chrome");
+
+ const first = await composeChrome({ manifestPath, region: "posts", schedule: sched, window, doRender: true });
+ assert.equal(first.cached, false);
+ assert.equal(first.projDir, path.join(chrome, "posts-c2"));
+ assert.equal(first.frames, path.join(chrome, "posts-c2-frames"));
+ assert.equal(first.frameCount, snapped.frames);
+ assert.deepEqual(first.window, snapped);
+ assert.equal(readFileSync(path.join(first.frames, ".key"), "utf8").trim(), first.key);
+ const html = readFileSync(path.join(first.projDir, "index.html"), "utf8");
+ assert.match(html, /src="assets\/qr00\.png"/);
+ assert.match(html, /src="assets\/qr01\.png"/);
+ assert.match(html, /url\('assets\/DeckSansBold\.ttf'\)/);
+ assert.match(readFileSync(path.join(dir, "runs.log"), "utf8"), /--no-browser-gpu --output/);
+
+ const again = await composeChrome({ manifestPath, region: "posts", schedule: sched, segment: "c2", doRender: true });
+ assert.equal(again.cached, true, "the same window by --segment, the same key");
+ assert.equal(again.key, first.key);
+ assert.equal(runs(), 1);
+
+ // A changed post is a new key.
+ const edited = { ...sched, posts: sched.posts.map((p) => (p.id === "a" ? { ...p, text: "edited" } : p)) };
+ const third = await composeChrome({ manifestPath, region: "posts", schedule: edited, window, doRender: true });
+ assert.equal(third.cached, false);
+ assert.equal(runs(), 2);
+
+ // A preview composes beside it and never renders.
+ const prev = await composeChrome({ manifestPath, region: "posts", schedule: sched, window, preview: true, doRender: true });
+ assert.equal(prev.projDir, path.join(chrome, "posts-preview-c2"));
+ assert.equal(prev.frames, null);
+ assert.equal(runs(), 2);
+
+ await assert.rejects(composeChrome({ manifestPath, region: "posts", schedule: sched, segment: "c1" }), /no post rides on c1/);
+ } finally {
+ if (prevBin === undefined) delete process.env.HYPERFRAMES_BIN;
+ else process.env.HYPERFRAMES_BIN = prevBin;
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs
@@ -38,8 +38,11 @@ import path from "node:path";
import { ledgerTotals, dateKey } from "./ledger-totals.mjs";
import { selectVariant } from "./build-video.mjs";
-import { chromeCacheKey, deckLayout, frameCount, hyperframesCommand, sha256 } from "./deck.mjs";
+import {
+ chromeCacheKey, deckLayout, frameCount, hyperframesCommand, postWindows, resolveDeck, sha256,
+} from "./deck.mjs";
import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs";
+import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs";
const run = promisify(execFile);
@@ -610,7 +613,7 @@ async function copyFonts(render, assetsDir, names, { strict }) {
* `projDir/assets`. The chart's branch is the band as it shipped; only where
* its GSAP comes from has changed.
*/
-async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule, duration, from }) {
+async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule, duration, from, window }) {
if (region === "chart") {
const sched = schedule ?? JSON.parse(await readFile(path.join(base, "schedule.json"), "utf8"));
const fonts = await copyFonts(manifest.render, assetsDir, { regular: "regular", bold: "bold" }, { strict: false });
@@ -628,6 +631,17 @@ async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule
}
return deckHtml(schedule, render, { fonts, qrSrcs, from, duration });
}
+ if (region === "posts") {
+ // The deck's faces and the deck's QR maker: a card is part of the deck's
+ // family, not a second design.
+ const render = manifest.render;
+ const fonts = await copyFonts(render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true });
+ const posts = windowPosts(schedule, window.segment);
+ const byUrl = await qrPngsFor(posts.map((p) => p.qrUrl), render, resolveDeck(render).posts.qrSize, assetsDir);
+ const qrSrcs = {};
+ for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl);
+ return postsHtml(schedule, render, window, { fonts, qrSrcs });
+ }
throw new Error(`unknown chrome region: ${region}`);
}
@@ -680,16 +694,26 @@ function runRenderer(cmd, args) {
* - the render is SKIPPED when `.key` equals this compose's `chromeCacheKey`
* and the frame count on disk is `frameCount(duration, fps)`.
*
+ * Posts (`region: "posts"`), one WINDOW at a time -- a `postWindows` entry
+ * (`window: {segment, from, to}`), or `segment` alone to look it up:
+ * - the window is snapped outward to the frame grid (`snapWindow`); frame 1 is
+ * cut time `window.from` of the result, and it is `frames` long;
+ * - project `chrome/posts-<segment>/` (`posts-preview-<segment>/` when
+ * `preview`, never rendered), frames `chrome/posts-<segment>-frames/` and
+ * their `.key`, cached exactly as the deck's are;
+ * - `still` is in CUT seconds.
+ *
* `schedule` (an object) overrides reading `out/<variant>/schedule.json`.
*
* @returns {Promise<{ projDir: string, frames: string|null, still: string|null,
- * cached: boolean, key: string|null, frameCount: number|null }>}
+ * cached: boolean, key: string|null, frameCount: number|null,
+ * window?: { segment: string, from: number, to: number, f0: number, frames: number } }>}
*/
export async function composeChrome({
manifestPath, outDir = null, variant = "sourced", region = "chart",
schedule = null, preview = false, doRender = false,
fps = null, workers = null, quality = "high", format = "png-sequence",
- still = null, png = null, from = 0, duration = null,
+ still = null, png = null, from = 0, duration = null, window = null, segment = null,
}) {
// The variant's view, and its own out directory. Handed the whole manifest
// the band would draw claims this cut never makes, and the deck would name
@@ -699,8 +723,10 @@ export async function composeChrome({
const base = path.resolve(outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out", variant));
from = Number(from ?? 0);
+ // The two regions drawn from the deck's schedule, and keyed by the render cache.
+ const keyed = region === "deck" || region === "posts";
let sched = schedule;
- if (region === "deck" && !sched) {
+ if (keyed && !sched) {
const p = path.join(base, "schedule.json");
try {
sched = JSON.parse(await readFile(p, "utf8"));
@@ -709,33 +735,49 @@ export async function composeChrome({
}
if (sched.kind !== "deck") throw new Error(`${p} is not a deck schedule (kind ${sched.kind ?? "missing"})`);
}
- const total = region === "deck" ? sched.total : null;
+ const total = keyed ? sched.total : null;
+ const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30);
const windowed =
region === "deck" && (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6));
const suffix = windowed ? `-from${fmtSeconds(from)}` : "";
- const projName = region === "deck" && preview ? "deck-preview" : `${region}${suffix}`;
+ // A posts window: the one asked for, or the segment's from the schedule.
+ let win = null;
+ if (region === "posts") {
+ const want = window ?? postWindows(sched).find((w) => w.segment === segment);
+ if (!want) {
+ throw new Error(
+ segment ? `no post rides on ${segment} in this schedule` : "the posts region needs a window (or a segment)",
+ );
+ }
+ if (!/^[A-Za-z0-9_-]+$/.test(String(want.segment))) throw new Error(`posts: ${want.segment} is not a segment id`);
+ win = snapWindow(want, { fps: rate, total });
+ }
+
+ const projName =
+ region === "posts"
+ ? `posts-${preview ? "preview-" : ""}${win.segment}`
+ : region === "deck" && preview ? "deck-preview" : `${region}${suffix}`;
const projDir = path.join(base, "chrome", projName);
const assetsDir = path.join(projDir, "assets");
// The deck's assets are rebuilt every time: a QR from a clip that has since
// left the cut must not sit in the directory the cache key hashes.
- if (region === "deck") await rm(assetsDir, { recursive: true, force: true });
+ if (keyed) await rm(assetsDir, { recursive: true, force: true });
await mkdir(assetsDir, { recursive: true });
await copyFile(GSAP_FILE, path.join(assetsDir, "gsap.min.js"));
const html = await regionHtml(region, {
manifest, base, projDir, assetsDir, schedule: sched,
- duration: duration != null ? Number(duration) : null, from,
+ duration: duration != null ? Number(duration) : null, from, window: win,
});
await writeFile(path.join(projDir, "index.html"), html, "utf8");
await writeFile(path.join(projDir, "hyperframes.json"), HF_JSON + "\n", "utf8");
const size = compositionSize(html);
- const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30);
const hf = hyperframesCommand(process.env);
let key = null;
let frames = null;
- if (region === "deck") {
+ if (keyed) {
frames = frameCount(size.duration, rate);
const assets = [];
for (const name of (await readdir(assetsDir)).sort()) {
@@ -743,7 +785,7 @@ export async function composeChrome({
}
key = chromeCacheKey({ html, assets, fps: rate, frames, version: hf.version });
}
- const result = { projDir, frames: null, still: null, cached: false, key, frameCount: frames };
+ const result = { projDir, frames: null, still: null, cached: false, key, frameCount: frames, ...(win ? { window: win } : {}) };
// The review still: seek and screenshot, no HyperFrames, ~2 s. It is the SAME
// seek the renderer performs for every frame, which is why a still that is
@@ -763,16 +805,17 @@ export async function composeChrome({
return { ...result, still: out };
}
- if (!doRender || (region === "deck" && preview)) return result;
+ if (!doRender || (keyed && preview)) return result;
// A sequence is a directory; every other format is a file.
const sequence = format === "png-sequence";
+ const stem = region === "posts" ? `posts-${win.segment}` : `${region}${suffix}`;
const target = sequence
- ? path.join(base, "chrome", `${region}${suffix}-frames`)
- : path.join(base, "chrome", `${region}${suffix}.${format}`);
+ ? path.join(base, "chrome", `${stem}-frames`)
+ : path.join(base, "chrome", `${stem}.${format}`);
const keyFile = path.join(target, ".key");
- if (region === "deck" && sequence) {
+ if (keyed && sequence) {
const onDisk = await readFile(keyFile, "utf8").then((s) => s.trim(), () => null);
if (onDisk === key && (await framesOnDisk(target)) === frames) {
return { ...result, frames: target, cached: true };
@@ -788,15 +831,15 @@ export async function composeChrome({
...(workers != null ? ["-w", String(workers)] : []),
// The deck is flat colour and text: software GL is deterministic and the
// GPU probe is a second per worker for nothing.
- ...(region === "deck" ? ["--no-browser-gpu"] : []),
+ ...(keyed ? ["--no-browser-gpu"] : []),
"--output", target, projDir,
];
await runRenderer(hf.cmd, args);
- if (region === "deck" && sequence) {
+ if (keyed && sequence) {
const got = await framesOnDisk(target);
if (got !== frames) {
- throw new Error(`the deck render wrote ${got} frames to ${target}, expected ${frames} (${size.duration}s at ${rate} fps)`);
+ throw new Error(`the ${region} render wrote ${got} frames to ${target}, expected ${frames} (${size.duration}s at ${rate} fps)`);
}
await writeFile(keyFile, key + "\n", "utf8");
}
@@ -807,13 +850,14 @@ if (import.meta.url === `file://${process.argv[1]}`) {
const argv = process.argv.slice(2);
const flag = (n) => { const i = argv.indexOf(n); return i < 0 ? null : argv[i + 1]; };
const VALUED = new Set([
- "--out", "--region", "--duration", "--variant", "--from",
+ "--out", "--region", "--duration", "--variant", "--from", "--segment",
"--still", "--png", "--workers", "--quality", "--format", "--fps",
]);
const manifestPath = argv.find((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1]));
if (!manifestPath) {
console.error(
- "usage: compose-chrome.mjs <manifest.json> [--region chart|deck] [--variant sourced|full]\n" +
+ "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|posts] [--variant sourced|full]\n" +
+ " [--segment <id>] (posts: the clip whose window to compose)\n" +
" [--from <s>] [--duration <s>] [--out <dir>] [--preview]\n" +
" [--still <s> --png <path>]\n" +
" [--render] [--workers 4] [--quality high] [--format png-sequence] [--fps 30]",
@@ -831,10 +875,11 @@ if (import.meta.url === `file://${process.argv[1]}`) {
preview: argv.includes("--preview"),
duration: num("--duration"),
from: num("--from") ?? 0,
+ segment: flag("--segment"),
still: num("--still"),
png: flag("--png"),
// The deck renders four-wide by default; the band keeps the renderer's own default.
- workers: num("--workers") ?? (region === "deck" ? 4 : null),
+ workers: num("--workers") ?? (region === "deck" ? 4 : region === "posts" ? 2 : null),
quality: flag("--quality") ?? "high",
format: flag("--format") ?? "png-sequence",
fps: num("--fps"),
diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs
@@ -18,7 +18,7 @@ import { promisify } from "node:util";
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
-import { selectVariant, variantPaths } from "./build-video.mjs";
+import { postsRegions, selectVariant, variantPaths } from "./build-video.mjs";
import { deckOn, frameCount } from "./deck.mjs";
const execFileP = promisify(execFile);
@@ -96,7 +96,8 @@ export async function verifyBuild(manifestPath, { outDir, variant = "sourced" }
/**
* The deck's half of the check: schedule.json is there and is a measured deck
* schedule, `chrome/deck-frames` holds frameCount(total, fps) frames, and the
- * file is as long as the schedule.
+ * file is as long as the schedule. When the schedule carries posts, each
+ * window's `chrome/posts-<segment>-frames` holds that window's frame count.
*/
export async function verifyDeck(variantDir, render, file, problems) {
const schedPath = path.join(variantDir, "schedule.json");
@@ -109,10 +110,11 @@ export async function verifyDeck(variantDir, render, file, problems) {
const fps = Number(schedule.fps ?? render.fps);
const want = frameCount(schedule.total, fps);
const framesDir = path.join(variantDir, "chrome", "deck-frames");
- const frames = await readdir(framesDir).then(
+ const countFrames = (dir) => readdir(dir).then(
(fs) => fs.filter((f) => /^frame_\d+\.png$/.test(f)).length,
() => 0,
);
+ const frames = await countFrames(framesDir);
if (frames === 0) {
problems.push(`the deck is on but ${framesDir} has no frames — built with --no-chrome?`);
} else if (frames !== want) {
@@ -128,7 +130,19 @@ export async function verifyDeck(variantDir, render, file, problems) {
if (!(Math.abs(videoFrames - schedule.total * fps) <= 1.5)) {
problems.push(`the picture is ${videoFrames} frames for a ${schedule.total.toFixed(3)}s schedule (${want} frames)`);
}
- return { total: schedule.total, frames, expectedFrames: want, videoFrames, segments: schedule.segments.length };
+ // The posts windows: each laid at its own second, each as long as snapWindow says.
+ const posts = [];
+ for (const r of postsRegions(render, variantDir, schedule)) {
+ const got = await countFrames(r.frames);
+ posts.push({ segment: r.segment, frames: got, expectedFrames: r.frameCount, at: r.offset });
+ if (got !== r.frameCount) {
+ problems.push(`${r.frames} holds ${got} frames; the posts window on ${r.segment} is ${r.frameCount}`);
+ }
+ }
+ return {
+ total: schedule.total, frames, expectedFrames: want, videoFrames, segments: schedule.segments.length,
+ ...(posts.length ? { posts } : {}),
+ };
}
async function main() {
@@ -153,6 +167,9 @@ async function main() {
);
if (res.deck) {
console.log(` deck: ${res.deck.frames}/${res.deck.expectedFrames} frame(s) over ${res.deck.segments} segment(s), ${res.deck.total}s`);
+ for (const w of res.deck.posts ?? []) {
+ console.log(` posts on ${w.segment}: ${w.frames}/${w.expectedFrames} frame(s) at ${w.at.toFixed(3)}s`);
+ }
}
for (const p of res.problems) console.log(` ** ${p}`);
if (res.ok) console.log(" ok");