Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 7f4857a91e889d79e7a0f3f4a90537b0b33f0ab1
parent 28f93a41529d5335fa42f3b97ee9ce285c4e7d35
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 01:12:16 -0400

Merge deck/posts-p1 (posts slice P1) — chrome-posts.mjs (cards: date, handle, words clamped, QR; stacked top-down, oldest slide out on overflow, leave in the transition), composeChrome region posts per window with its own cache, snapWindow in deck.mjs, the build passing posts to the schedule and overlaying each window at its from (crossfade, applyChrome, --chrome-only, --chrome-preview), verify-build window checks; ferret scratch QR 7/7; reviewed by contact sheet

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 1+
Mumtool/docs/quirks.md | 40++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/README.md | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/report-to-video/build-video.mjs | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Aumtool/report-to-video/chrome-posts.mjs | 399+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/chrome-posts.test.mjs | 422+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/compose-chrome.mjs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mumtool/report-to-video/deck.mjs | 16++++++++++++++++
Mumtool/report-to-video/verify-build.mjs | 25+++++++++++++++++++++----
9 files changed, 1144 insertions(+), 33 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] - **umtool's report videos can wear an on-screen deck: one panel under the footage for the whole cut, with a pip timeline, a title per clip, its source and date, and its QR.** A report manifest whose `render` says `"chrome": { "engine": "hyperframes", "layout": "deck" }` scales the footage into a box above a 190 px panel (both sizes are settings) and draws, over the whole cut, one unlabelled pip per clip on a track that fills as the cut plays, the clip's own title from `onscreen.title`, a subtitle naming the recording and its date (the channel too when the cut spans more than one; `onscreen.subtitle` replaces it), and the clip's QR. At each clip change the marker travels to the next pip and the title, subtitle and QR hand over; over a card the panel slides away and comes back after. The citation header, the corner QR and the section footer are not drawn on such a cut, and chapters take the clip's on-screen title. Every setting (sizes, spacing, date format, what the subtitle names, whether cards keep the panel, the motion's timings) is in `render.chrome.deck` and checked when it is saved; an unknown or out-of-range one is refused with a sentence saying why. The panel is rendered once per cut by HyperFrames (pinned to 0.8.24; `HYPERFRAMES_PKG` or `HYPERFRAMES_BIN` override it) and reused until its text or settings change. `build-video.mjs --chrome-only` redraws it over the built segments without rebuilding or fetching anything, `--no-chrome` builds the framed cut without it, and `--chrome-preview <at> <dur>` renders a short window. In umtool, the report page has an **On-screen** section — a switch, the settings, a table of every entry's title and subtitle with the automatic subtitle as its placeholder and a character counter, a live preview with a scrubber, a true still, **Re-render on-screen** and the built video — and the clip bench has on-screen title and subtitle fields with the panel previewed over the clip. A manifest without `render.chrome` builds exactly as before, byte for byte. +- **A report cut that wears the on-screen deck can show posts — Bluesky or X statements — as cards over the footage.** A report manifest's `posts` list (each with its platform, handle, date, words and link) is drawn near the end of the clip each post belongs with: the clip whose recording most closely precedes it by date, unless the post names one with `attachTo`; `hide` leaves one out. A clip's posts appear two seconds apart, stack down a column at the footage's top right, and leave together in the change to the next clip; when the column is full the oldest slide up and out. Each card shows the post's date, `@handle · Bluesky` (or X), its words in paragraphs up to seven lines with an ellipsis, and a QR of the post's link, in the deck's colours and faces. The timing, the column's side, width and inset, the QR size and the line limit are settings under `render.chrome.deck.posts`, and a bad post or setting is refused with a sentence before a build fetches anything. Only the seconds the cards are up are rendered, one short sequence per clip, cached like the deck; `--chrome-only`, `--chrome-preview` and a hard-cut cut lay them as they lay the deck, and `--no-chrome` draws neither. A cut without `posts` builds exactly as before, and without the deck `posts` is not drawn at all. - **A report cut with `transition: 0` builds when its output folder was given as a relative path.** The hard-cut concat listed its segments relative to the working directory, and ffmpeg reads that list relative to the list file's own folder, so every hard-cut build with a relative `--out` failed at the concat. The list now names each segment by its full path. - **`archilyzer doctor` checks the image Build all builds sites in.** When a container engine answers, a new **build image** section says whether the image named under **Settings → Build pipeline** is there, when it was built and how big it is. It warns when the image is missing, or older than the last change to its Dockerfile, and prints the one command that rebuilds it. Build all still builds or refreshes the image itself before it builds any site; the warning tells you ahead of time that the next Build all will spend that time. With no container engine the check is skipped in one line, and with no corpus it is only a note. It never fails the doctor. - **The site build image runs Node 22 and pnpm 11**, the versions the rest of the workspace runs on, instead of Node 20 and pnpm 9, which did not read the workspace's install rules. The next Build all rebuilds the image from its first step, reinstalling every dependency, before it builds any site. diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md @@ -202,6 +202,46 @@ fonts in as private families to dodge the `local()` trap above, but a missing silently to the browser default, because the deck has no other on-screen text to notice a wrong metric by. +**A short sequence laid partway through the cut takes `eof_action=pass`, +never `shortest=1` — the posts windows.** `shortest=1` ends the overlay's +OUTPUT when its shorter input ends, so a five-second window would end the +whole cut there. `-itsoffset <s>` on the window's input puts its frame 1 at +that second; before it the overlay has no secondary frame and passes the main +through, and after its last frame `eof_action=pass` does the same. Measured +with framemd5: every frame outside the window is bit-identical to the +deck-only frame, the length is unchanged, a window running past the cut's end +does not lengthen it, and a negative `-itsoffset` (a `--chrome-preview` that +starts after the window does) works. A window's sequence mixes RGB and RGBA +frames like the deck's, so it takes the same `-reinit_filter 0` + +`format=rgba`. `chrome-posts.test.mjs` runs ffmpeg to keep all of this true. + +**`-webkit-line-clamp` over text with blank lines can put the ellipsis on an +empty line.** A post's paragraphs are separated by blank lines; clamped as one +`pre-line` block, a clamp that lands on the blank line draws a lone "…" under +the last words. Each paragraph is its own block, a part-line apart, clamped to +what is left of `maxLines` once the faces are in; a blank line costs nothing, +and a dropped paragraph puts the ellipsis on the last one drawn. + +**A layout that depends on measured text is planned after the faces load, and +the timeline registered at the END of that callback.** The posts stack needs +each card's height, which is how its words wrap in the deck's face. The page +builds its timeline inside the fonts-loaded callback and only then assigns +`window.__timelines["posts"]` (and calls `__hfForceTimelineRebind` when the +runtime has it): HyperFrames' own lint calls registering an empty timeline +first and filling it later an error (`gsap_timeline_registered_before_async_build`). +The renderer awaits `document.fonts.ready` before its first seek, so every +frame sees the built timeline. + +**`build-video.mjs` must not import a chrome PAGE module statically.** +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 +tests run in plain Node and pass either way. + ## Rail strips and rolling counters **A slab that slides moves text that did not change.** The tally used to be four diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -222,7 +222,9 @@ whatever a manifest omits, one level deep: "subtitle": { "parts": "auto", "dateFormat": "long" }, // parts "auto" | a distinct list of channel/title/date/clock; dateFormat "long" | "iso" "qr": { "show": true, "size": 150 }, // size 80–380, and at most height − 20 "overCards": "hide", // "hide" | "show" - "motion": { "out": 0.3, "in": 0.45, "pip": 0.7 } // seconds; out/in 0–2, pip 0–3 + "motion": { "out": 0.3, "in": 0.45, "pip": 0.7 }, // seconds; out/in 0–2, pip 0–3 + "posts": { "show": true, "seconds": 2, "position": "top-right", // the manifest's `posts` (below); position | "top-left" + "width": 600, "qrSize": 120, "maxLines": 7, "inset": 24 } // seconds 0.5–10; width 320–900 px; qrSize 80–200 and ≤ width/2; maxLines 2–14; inset 0–80 } } } @@ -253,6 +255,48 @@ card's `sub`. The QR follows a clip's corner-QR rule unchanged (`citeUrl`, else the site link at the clip's start); an image draws one only with an explicit `citeUrl`; a card never does. +### `posts` — written statements on the deck + +A cut that wears the deck can carry a post — a Bluesky or X statement — as a +card over the footage, near the end of the clip it belongs with +(`plans/deck-posts.md` has the rulings). A post is not a segment: it has no +footage, so it rides on a clip. + +```jsonc +"posts": [ + { "id": "bs-3msydljwjis2a", "platform": "bluesky", // "bluesky" | "x" + "author": "Pirate Software", "handle": "piratesoftware.live", + "date": "2026-08-13T19:05:26.424Z", // ISO date or date-time + "text": "We just signed off on 51 page document …", // newlines kept; ≤ 3000 characters + "url": "https://bsky.app/profile/piratesoftware.live/post/3msydljwjis2a", // the QR + "attachTo": null, // a clip id, to override the date rule + "hide": false } ] +``` + +- **Which clip.** The one whose recording most closely PRECEDES the post: the + latest day on or before the post's (a clip's day is its own `date`, else its + record's upload date — the build reads the real one), ties to the later clip in + the cut; a post older than every clip goes on the first. `attachTo` overrides; + `hide: true` leaves a post out. A variant that drops the named clip falls back + to the date rule. +- **When.** A clip's posts, oldest first, stack: post j of k appears at + A − seconds·(k − j), where A is the start of the outgoing transition (the next + segment's start under a crossfade, 0.3 s before a hard cut, 0.3 s before the end + of the cut). Each has `seconds` alone before the next stacks on, and the last + has the clip's final `seconds`; a clip too short for that shares what it has + after its incoming dissolve. They all leave together over the transition. +- **What.** The post's date (as the deck writes dates; a date-time is drawn as + its day), `@handle · Bluesky` (or X), the words — paragraphs kept, clamped to + `maxLines` with an ellipsis — and a QR of the post's own `url`. +- **Where.** A column inside the footage box, `inset` from its top and from the + `position` side, `width` wide. Cards stack top-down; when the next would + overflow the column, the oldest slide up and out. + +`validatePosts()` (`deck.mjs`) is the one validator, unknown keys refused; a +deck build refuses a bad `posts` before a single fetch. Posts are drawn only +under the deck: without `render.chrome` they are data for the report, and +nothing in a build reads them. + ### The `image` entry type A still: the receipts a clip cannot say out loud — a post, a thread, a DM, a @@ -942,6 +986,50 @@ ported from the diet fork) lays the panel on afterwards, because the concat demuxer's stream copy cannot host a filtergraph. The legacy chart band still refuses a hard-cut transition outright. +### Posts on the deck + +When the schedule carries `posts` (`deckSchedule` adds the key only when there +are some, so a cut without them writes the schedule it always did), the build +renders one short sequence per clip that carries posts — a WINDOW +(`postWindows`), from that clip's first card appearing to the end of its +leave — and lays each over the cut at its own second, after the deck's own +region. `chrome-posts.mjs` is the page (pure, like `chrome-deck.mjs`). + +``` +node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts --segment <clip id> + [--still <cut s> --png <path>] [--render] [--preview] +``` + +- **The window** is snapped outward to the frame grid (`snapWindow`): frame 1 + lands exactly on cut frame `f0`, and the sequence is `frameCount(to − from, + fps)` of the snapped window, never past the cut's end. +- **Files:** project `chrome/posts-<segment>/`, frames + `chrome/posts-<segment>-frames/` with their own `.key` (html, assets, fps, + frames, renderer version — the deck's cache, per window); `--preview` + composes `chrome/posts-preview-<segment>/` and never renders. +- **The page** is region-local (`postsGeometry`, 600×838 at (1123, 26) by + default), transparent outside the cards. `?still=<t>` and the preview's + `deck:seek` take CUT seconds; a preview page answers with + `{type: "posts:ready", segment, from, to, ids}`. +- **The stack is planned in the page.** Whether the next card overflows the + column depends on how its words wrap in the deck's face, so the page + measures the cards once the fonts are in and hands the heights to + `postsCues` — the module's own function, carried in by its source text, so + the tests run the exact plan the page runs. The timeline is built and + registered in that callback; the renderer awaits `document.fonts.ready` + before its first seek. +- **The overlay:** each window is an input with `-itsoffset <from>` (negative + in a `--chrome-preview` that starts after the window does), the deck's + `-reinit_filter 0` + `format=rgba`, and + `overlay=…:format=yuv444:eof_action=pass` — **not** `shortest=1`, which would + end the whole cut where the window ends. Outside its window every frame is + the deck-only frame, bit for bit, and the length is unchanged (a unit test + runs ffmpeg to say so). The crossfade concat, the hard-cut `applyChrome` + pass, `--chrome-only` and `--chrome-preview` (only the windows it touches) + all lay them; `--no-chrome` lays neither. +- **`verify-build`** also checks each window's `chrome/posts-<segment>-frames` + holds that window's frame count. + ### Two ffmpeg traps that are the deck's alone - **Mixed RGB/RGBA frames restart the whole filtergraph.** HyperFrames writes diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -80,7 +80,8 @@ 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, snapWindow, validatePosts, } from "./deck.mjs"; // The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, // in common, plain JS so bare `node` can load it. @@ -296,6 +297,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 +1726,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 +1749,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 +1765,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 +2321,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 +2348,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 +2488,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 +2593,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,399 @@ +// 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 { postsGeometry, resolveDeck, snapWindow } from "./deck.mjs"; +import { mix, rgba } from "./chrome-deck.mjs"; + +// The window arithmetic is deck.mjs's (pure, and loaded by the build without +// this page module); re-exported for the page's own callers. +export { snapWindow }; + +const esc = (s) => + String(s ?? "") + .replace(/&/g, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/"/g, "&quot;") + .replace(/'/g, "&#39;"); + +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 }); + +/** 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 front-loaded 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; + // Front-loaded: mostly gone by the mid-dissolve, where the deck hands over. + add(`c${j}`, a, b - a, { autoAlpha: 0, scale: 0.97 }, "power2.out", `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("&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;\nsecond line &amp; &#39;quotes&#39;")); + assert.ok(html.includes("@&quot;&gt;&lt;img src=x onerror=alert(1)&gt;")); + 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&quot;&lt;x&gt;"')); + 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/deck.mjs b/umtool/report-to-video/deck.mjs @@ -736,6 +736,22 @@ export function postWindows(schedule) { } /** + * 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) }; +} + +/** * Where the posts region sits in the frame: a column inside the footage box, * `inset` from its top and from the chosen side, `width` wide and the box's * height less the insets. Cards stack top-down inside it. 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");