commit 27f7d3a7494943e086d1cd9eafd9dff0b03e70a9
parent 7fa7dc210251853179f484cd61fbac160f67d7f8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 01:39:40 -0400
Merge deck/posts — posts on the report-video on-screen deck (umtool; plans/deck-posts.md); reviewed SHIP by its own session's read-only review, merged by the session holding main
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
26 files changed, 3196 insertions(+), 73 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/plans/deck-posts.md b/plans/deck-posts.md
@@ -0,0 +1,127 @@
+# Posts on the on-screen deck
+
+A report cut that wears the deck (`plans/onscreen-deck.md`) can carry written statements — a
+Bluesky or X post — as cards over the footage. A post is not a segment: it rides on a clip,
+appears near the end of it, and leaves in the transition to the next segment.
+
+## Rulings
+
+- **Attachment.** A post rides on the clip whose recording most closely PRECEDES it: the clip
+ with the latest day on or before the post's day (a clip's day is its own `date`, else its
+ record's upload date); ties go to the later clip in the cut; a post older than every clip goes
+ on the first. `attachTo: "<clip id>"` overrides. `hide: true` leaves a post out.
+- **Timing.** 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` (default 2) alone
+ before the next stacks on; the last has the clip's final `seconds`. A clip too short for that
+ shares what it has after its incoming dissolve evenly. All of them leave together over the
+ transition.
+- **Card.** The post's date, its words (clamped to `maxLines`, ellipsis), the author's handle and
+ platform, and a QR of the post's own permalink (`url`). The deck's palette and fonts.
+- **Where.** A column inside the footage box, `inset` from its top and from the chosen side
+ (`position`, default top-right), `width` wide. Cards stack top-down; when a new card would
+ overflow the column, the oldest slide up and out.
+
+## Manifest
+
+```jsonc
+"posts": [
+ { "id": "bs-3msydljwjis2a", "platform": "bluesky", "author": "Pirate Software",
+ "handle": "piratesoftware.live", "date": "2026-08-13T19:05:26.424Z",
+ "text": "We just signed off on 51 page document …",
+ "url": "https://bsky.app/profile/piratesoftware.live/post/3msydljwjis2a",
+ "attachTo": null, "hide": false } ],
+"render": { "chrome": { "engine": "hyperframes", "layout": "deck",
+ "deck": { "posts": { "show": true, "seconds": 2, "position": "top-right", "width": 600,
+ "qrSize": 120, "maxLines": 7, "inset": 24 } } } }
+```
+
+`validatePosts(posts, timeline)` and the `deck.posts` branch of `validateChrome` are the one
+validator (unknown keys refused). Posts are drawn only under the deck; without it they are data
+for the report and nothing in a build reads them.
+
+## Core (in `umtool/report-to-video/deck.mjs`, pure)
+
+| Export | → |
+|---|---|
+| `validatePosts(posts, timeline)` | sentences |
+| `clipDay(entry, meta)` | `YYYY-MM-DD` or null |
+| `attachPosts({posts, entries, metas})` | `[{id, entryId, rule: "attachTo"\|"date"\|"first", clipDay}]` |
+| `postSchedule({posts, entries, metas, segments, D, total, render})` | `[{id, segment, slot, of, appear, out:[a,b], date, text, author, handle, platform, url, qrUrl}]` |
+| `postWindows(schedule)` | `[{segment, from, to}]` — one render window per carrying clip |
+| `postsGeometry(render)` | `{x, y, width, height}` in the frame |
+| `deckSchedule({…, posts})` / `estimateSchedule(manifest)` | the schedule gains `posts` (only when there are some) |
+
+## Slices
+
+| Slice | What | Owns |
+|---|---|---|
+| P0 | This file; the core and its tests | `deck.mjs`, `deck.test.mjs` |
+| P1 | The posts region and its overlay: `chrome-posts.mjs` (card HTML, stack/slide choreography, `?still`, `?preview=1`), `composeChrome({region:"posts", window})` per `postWindows` entry with its own cache, the build passing `manifest.posts` to `writeChromeSchedule` and `validatePosts` at build start, the overlay of each window at its `from` in the crossfade concat, `applyChrome`, `--chrome-only` and `--chrome-preview`; verify-build; docs | `report-to-video/*` |
+| P2 | umtool: `updatePosts(dir, {[id]: {attachTo?, hide?}}, {token})`, the On-screen section lists posts with their automatic clip and an override + hide, the preview shows the posts region in its windows; e2e | `umtool/lib`, `umtool/app/api/report`, `umtool/components`, `umtool/e2e` |
+| P3 | First application: the ferret report's posts (report section + manifest) and a rebuild | outside the repo |
+
+## As shipped
+
+Branch `deck/posts` from `main` 2cf43a69; slices merged `--no-ff` after review.
+
+| Commit | What |
+|---|---|
+| de072804 | P0 — this file; `deck.posts` settings, `validatePosts`, `clipDay`, `attachPosts`, `postSchedule`, `postWindows`, `postsGeometry`; `deckSchedule` carries `posts` only when there are some |
+| fd577d96 | P2 — umtool: `updatePosts` (attachTo/hide only), posts rows on `GET /api/report/onscreen` (auto and effective clip, timing), `PUT /api/report/posts`, the preview composing one posts window at a time and the files route serving them, the Posts table, the `deck.posts` settings group (saving settings no longer drops it), `onscreen-posts.spec.ts` |
+| a598a386 | P1 — `chrome-posts.mjs` (the cards; the stack planned in the page from measured heights by the same `postsCues` the tests run), `composeChrome` region `posts` per window with its own cache, `snapWindow` in `deck.mjs`, the overlay of every window at its `from` in the crossfade concat, `applyChrome`, `--chrome-only` and `--chrome-preview`, verify-build window checks, README, quirks, one `[Unreleased]` bullet |
+| f9625df7 | The posts preview e2e asserts real cards (the branch for a missing region is gone) |
+
+### As built, where it differs from the rulings above
+
+- **Windows are snapped outward to the frame grid** (`snapWindow`, in `deck.mjs`): frame i of a
+ window is cut frame f0 + i exactly. It lives in `deck.mjs`, not `chrome-posts.mjs`, because the
+ build importing the page module pulled the composition's asset URL into umtool's bundle and
+ failed its `next build` while collecting page data — a failure no unit test could see.
+- **The stack is planned in the page,** after the fonts load: whether a card overflows the column
+ depends on measured heights. The planner's source is embedded in the page under a fixed name
+ (`embedFn`: `const postsCues = (<source>)`), so the page and the tests run one implementation
+ even after umtool's production build renames the module function (the review's F1: the bare
+ `toString()` declared the minified name and the page threw, drawing nothing in umtool's live
+ preview; builds were unaffected).
+- **The column's fit is checked where posts are known** (review F2): `validateChrome` holds a deck
+ to it only when the deck sets `deck.posts`, and `validatePosts(posts, timeline, render)` holds
+ drawn posts to it. A deck with no posts is never refused for a column it does not draw.
+- `qrencode` takes `--` before the URL in all three callers: a URL is data, never an option.
+- **A post's date is drawn as the day in its own string** (UTC as archived).
+- The overlay of a window uses `-itsoffset`, `-reinit_filter 0`, `format=rgba` and
+ `eof_action=pass`, never `shortest=1`; a framemd5 test shows every frame outside a window is
+ bit-identical to the deck-only output and the length never changes.
+
+### Gates on a598a386 (then f9625df7)
+
+- Workspace tsc clean (51 s); `test:scripts` 300 pass, 2 skipped (the queue-lock timing cases);
+ capped umtool `next build` with the corpus linked exit 0 (23 s), link removed.
+- umtool e2e `onscreen-posts onscreen clip-bench build projects report-fetch-via-editor`: 93 passed
+ (4.1 min); after f9625df7 the strict `onscreen-posts.spec.ts` 6 passed (real composition,
+ `posts:ready`).
+- Byte-identical: a manifest without `posts` builds as before (`--only c07` md5
+ `6a92235fa12ca181bb81993129c9ee9d`, with and without `posts`); a deck without posts writes the
+ same `schedule.json` and the same overlay chain and `applyChrome` argv.
+- First application (P1, scratch): 7 posts on c03 ×2, c06 ×3, c12, c17 at the predicted times;
+ 350.200 s; verify-build windows 136/195/76/76 frames; QR 7/7 on the crossfade cut and on a
+ transition-0 copy.
+
+- Review (read-only, Opus): SHIP AFTER FIXES — F1 (the live preview's page called a planner the
+ production build had renamed) and F2 (a deck with no posts refused for the column), fixed in
+ 7bb62683 with `--` before every qrencode URL. On 7bb62683: tsc clean (71 s); `test:scripts` 301
+ pass, 2 skipped, 1 queue-lock timing case (11/11 alone); capped umtool build exit 0 (26 s) and its
+ server bundle carries `const postsCues = (` (2 files); umtool e2e 93 passed (3.8 min).
+
+### Found and left
+
+- The ferret posts never fill the column, so "oldest slide up and out" is proven by unit tests,
+ not by media.
+- There is no live text patching for posts in the umtool preview: an override or hide recomposes.
+- The true still covers the deck region only.
+- Adding a post is an agent's edit to the manifest; umtool edits only `attachTo` and `hide`.
+
+### Rollout
+
+Umtool-only, like the deck: a umtool rebuild and restart. Nothing under `export/`, `homepage/`,
+`common/` or editor code.
diff --git a/umtool/app/api/report/chrome/files/[...path]/route.ts b/umtool/app/api/report/chrome/files/[...path]/route.ts
@@ -1,8 +1,9 @@
import path from "node:path";
+import { selectVariant } from "umtool-report-to-video/build-video";
import {
decodeProjectSegment,
- deckPreviewDir,
deckPreviewFile,
+ previewDirFor,
rangeResponse,
resolveReport,
} from "@/lib/report/serve.mjs";
@@ -22,6 +23,11 @@ export const dynamic = "force-dynamic";
// own scan and list, and the file must resolve -- symlinks followed -- inside
// that cut's out/<variant>/chrome/deck-preview/. deckPreviewFile is the rule,
// and it is tested.
+//
+// The posts region's windows are served from the same prefix one segment
+// deeper: <project>/<variant>/posts-preview-<segment>/<file…>, where <segment>
+// must be an entry of this cut (previewDirFor), and the file is then confined
+// to THAT window's directory by the same deckPreviewFile.
const TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
@@ -50,7 +56,10 @@ export async function GET(request: Request, ctx: { params: Promise<{ path: strin
const r = await resolveReport(projectId, variant);
if ("error" in r) return new Response(r.error, { status: r.status });
- const file = await deckPreviewFile(deckPreviewDir(r.project.dir, r.variant), rest);
+ const ids = (selectVariant(r.manifest, r.variant).timeline ?? []).map((e: { id: string }) => e.id);
+ const where = previewDirFor(r.project.dir, r.variant, rest, ids);
+ if (!where) return new Response("not found", { status: 404 });
+ const file = await deckPreviewFile(where.dir, where.rest);
if (!file) return new Response("not found", { status: 404 });
return rangeResponse(request, {
diff --git a/umtool/app/api/report/chrome/preview/route.ts b/umtool/app/api/report/chrome/preview/route.ts
@@ -1,6 +1,12 @@
-import { composeDeckPreview, normalizeDraft, scheduleForPreview } from "@/lib/report/onscreen.mjs";
-import { deckPreviewSrc, resolveReport } from "@/lib/report/serve.mjs";
-import { deckGeometry, deckLayout, deckOn, validateChrome } from "umtool-report-to-video/deck";
+import {
+ composeDeckPreview,
+ composePostsPreviews,
+ normalizeDraft,
+ normalizePostsDraft,
+ scheduleForPreview,
+} from "@/lib/report/onscreen.mjs";
+import { deckPreviewSrc, postsPreviewSrc, resolveReport } from "@/lib/report/serve.mjs";
+import { deckGeometry, deckLayout, deckOn, postsGeometry, validateChrome } from "umtool-report-to-video/deck";
export const dynamic = "force-dynamic";
@@ -17,7 +23,15 @@ export const dynamic = "force-dynamic";
// `draft` -- unsaved rows, id → { title?, subtitle? } | null, the shape PUT
// /api/report/onscreen takes -- is applied on top.
//
-// The client sends a project id, a variant and the draft. Never a path.
+// The posts region is composed beside it, one project per window
+// (postWindows: a clip that carries posts, from its first post's appearance to
+// the end of their leave), each loaded in its own iframe at
+// `posts.geometry` over the footage while the scrubber is inside the window.
+// `postsDraft` -- unsaved overrides, the shape PUT /api/report/posts takes --
+// is applied first. A window that does not compose says why in its row; the
+// deck's preview is returned either way.
+//
+// The client sends a project id, a variant and the drafts. Never a path.
export async function POST(request: Request) {
let body: Record<string, unknown>;
try {
@@ -36,7 +50,14 @@ export async function POST(request: Request) {
return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
}
- const { variantManifest, schedule } = await scheduleForPreview(r.project, r.manifest, r.variant, draft);
+ let postsDraft;
+ try {
+ postsDraft = normalizePostsDraft(body.postsDraft);
+ } catch (e) {
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
+ }
+
+ const { variantManifest, schedule } = await scheduleForPreview(r.project, r.manifest, r.variant, draft, postsDraft);
const render = (variantManifest.render ?? {}) as Record<string, unknown>;
if (!deckOn(render)) {
return Response.json(
@@ -55,16 +76,28 @@ export async function POST(request: Request) {
} catch (e) {
return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 });
}
+ // `posts: false` -- the clip bench's strip, which has no footage to lay them on.
+ const windows = body.posts === false ? [] : await composePostsPreviews(r.project, r.variant, schedule);
+ const stamp = Date.now();
return Response.json(
{
// `v` so the iframe reloads a recomposed preview; the files themselves
// are served no-store, so its relative asset urls need none.
- src: `${deckPreviewSrc(r.project.id, r.variant)}?v=${Date.now()}`,
+ src: `${deckPreviewSrc(r.project.id, r.variant)}?v=${stamp}`,
variant: r.variant,
geometry: deckGeometry(render),
layout: deckLayout(render),
schedule,
+ posts: {
+ geometry: postsGeometry(render),
+ windows: windows.map((w) => ({
+ segment: w.segment,
+ from: w.from,
+ to: w.to,
+ ...(w.ok ? { src: `${postsPreviewSrc(r.project.id, r.variant, w.segment)}?v=${stamp}` } : { error: w.error }),
+ })),
+ },
},
{ headers: { "cache-control": "no-store" } },
);
diff --git a/umtool/app/api/report/onscreen/route.ts b/umtool/app/api/report/onscreen/route.ts
@@ -1,7 +1,6 @@
import { StaleToken, manifestToken, updateOnscreen } from "@/lib/report/manifest.mjs";
-import { deckMetas } from "@/lib/report/onscreen.mjs";
+import { postRows, scheduleForPreview } from "@/lib/report/onscreen.mjs";
import { resolveReport } from "@/lib/report/serve.mjs";
-import { selectVariant } from "umtool-report-to-video/build-video";
import { deckText, isMultiChannel, resolveDeck } from "umtool-report-to-video/deck";
export const dynamic = "force-dynamic";
@@ -26,19 +25,25 @@ const noStore = { "cache-control": "no-store" };
* shows as placeholders. Auto text comes from the archive's cue files (the
* clip bench's source), so before a build it can differ from the fetched
* file's metadata the build will use.
+ *
+ * `posts` rides along, for the Posts table under it: every post the manifest
+ * carries with the clip the date rule picks for it (`auto`), the clip it rides
+ * on as saved (`effective`, null when hidden), and its slot in the schedule
+ * the preview draws -- the build's when it still matches the cut, else the
+ * estimate (`postsEstimated`). `clips` is the override select's list.
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const r = await resolveReport(url.searchParams.get("project") ?? "", url.searchParams.get("variant"));
if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
- const cut = selectVariant(r.manifest, r.variant);
+ const { variantManifest: cut, metas, schedule } = await scheduleForPreview(r.project, r.manifest, r.variant);
const entries = (cut.timeline ?? []) as Entry[];
const render = cut.render ?? {};
const deck = resolveDeck(render);
const provenance = cut.provenance ?? {};
const multi = isMultiChannel(entries, provenance);
- const metas = await deckMetas(r.project.dir, r.manifest, entries);
+ const posts = postRows({ variantManifest: cut, metas, schedule });
const rows = entries.map((e, i) => {
const { onscreen, ...bare } = e;
return {
@@ -54,6 +59,10 @@ export async function GET(request: Request) {
rows,
maxChars: deck.title.maxChars,
multiChannel: multi,
+ posts: posts.posts,
+ clips: posts.clips,
+ postsShown: deck.posts.show,
+ postsEstimated: schedule.estimated === true,
token: await manifestToken(r.project.dir),
},
{ headers: noStore },
diff --git a/umtool/app/api/report/posts/route.ts b/umtool/app/api/report/posts/route.ts
@@ -0,0 +1,49 @@
+import { PostsRefused, StaleToken, updatePosts } from "@/lib/report/manifest.mjs";
+import { resolveReport } from "@/lib/report/serve.mjs";
+
+export const dynamic = "force-dynamic";
+
+// The two decisions a person makes about a post once it is in the manifest:
+// which clip it rides on (`attachTo`, null for the automatic one) and whether
+// it is shown (`hide`). Adding, removing or rewording posts is not here --
+// they are written into the manifest by whoever cites them.
+//
+// PUT is the Posts table's one save: `{ project, posts: { <id>: { attachTo?,
+// hide? } }, token }`. One unknown id, one bad value, or a result the build's
+// validatePosts refuses fails the WHOLE batch and nothing is written. The
+// client sends a project id, never a path. Read through GET
+// /api/report/onscreen, which carries the rows and the token.
+
+const noStore = { "cache-control": "no-store" };
+
+export async function PUT(request: Request) {
+ let body: Record<string, unknown>;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "expected JSON" }, { status: 400 });
+ }
+
+ const r = await resolveReport(String(body.project ?? ""));
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+
+ try {
+ const res = await updatePosts(
+ r.project.dir,
+ body.posts as Record<string, { attachTo?: string | null; hide?: boolean }>,
+ { token: body.token === undefined ? null : String(body.token) },
+ );
+ return Response.json({ ok: true, posts: res.posts, token: res.token }, { headers: noStore });
+ } catch (e) {
+ if (e instanceof StaleToken) {
+ return Response.json(
+ { error: e.message, expected: e.expected, got: e.got, stale: true },
+ { status: 409 },
+ );
+ }
+ if (e instanceof PostsRefused) {
+ return Response.json({ error: e.message, errors: e.errors }, { status: 400 });
+ }
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
+ }
+}
diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx
@@ -436,7 +436,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
let live = true;
void (async () => {
const [pv, table] = await Promise.all([
- composePreview(data.project, null),
+ composePreview(data.project, null, {}, { posts: false }),
fetch(`/api/report/onscreen?project=${encodeURIComponent(data.project)}`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.catch(() => null) as Promise<{
diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx
@@ -60,7 +60,19 @@ export type DeckSchedule = {
multiChannel: boolean;
segments: DeckSegment[];
};
-export type DeckPreviewDoc = { src: string; variant: string; geometry: DeckGeometry; schedule: DeckSchedule };
+export type PostSlot = { id: string; segment: string; slot: number; of: number; appear: number; out: [number, number] };
+/** One posts window's preview composition: `src` when it composed, `error` when it did not. */
+export type PostsWindow = { segment: string; from: number; to: number; src?: string; error?: string };
+export type DeckPreviewDoc = {
+ src: string;
+ variant: string;
+ geometry: DeckGeometry;
+ schedule: DeckSchedule & { posts?: PostSlot[] };
+ /** The posts region: where it sits in the frame, and one composition per window. */
+ posts?: { geometry: Rect; windows: PostsWindow[] };
+};
+/** An unsaved change to a post, as PUT /api/report/posts takes it. */
+export type PostPatch = { attachTo?: string | null; hide?: boolean };
/** What each segment's panel should say right now, by entry id. */
export type DeckTexts = Record<string, { title: string; subtitle: string }>;
export type Onscreen = { title?: string; subtitle?: string };
@@ -83,16 +95,21 @@ export const onscreenValue = (d: { title: string; subtitle: string }): Onscreen
return o.title || o.subtitle ? o : null;
};
-/** Ask for a fresh preview composition. One door, used by both callers. */
+/**
+ * Ask for a fresh preview composition. One door, used by both callers.
+ * `postsDraft` is the Posts table's unsaved overrides; `posts: false` skips
+ * composing the posts windows (the bench's strip has no footage to put them on).
+ */
export async function composePreview(
project: string,
variant: string | null,
draft: Record<string, Onscreen | null> = {},
+ { postsDraft = {}, posts = true }: { postsDraft?: Record<string, PostPatch>; posts?: boolean } = {},
): Promise<DeckPreviewDoc | { error: string }> {
const r = await fetch("/api/report/chrome/preview", {
method: "POST",
headers: { "content-type": "application/json" },
- body: JSON.stringify({ project, variant: variant || undefined, draft }),
+ body: JSON.stringify({ project, variant: variant || undefined, draft, postsDraft, ...(posts ? {} : { posts: false }) }),
});
const j = (await r.json().catch(() => ({}))) as Record<string, unknown>;
if (!r.ok) return { error: String(j.error ?? r.status) };
@@ -244,6 +261,114 @@ export function NeutralFrame({ geometry: g, label = "footage" }: { geometry: Dec
);
}
+/** The posts window the scrubber is inside, if any. Windows of one cut do not overlap in practice; the first wins. */
+export const postsWindowAt = (windows: PostsWindow[] | undefined, t: number): PostsWindow | null =>
+ (windows ?? []).find((w) => t >= w.from && t <= w.to) ?? null;
+
+// ---------------------------------------------------------------------------
+// PostsOverlay: one posts window's composition, at the posts region's rect
+// inside the 16:9 frame, while the scrubber is in that window.
+//
+// The same contract as the deck's frame, with its own message names: the page
+// is loaded with `?preview=1`, posts `{type: "posts:ready"}` when it can be
+// seeked, and takes `{type: "deck:seek", t}` in the CUT's clock -- the
+// window's composition knows where it starts. Laid out at its own pixel size
+// and scaled, so its text is measured at the width the render measures it.
+// Same origin, not sandboxed, for the reason DeckFrame's header gives.
+// ---------------------------------------------------------------------------
+export function PostsOverlay({
+ win,
+ geometry,
+ frame: g,
+ t,
+}: {
+ win: PostsWindow;
+ geometry: Rect;
+ frame: DeckGeometry;
+ t: number;
+}) {
+ const box = useRef<HTMLDivElement | null>(null);
+ const el = useRef<HTMLIFrameElement | null>(null);
+ const [width, setWidth] = useState(0);
+ const [readySrc, setReadySrc] = useState<string | null>(null);
+ const src = win.src ? `${win.src}${win.src.includes("?") ? "&" : "?"}preview=1` : null;
+ const pct = (n: number, of: number) => `${(n / of) * 100}%`;
+
+ useEffect(() => {
+ const node = box.current;
+ if (!node) return;
+ const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
+ ro.observe(node);
+ return () => ro.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const onMsg = (e: MessageEvent) => {
+ if (e.source !== el.current?.contentWindow || e.origin !== window.location.origin) return;
+ if ((e.data as { type?: string } | null)?.type === "posts:ready") setReadySrc(src);
+ };
+ window.addEventListener("message", onMsg);
+ return () => window.removeEventListener("message", onMsg);
+ }, [src]);
+
+ const live = !!src && readySrc === src;
+ useEffect(() => {
+ if (live) el.current?.contentWindow?.postMessage({ type: "deck:seek", t }, window.location.origin);
+ }, [live, t]);
+
+ const scale = width > 0 ? width / geometry.width : 0;
+ return (
+ <div
+ ref={box}
+ data-testid="onscreen-posts-preview"
+ data-segment={win.segment}
+ data-posts-ready={live ? "1" : "0"}
+ className="pointer-events-none absolute"
+ style={{
+ left: pct(geometry.x, g.W),
+ top: pct(geometry.y, g.H),
+ width: pct(geometry.width, g.W),
+ height: pct(geometry.height, g.H),
+ }}
+ >
+ {src ? (
+ <iframe
+ ref={el}
+ key={src}
+ src={src}
+ title="posts preview"
+ data-testid="onscreen-posts-preview-iframe"
+ tabIndex={-1}
+ aria-hidden
+ style={{
+ position: "absolute",
+ left: 0,
+ top: 0,
+ width: geometry.width,
+ height: geometry.height,
+ transform: `scale(${scale})`,
+ transformOrigin: "0 0",
+ border: 0,
+ background: "transparent",
+ colorScheme: "normal",
+ visibility: scale > 0 ? "visible" : "hidden",
+ }}
+ />
+ ) : (
+ <div
+ data-testid="onscreen-posts-preview-error"
+ className="absolute inset-0 flex items-start justify-center border border-dashed border-[var(--color-dirty)] p-2 text-center text-[11px] text-[var(--color-dirty)]"
+ title={win.error}
+ >
+ <span className="rounded bg-black/70 px-1.5 py-0.5">
+ posts on {win.segment}: not composed — {win.error}
+ </span>
+ </div>
+ )}
+ </div>
+ );
+}
+
// ---------------------------------------------------------------------------
// The settings form: every key of render.chrome.deck, flattened.
// ---------------------------------------------------------------------------
@@ -258,6 +383,7 @@ type DeckSettings = {
qr: { show: boolean; size: number };
overCards: string;
motion: { out: number; in: number; pip: number };
+ posts: { show: boolean; seconds: number; position: string; width: number; qrSize: number; maxLines: number; inset: number };
};
type Field =
@@ -307,6 +433,18 @@ const GROUPS: { name: string; fields: Field[] }[] = [
],
},
{
+ name: "posts",
+ fields: [
+ { key: "posts.show", label: "show", kind: "bool", hint: "off leaves every post out of the cut" },
+ { key: "posts.seconds", label: "seconds", kind: "num", step: 0.5, hint: "s, 0.5–10: each post alone before the next stacks on" },
+ { key: "posts.position", label: "side", kind: "select", options: ["top-right", "top-left"], hint: "the corner of the footage the column hangs from" },
+ { key: "posts.width", label: "width", kind: "int", hint: "px, 320–900, inside the footage" },
+ { key: "posts.qrSize", label: "qr", kind: "num", hint: "px, 80–200, at most half the card" },
+ { key: "posts.maxLines", label: "lines", kind: "int", hint: "2–14: longer posts end in an ellipsis" },
+ { key: "posts.inset", label: "inset", kind: "num", hint: "px, 0–80 from the footage's edges" },
+ ],
+ },
+ {
name: "motion",
fields: [
{ key: "motion.out", label: "out", kind: "num", step: 0.05, hint: "s, 0–2: the old title wiping out" },
@@ -378,6 +516,24 @@ type Row = {
auto: { title: string; subtitle: string };
};
type Draft = { title: string; subtitle: string };
+type PostWhere = { entryId: string; rule: "attachTo" | "date" | "first"; clipDay: string | null; label: string };
+type PostRow = {
+ id: string;
+ platform: string;
+ author: string;
+ handle: string;
+ date: string;
+ text: string;
+ url: string;
+ attachTo: string | null;
+ hide: boolean;
+ auto: PostWhere | null;
+ effective: PostWhere | null;
+ timing: { segment: string; slot: number; of: number; appear: number; out: [number, number] } | null;
+};
+type ClipOption = { id: string; label: string; day: string | null };
+/** A post's row as the form holds it: `attachTo` "" is the automatic clip. */
+type PostDraft = { attachTo: string; hide: boolean };
type JobView = {
id: string;
state: "running" | "done" | "failed";
@@ -390,6 +546,17 @@ type JobView = {
const draftOf = (o: Onscreen | null): Draft => ({ title: o?.title ?? "", subtitle: o?.subtitle ?? "" });
const sameDraft = (a: Draft, b: Draft) => a.title.trim() === b.title.trim() && a.subtitle.trim() === b.subtitle.trim();
+const postDraftOf = (p: PostRow): PostDraft => ({ attachTo: p.attachTo ?? "", hide: p.hide });
+const samePost = (a: PostDraft, b: PostDraft) => a.attachTo === b.attachTo && a.hide === b.hide;
+/** Only what changed, in the writer's shape. */
+const postPatch = (d: PostDraft, saved: PostDraft): PostPatch => {
+ const p: PostPatch = {};
+ if (d.attachTo !== saved.attachTo) p.attachTo = d.attachTo || null;
+ if (d.hide !== saved.hide) p.hide = d.hide;
+ return p;
+};
+const clipText = (id: string, label: string) => (label ? `${id} — ${label}` : id);
+const postDay = (iso: string) => String(iso).slice(0, 10);
const STALE =
"the manifest changed since you opened this — reload the saved values (your edits are kept) and save again, or your edit would overwrite whatever was written";
@@ -428,6 +595,16 @@ export default function OnscreenSection({
}, [rows]);
const [maxChars, setMaxChars] = useState(48);
const [drafts, setDrafts] = useState<Record<string, Draft>>({});
+ // The Posts table: the manifest's posts as saved, the clips one can ride on,
+ // and the unsaved overrides. Saved through the same token as everything else.
+ const [posts, setPosts] = useState<PostRow[]>([]);
+ const postsRef = useRef<PostRow[]>([]);
+ useEffect(() => {
+ postsRef.current = posts;
+ }, [posts]);
+ const [clips, setClips] = useState<ClipOption[]>([]);
+ const [postsShown, setPostsShown] = useState(true);
+ const [postDrafts, setPostDrafts] = useState<Record<string, PostDraft>>({});
const [note, setNote] = useState<string | null>(null);
const [stale, setStale] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
@@ -484,12 +661,36 @@ export default function OnscreenSection({
const loadRows = useCallback(
async (keep: boolean) => {
const r = await fetch(`/api/report/onscreen?${q}`, { cache: "no-store" });
- const j = (await r.json()) as { rows: Row[]; maxChars: number; token: string | null; error?: string };
+ const j = (await r.json()) as {
+ rows: Row[];
+ maxChars: number;
+ token: string | null;
+ posts?: PostRow[];
+ clips?: ClipOption[];
+ postsShown?: boolean;
+ error?: string;
+ };
if (!r.ok) {
setLoadError(String(j.error ?? r.status));
return;
}
const before = new Map(rowsRef.current.map((row) => [row.id, draftOf(row.onscreen)]));
+ const postsBefore = new Map(postsRef.current.map((p) => [p.id, postDraftOf(p)]));
+ const nextPosts = j.posts ?? [];
+ setPosts(nextPosts);
+ setClips(j.clips ?? []);
+ setPostsShown(j.postsShown !== false);
+ // The titles' rule, for posts: only a real edit survives a reload.
+ setPostDrafts((prev) => {
+ const next: Record<string, PostDraft> = {};
+ for (const p of nextPosts) {
+ const saved = postDraftOf(p);
+ const old = prev[p.id];
+ const was = postsBefore.get(p.id);
+ next[p.id] = keep && old && was && !samePost(old, was) ? old : saved;
+ }
+ return next;
+ });
token.current = j.token;
setRows(j.rows);
setMaxChars(j.maxChars);
@@ -508,10 +709,10 @@ export default function OnscreenSection({
);
const recompose = useCallback(
- async (draft: Record<string, Onscreen | null> = {}) => {
+ async (draft: Record<string, Onscreen | null> = {}, postsDraft: Record<string, PostPatch> = {}) => {
setComposing(true);
setPreviewError(null);
- const res = await composePreview(project, variant || null, draft);
+ const res = await composePreview(project, variant || null, draft, { postsDraft });
setComposing(false);
if ("error" in res) {
setPreviewError(res.error);
@@ -627,6 +828,14 @@ export default function OnscreenSection({
() => Object.fromEntries(dirtyIds.map((id) => [id, onscreenValue(drafts[id])])) as Record<string, Onscreen | null>,
[dirtyIds, drafts],
);
+ const dirtyPostIds = posts.filter((p) => postDrafts[p.id] && !samePost(postDrafts[p.id], postDraftOf(p))).map((p) => p.id);
+ const postsDraftMap = useCallback(
+ () =>
+ Object.fromEntries(
+ posts.filter((p) => dirtyPostIds.includes(p.id)).map((p) => [p.id, postPatch(postDrafts[p.id], postDraftOf(p))]),
+ ) as Record<string, PostPatch>,
+ [posts, dirtyPostIds, postDrafts],
+ );
const saveSettings = useCallback(async () => {
if (!doc) return;
@@ -641,9 +850,9 @@ export default function OnscreenSection({
// The auto subtitles follow subtitle.parts and dateFormat; the counter
// follows title.maxChars. Unsaved table edits ride through.
await loadRows(true);
- await recompose(draftMap());
+ await recompose(draftMap(), postsDraftMap());
setNote("settings saved — the preview is recomposed with them");
- }, [doc, form, putChrome, loadChrome, loadRows, recompose, draftMap]);
+ }, [doc, form, putChrome, loadChrome, loadRows, recompose, draftMap, postsDraftMap]);
const saveRows = useCallback(
() =>
@@ -682,6 +891,39 @@ export default function OnscreenSection({
[queued, dirtyIds, project, draftMap],
);
+ /** The Posts table's one save: only the posts that changed, only the keys that changed. */
+ const savePosts = useCallback(
+ () =>
+ queued(async () => {
+ if (!dirtyPostIds.length) return;
+ setBusy("saving…");
+ setNote(null);
+ const r = await fetch("/api/report/posts", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project, posts: postsDraftMap(), token: token.current }),
+ });
+ const j = (await r.json()) as Record<string, unknown>;
+ setBusy(null);
+ if (!r.ok) {
+ if (j.stale) {
+ setStale(true);
+ setNote(STALE);
+ } else setNote(`could not save: ${String(j.error ?? r.status)}`);
+ return;
+ }
+ token.current = String(j.token ?? "");
+ setStale(false);
+ const n = Object.keys((j.posts ?? {}) as object).length;
+ // The effective clip and the timing are the server's to say: read them
+ // back, keeping any title edits, and redraw the windows.
+ await loadRows(true);
+ if (doc?.deckOn) await recompose(draftMap());
+ setNote(`saved ${n} post${n === 1 ? "" : "s"}`);
+ }),
+ [queued, dirtyPostIds, project, postsDraftMap, loadRows, recompose, draftMap, doc],
+ );
+
const reloadSaved = useCallback(async () => {
await loadChrome();
await loadRows(true);
@@ -754,6 +996,7 @@ export default function OnscreenSection({
const schedule = preview?.schedule ?? null;
const current = schedule ? segmentAt(schedule, t) : null;
+ const postsWin = preview?.posts ? postsWindowAt(preview.posts.windows, t) : null;
const backdropId = current && segmentOf.has(current.id) ? current.id : null;
// The backdrop follows the scrubber: the built segment of whichever clip is
@@ -924,6 +1167,173 @@ export default function OnscreenSection({
</div>
);
+ // ---- the posts ------------------------------------------------------------
+ const clipById = new Map(clips.map((c) => [c.id, c]));
+ /** Where a post rides as DRAFTED: hidden, the chosen clip, or the automatic one. */
+ const ridesOn = (p: PostRow, d: PostDraft): { id: string; label: string; how: "auto" | "chosen" } | null => {
+ if (d.hide) return null;
+ const chosen = d.attachTo ? clipById.get(d.attachTo) : null;
+ if (chosen) return { id: chosen.id, label: chosen.label, how: "chosen" };
+ return p.auto ? { id: p.auto.entryId, label: p.auto.label, how: "auto" } : null;
+ };
+ const autoText = (p: PostRow) =>
+ p.auto ? `auto: ${clipText(p.auto.entryId, p.auto.label)}` : "auto: no clip in this cut";
+
+ const postsBlock = posts.length > 0 && (
+ <div className="space-y-1.5" data-testid="onscreen-posts">
+ <div className="flex flex-wrap items-center gap-2">
+ <span className="micro">posts — {posts.length}</span>
+ <span className="text-[11px] text-[var(--color-dim)]">
+ {postsShown
+ ? "each rides on the clip recorded most closely before it, and stacks on at that clip's end"
+ : "posts are switched off in the settings (posts → show); nothing below is drawn"}
+ </span>
+ <button
+ type="button"
+ data-testid="onscreen-posts-save"
+ className={`${buttonVariants({ variant: "primary", size: "sm" })} ml-auto`}
+ disabled={!!busy || dirtyPostIds.length === 0}
+ onClick={() => void savePosts()}
+ >
+ {dirtyPostIds.length ? `save ${dirtyPostIds.length} post${dirtyPostIds.length === 1 ? "" : "s"}` : "saved"}
+ </button>
+ {dirtyPostIds.length > 0 && (
+ <button
+ type="button"
+ data-testid="onscreen-posts-discard"
+ className={buttonVariants({ size: "sm" })}
+ disabled={!!busy}
+ onClick={() => setPostDrafts(Object.fromEntries(posts.map((p) => [p.id, postDraftOf(p)])))}
+ >
+ discard
+ </button>
+ )}
+ </div>
+ <div className="overflow-x-auto">
+ <table data-testid="onscreen-posts-table" className="w-full border-collapse text-[12px]">
+ <thead>
+ <tr className="micro text-left">
+ <th className="w-40 px-1.5 py-0.5">post</th>
+ <th className="px-1.5 py-0.5">words</th>
+ <th className="w-72 px-1.5 py-0.5">rides on</th>
+ <th className="w-12 px-1.5 py-0.5 text-center">hide</th>
+ </tr>
+ </thead>
+ <tbody>
+ {posts.map((p) => {
+ const d = postDrafts[p.id] ?? postDraftOf(p);
+ const dirty = !samePost(d, postDraftOf(p));
+ const on = ridesOn(p, d);
+ const set = (patch: Partial<PostDraft>) =>
+ setPostDrafts((prev) => ({ ...prev, [p.id]: { ...(prev[p.id] ?? postDraftOf(p)), ...patch } }));
+ const inWindow = postsWin && on && postsWin.segment === on.id;
+ return (
+ <tr
+ key={p.id}
+ data-testid="onscreen-post-row"
+ data-post={p.id}
+ data-dirty={dirty ? "1" : "0"}
+ data-hidden={d.hide ? "1" : "0"}
+ data-effective={on?.id ?? ""}
+ data-current={inWindow ? "1" : "0"}
+ className={`border-t border-[var(--color-line)] align-top ${d.hide ? "opacity-50" : ""} ${inWindow ? "bg-[var(--color-panel-2)]" : ""}`}
+ >
+ <td className="px-1.5 py-1">
+ <div className="num font-mono text-[11px] text-[var(--color-text)]" title={p.date}>
+ {postDay(p.date)}
+ {dirty && <span className="ml-1 text-[var(--color-dirty)]" title="unsaved">●</span>}
+ </div>
+ <div className="text-[11px] text-[var(--color-dim)]">
+ <span className={badgeVariants({ size: "sm" })} data-testid="onscreen-post-platform">
+ {p.platform}
+ </span>{" "}
+ <span data-testid="onscreen-post-handle">@{p.handle || p.author}</span>
+ </div>
+ <a
+ href={p.url}
+ target="_blank"
+ rel="noreferrer noopener"
+ data-testid="onscreen-post-link"
+ className="text-[11px] text-[var(--color-sel)] hover:underline"
+ >
+ open the post ↗
+ </a>
+ </td>
+ <td className="px-1.5 py-1">
+ <p
+ data-testid="onscreen-post-text"
+ className="line-clamp-3 whitespace-pre-line text-[12px] leading-snug text-[var(--color-text)]"
+ title={p.text}
+ >
+ {p.text}
+ </p>
+ </td>
+ <td className="px-1.5 py-1">
+ <select
+ data-testid="onscreen-post-attach"
+ value={d.attachTo}
+ disabled={d.hide}
+ onChange={(e) => set({ attachTo: e.target.value })}
+ className={`${input} w-full text-[11px]`}
+ title="The clip this post rides on: automatic (by date), or any clip of the cut"
+ >
+ <option value="" data-testid="onscreen-post-auto">
+ {autoText(p)}
+ </option>
+ {clips.map((c) => (
+ <option key={c.id} value={c.id}>
+ {clipText(c.id, c.label)}
+ </option>
+ ))}
+ </select>
+ <div className="mt-0.5 flex flex-wrap items-center gap-1 text-[11px] text-[var(--color-dim)]">
+ {on ? (
+ <>
+ <span data-testid="onscreen-post-effective">
+ {on.how === "auto" ? "automatic" : "chosen"}:{" "}
+ <span className="font-mono text-[var(--color-text)]">{on.id}</span>
+ {on.how === "auto" && p.auto?.clipDay ? ` (${p.auto.clipDay})` : ""}
+ </span>
+ {!dirty && p.timing && schedule && (
+ <button
+ type="button"
+ data-testid="onscreen-post-jump"
+ className="num font-mono text-[var(--color-sel)] hover:underline"
+ onClick={() => setT(Math.min(schedule.total, Math.round((p.timing!.appear + 0.25) * 1000) / 1000))}
+ title="show this post in the preview"
+ >
+ at {clock(p.timing.appear)}
+ </button>
+ )}
+ </>
+ ) : (
+ <span data-testid="onscreen-post-effective">hidden — not in the cut</span>
+ )}
+ </div>
+ </td>
+ <td className="px-1.5 py-1 text-center">
+ <input
+ type="checkbox"
+ data-testid="onscreen-post-hide"
+ checked={d.hide}
+ onChange={(e) => set({ hide: e.target.checked })}
+ aria-label={`hide post ${p.id}`}
+ />
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ {dirtyPostIds.length > 0 && doc.deckOn && (
+ <p className="text-[11px] text-[var(--color-dirty)]">
+ the preview shows saved posts; recompose to see the unsaved {dirtyPostIds.length === 1 ? "change" : "changes"}
+ </p>
+ )}
+ </div>
+ );
+
return (
<section
data-testid="onscreen-section"
@@ -1008,6 +1418,15 @@ export default function OnscreenSection({
) : (
<NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} />
)}
+ {postsWin && preview.posts && (
+ <PostsOverlay
+ key={`${postsWin.segment}:${postsWin.src ?? "none"}`}
+ win={postsWin}
+ geometry={preview.posts.geometry}
+ frame={preview.geometry}
+ t={t}
+ />
+ )}
</DeckFrame>
) : (
<div className="flex aspect-video w-full items-center justify-center rounded border border-dashed border-[var(--color-line)] text-[12px] text-[var(--color-dim)]">
@@ -1045,6 +1464,28 @@ export default function OnscreenSection({
/>
))}
</div>
+ {(preview?.posts?.windows.length ?? 0) > 0 && (
+ // Where the posts are up: one mark per window, a click to
+ // the moment its last card has stacked on.
+ <div className="relative h-1.5 w-full" data-testid="onscreen-posts-windows">
+ {preview!.posts!.windows.map((w) => (
+ <button
+ key={w.segment}
+ type="button"
+ data-posts-window={w.segment}
+ title={`posts on ${w.segment}${w.error ? " · not composed" : ""}`}
+ onClick={() => setT(Math.round(((w.from + w.to) / 2) * 1000) / 1000)}
+ className={`absolute top-0 h-full rounded-sm ${
+ postsWin?.segment === w.segment ? "bg-[var(--color-sel)]" : w.error ? "bg-[var(--color-dirty)]" : "bg-[var(--color-meter)]"
+ }`}
+ style={{
+ left: `${(w.from / schedule.total) * 100}%`,
+ width: `${Math.max(0.4, ((w.to - w.from) / schedule.total) * 100)}%`,
+ }}
+ />
+ ))}
+ </div>
+ )}
<div className="flex items-center gap-2">
<input
type="range"
@@ -1086,8 +1527,8 @@ export default function OnscreenSection({
data-testid="onscreen-recompose"
className={buttonVariants({ size: "sm" })}
disabled={composing}
- onClick={() => void recompose(draftMap())}
- title="Compose the preview again from the manifest, with the table's unsaved rows on top"
+ onClick={() => void recompose(draftMap(), postsDraftMap())}
+ title="Compose the preview again from the manifest, with the tables' unsaved rows on top"
>
{composing ? "recomposing…" : "recompose"}
</button>
@@ -1302,7 +1743,10 @@ export default function OnscreenSection({
never uses the panel should not carry a nineteen-row form, and one
that will can have its titles written before the switch. */}
{on ? (
- words
+ <>
+ {words}
+ {postsBlock}
+ </>
) : (
<details data-testid="onscreen-words-folded">
<summary className="cursor-pointer text-[11px] text-[var(--color-dim)]">
@@ -1312,6 +1756,14 @@ export default function OnscreenSection({
<div className="mt-1.5">{words}</div>
</details>
)}
+ {!on && postsBlock && (
+ <details data-testid="onscreen-posts-folded">
+ <summary className="cursor-pointer text-[11px] text-[var(--color-dim)]">
+ posts — {posts.length}; drawn only when the panel is on
+ </summary>
+ <div className="mt-1.5">{postsBlock}</div>
+ </details>
+ )}
</section>
);
}
diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md
@@ -202,6 +202,49 @@ 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. So
+build-video reaches the page modules only through a dynamic import of
+compose-chrome, and the posts windows' frame arithmetic it needs
+(`snapWindow`) lives in `deck.mjs`. This is about build-video's import chain,
+not a wall around the page modules: umtool's preview helper
+(`lib/report/onscreen.mjs`) imports compose-chrome statically, as it did before
+posts, and its routes build. A capped umtool build is the gate that catches it — the unit
+tests run in plain Node and pass either way.
+
## Rail strips and rolling counters
**A slab that slides moves text that did not change.** The tally used to be four
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -1506,6 +1506,41 @@ const ONSCREEN_BUILD = writeProject(
]),
);
+// onscreen-posts-fixture: the Posts table writes here (onscreen-posts.spec.ts).
+// The clips carry their own `date`, so which clip a post rides on is the
+// date rule's answer and not the cue file's one shared upload day:
+// p-early Aug 1 older than every clip -> c01 ("first")
+// p-mid Sep 5 after c01's Sep 3 -> c01 ("date")
+// p-late Sep 12 after c02's Sep 10 -> c02 ("date")
+// Never built, so the posts' timing is the estimate's.
+const ONSCREEN_POSTS = writeProject(
+ "onscreen-posts-fixture",
+ {
+ ...deckManifest("onscreen-posts-fixture", "The On-screen Posts Fixture", [
+ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because", date: "2024-09-03" },
+ { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence", date: "2024-09-10" },
+ { type: "card", id: "k01", style: "chapter", seconds: 3, heading: "A card" },
+ ]),
+ posts: [
+ {
+ id: "p-early", platform: "bluesky", author: "Fixture Author", handle: "fixture.example",
+ date: "2024-08-01T12:00:00.000Z", text: "Older than every clip in the cut.",
+ url: "https://bsky.app/profile/fixture.example/post/early",
+ },
+ {
+ id: "p-mid", platform: "bluesky", author: "Fixture Author", handle: "fixture.example",
+ date: "2024-09-05T09:30:00.000Z", text: "Two days after the first clip.\nA second line.",
+ url: "https://bsky.app/profile/fixture.example/post/mid",
+ },
+ {
+ id: "p-late", platform: "x", author: "Fixture Author", handle: "fixture",
+ date: "2024-09-12T18:00:00.000Z", text: "Two days after the second clip.",
+ url: "https://x.com/fixture/status/1",
+ },
+ ],
+ },
+);
+
mkdirSync(path.join(reports, "bike-fixture"), { recursive: true });
writeFileSync(
path.join(reports, "bike-fixture", "sweep-report.md"),
@@ -1545,7 +1580,7 @@ ff([
// intermediates and are excluded by name.
mkdirSync(path.join(reports, "no-origin-fixture", "out"), { recursive: true });
-for (const dir of [BENCH, BUILD, ONSCREEN, ONSCREEN_BUILD]) {
+for (const dir of [BENCH, BUILD, ONSCREEN, ONSCREEN_BUILD, ONSCREEN_POSTS]) {
mkdirSync(path.join(dir, "out", "clips-raw"), { recursive: true });
copyFileSync(
path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"),
@@ -1666,5 +1701,6 @@ console.log(` editor-fetch-{,many-,reuse-}fixture (nothing cached —
console.log(` longform-fixture (cue gap, legacy .bak, ffmeta), longform-edit-fixture, dash-fixture`);
console.log(` deliver-fixture (writable: a01/a02 to cut, a03 unfetched, b01 shared, b02 incorrect, b03 unjudged)`);
console.log(` onscreen-fixture (writable, deck on, unbuilt), onscreen-build-fixture (built with the deck)`);
+console.log(` onscreen-posts-fixture (writable, deck on, three posts, unbuilt)`);
console.log(` deliver-stop-fixture (writable: six confirmed clips to cut, for Stop and resume)`);
console.log(` ${taken} candidate files copied, 2 mix tracks synthesised`);
diff --git a/umtool/e2e/onscreen-posts.spec.ts b/umtool/e2e/onscreen-posts.spec.ts
@@ -0,0 +1,258 @@
+import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { postWindows } from "umtool-report-to-video/deck";
+
+// ---------------------------------------------------------------------------
+// POSTS on the on-screen deck, as umtool edits them: the Posts table under the
+// titles, its override and hide, the writer behind it, and the posts region
+// in the live preview.
+//
+// One project, onscreen-posts-fixture (make-fixture.mjs): two dated clips and
+// a card, three posts whose dates put them on c01 ("first"), c01 ("date") and
+// c02 ("date"). Never built, so every timing is the estimate's. Each test puts
+// the posts back to automatic and shown through the route before it starts.
+//
+// The posts region's COMPOSITION is compose-chrome's (`region: "posts"`): the
+// preview test asserts every window composes and its page reports
+// `posts:ready` in the live preview.
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const FIXTURE = path.join(HERE, "..", ".e2e-song");
+const PROJECT = "reports/onscreen-posts-fixture";
+const DIR = path.join(FIXTURE, "reports", "onscreen-posts-fixture");
+const IDS = ["p-early", "p-mid", "p-late"];
+
+type Post = { id: string; attachTo?: string | null; hide?: boolean };
+type Manifest = { render: Record<string, unknown>; posts: Post[] };
+const readManifest = (): Manifest => JSON.parse(readFileSync(path.join(DIR, "video.manifest.json"), "utf8"));
+const post = (id: string) => readManifest().posts.find((p) => p.id === id)!;
+const enc = encodeURIComponent;
+
+type PostRow = {
+ id: string;
+ attachTo: string | null;
+ hide: boolean;
+ auto: { entryId: string; rule: string; label: string } | null;
+ effective: { entryId: string; rule: string } | null;
+ timing: { segment: string; appear: number } | null;
+};
+
+async function token(request: APIRequestContext): Promise<string> {
+ const j = (await (await request.get(`/api/report/chrome?project=${enc(PROJECT)}`)).json()) as { token: string };
+ return j.token;
+}
+
+async function putPosts(request: APIRequestContext, posts: Record<string, { attachTo?: string | null; hide?: boolean }>) {
+ const r = await request.put("/api/report/posts", { data: { project: PROJECT, posts, token: await token(request) } });
+ expect(r.ok(), await r.text()).toBeTruthy();
+}
+
+async function rows(request: APIRequestContext): Promise<Record<string, PostRow>> {
+ const j = (await (await request.get(`/api/report/onscreen?project=${enc(PROJECT)}`)).json()) as { posts: PostRow[] };
+ return Object.fromEntries(j.posts.map((p) => [p.id, p]));
+}
+
+const reset = (request: APIRequestContext) =>
+ putPosts(request, Object.fromEntries(IDS.map((id) => [id, { attachTo: null, hide: false }])));
+
+async function openSection(page: Page) {
+ await page.goto(`/browse/${PROJECT}`);
+ await expect(page.getByTestId("onscreen-section")).toHaveAttribute("data-onscreen", "on");
+ await expect(page.getByTestId("onscreen-post-row")).toHaveCount(3);
+}
+
+const postRow = (page: Page, id: string) => page.locator(`[data-testid="onscreen-post-row"][data-post="${id}"]`);
+
+test.beforeEach(async ({ request }) => {
+ test.setTimeout(120_000);
+ test.skip(
+ !readManifest().render.fontRegular,
+ "the deck refuses to draw without render.fontRegular/fontBold, and make-fixture.mjs found no font on this machine",
+ );
+ await reset(request);
+});
+
+test("the posts table shows each post with the clip the date rule puts it on", async ({ page, request }) => {
+ // The route first: the automatic clip and the effective one, and a slot in
+ // the estimated schedule.
+ const r = await rows(request);
+ expect(r["p-early"].auto).toMatchObject({ entryId: "c01", rule: "first", label: "and because" });
+ expect(r["p-mid"].auto).toMatchObject({ entryId: "c01", rule: "date" });
+ expect(r["p-late"].auto).toMatchObject({ entryId: "c02", rule: "date", label: "another whole sentence" });
+ for (const id of IDS) expect(r[id].effective?.entryId).toBe(r[id].auto?.entryId);
+ expect(r["p-late"].timing?.segment).toBe("c02");
+
+ await openSection(page);
+ const late = postRow(page, "p-late");
+ await expect(late).toHaveAttribute("data-effective", "c02");
+ await expect(late.getByTestId("onscreen-post-platform")).toHaveText("x");
+ await expect(late.getByTestId("onscreen-post-handle")).toHaveText("@fixture");
+ await expect(late.getByTestId("onscreen-post-link")).toHaveAttribute("href", "https://x.com/fixture/status/1");
+ await expect(late.getByTestId("onscreen-post-text")).toHaveText("Two days after the second clip.");
+ await expect(late.getByTestId("onscreen-post-auto")).toHaveText("auto: c02 — another whole sentence");
+ await expect(late.getByTestId("onscreen-post-attach")).toHaveValue("");
+ await expect(late.getByTestId("onscreen-post-effective")).toContainText("automatic: c02 (2024-09-10)");
+ await expect(postRow(page, "p-early")).toHaveAttribute("data-effective", "c01");
+ await expect(postRow(page, "p-early").getByTestId("onscreen-post-auto")).toHaveText("auto: c01 — and because");
+ await expect(page.getByTestId("onscreen-posts-save")).toBeDisabled();
+});
+
+test("an override saves attachTo and moves the effective clip; auto deletes the key", async ({ page, request }) => {
+ await openSection(page);
+ const mid = postRow(page, "p-mid");
+ await mid.getByTestId("onscreen-post-attach").selectOption("c02");
+ await expect(mid).toHaveAttribute("data-dirty", "1");
+ await expect(mid).toHaveAttribute("data-effective", "c02");
+ await expect(mid.getByTestId("onscreen-post-effective")).toContainText("chosen: c02");
+ // Unsaved: nothing written yet.
+ expect("attachTo" in post("p-mid")).toBe(false);
+
+ const put = page.waitForRequest((q) => q.url().includes("/api/report/posts") && q.method() === "PUT");
+ await page.getByTestId("onscreen-posts-save").click();
+ // Only the post that changed, only the key that changed.
+ expect((await put).postDataJSON().posts).toEqual({ "p-mid": { attachTo: "c02" } });
+ await expect.poll(() => post("p-mid").attachTo).toBe("c02");
+ await expect(mid).toHaveAttribute("data-dirty", "0");
+ const r = await rows(request);
+ expect(r["p-mid"].effective).toMatchObject({ entryId: "c02", rule: "attachTo" });
+ // The automatic answer is still the date rule's.
+ expect(r["p-mid"].auto?.entryId).toBe("c01");
+ expect(r["p-mid"].timing?.segment).toBe("c02");
+
+ // Back to auto: the key goes, it is not left as null.
+ await page.reload();
+ await expect(postRow(page, "p-mid").getByTestId("onscreen-post-attach")).toHaveValue("c02");
+ await postRow(page, "p-mid").getByTestId("onscreen-post-attach").selectOption("");
+ await page.getByTestId("onscreen-posts-save").click();
+ await expect.poll(() => "attachTo" in post("p-mid")).toBe(false);
+ await expect(postRow(page, "p-mid")).toHaveAttribute("data-effective", "c01");
+});
+
+test("hide persists across a reload, and showing it again deletes the key", async ({ page, request }) => {
+ await openSection(page);
+ const early = postRow(page, "p-early");
+ await early.getByTestId("onscreen-post-hide").check();
+ await expect(early).toHaveAttribute("data-hidden", "1");
+ await expect(early.getByTestId("onscreen-post-attach")).toBeDisabled();
+ await page.getByTestId("onscreen-posts-save").click();
+ await expect.poll(() => post("p-early").hide).toBe(true);
+
+ const r = await rows(request);
+ expect(r["p-early"].effective).toBeNull();
+ // It still says where it WOULD ride.
+ expect(r["p-early"].auto?.entryId).toBe("c01");
+ expect(r["p-early"].timing).toBeNull();
+
+ await page.reload();
+ await expect(postRow(page, "p-early").getByTestId("onscreen-post-hide")).toBeChecked();
+ await expect(postRow(page, "p-early")).toHaveAttribute("data-dirty", "0");
+ await expect(postRow(page, "p-early").getByTestId("onscreen-post-effective")).toContainText("hidden");
+
+ await postRow(page, "p-early").getByTestId("onscreen-post-hide").uncheck();
+ await page.getByTestId("onscreen-posts-save").click();
+ await expect.poll(() => "hide" in post("p-early")).toBe(false);
+});
+
+test("a stale token is a 409; reloading keeps the edit and shows the other writer's", async ({ page, request }) => {
+ await openSection(page);
+
+ // Somebody else writes after this page read its token.
+ await putPosts(request, { "p-late": { hide: true } });
+
+ await postRow(page, "p-mid").getByTestId("onscreen-post-attach").selectOption("c02");
+ const refused = page.waitForResponse((r) => r.url().includes("/api/report/posts") && r.request().method() === "PUT");
+ await page.getByTestId("onscreen-posts-save").click();
+ expect((await refused).status()).toBe(409);
+ await expect(page.getByTestId("onscreen-note")).toContainText("the manifest changed since you opened this");
+ expect("attachTo" in post("p-mid")).toBe(false);
+ expect(post("p-late").hide).toBe(true);
+
+ await page.getByTestId("onscreen-reload").click();
+ await expect(page.getByTestId("onscreen-reload")).toHaveCount(0);
+ // The unsaved override survives...
+ await expect(postRow(page, "p-mid").getByTestId("onscreen-post-attach")).toHaveValue("c02");
+ await expect(postRow(page, "p-mid")).toHaveAttribute("data-dirty", "1");
+ // ...and a post nobody touched here shows what the other writer saved,
+ // rather than reading as an edit the next save would quietly revert.
+ await expect(postRow(page, "p-late").getByTestId("onscreen-post-hide")).toBeChecked();
+ await expect(postRow(page, "p-late")).toHaveAttribute("data-dirty", "0");
+
+ const saved = page.waitForResponse((r) => r.url().includes("/api/report/posts") && r.request().method() === "PUT");
+ await page.getByTestId("onscreen-posts-save").click();
+ expect((await saved).status()).toBe(200);
+ await expect.poll(() => post("p-mid").attachTo).toBe("c02");
+ expect(post("p-late").hide).toBe(true);
+});
+
+test("the writer refuses an unknown post and a clip that is not one, and writes nothing", async ({ request }) => {
+ const before = readFileSync(path.join(DIR, "video.manifest.json"), "utf8");
+ const unknown = await request.put("/api/report/posts", {
+ data: { project: PROJECT, posts: { "p-mid": { hide: true }, nope: { hide: true } }, token: await token(request) },
+ });
+ expect(unknown.status()).toBe(400);
+ expect(((await unknown.json()) as { error: string }).error).toContain("no post with id nope");
+ const card = await request.put("/api/report/posts", {
+ data: { project: PROJECT, posts: { "p-mid": { attachTo: "k01" } }, token: await token(request) },
+ });
+ expect(card.status()).toBe(400);
+ expect(((await card.json()) as { errors: string[] }).errors).toEqual([
+ 'posts[1].attachTo "k01" is not a clip in the timeline',
+ ]);
+ expect(readFileSync(path.join(DIR, "video.manifest.json"), "utf8")).toBe(before);
+});
+
+test("the preview composes the posts region per window and overlays it while the scrubber is inside", async ({
+ page,
+ request,
+}) => {
+ // The route: one window per clip that carries posts, the deck's own
+ // postWindows over the schedule it returns, at postsGeometry.
+ const pv = (await (await request.post("/api/report/chrome/preview", { data: { project: PROJECT } })).json()) as {
+ schedule: Parameters<typeof postWindows>[0] & { total: number };
+ posts: {
+ geometry: { x: number; y: number; width: number; height: number };
+ windows: { segment: string; from: number; to: number; src?: string; error?: string }[];
+ };
+ };
+ expect(pv.posts.windows.map(({ segment, from, to }) => ({ segment, from, to }))).toEqual(postWindows(pv.schedule));
+ expect(pv.posts.windows.map((w) => w.segment)).toEqual(["c01", "c02"]);
+ expect(pv.posts.geometry.width).toBe(600);
+ // Every window composes: compose-chrome draws the posts region.
+ for (const w of pv.posts.windows) {
+ expect(w.error).toBeUndefined();
+ expect(w.src).toMatch(new RegExp(`posts-preview-${w.segment}/index\\.html`));
+ }
+
+ // The bench's strip asks for no posts windows at all.
+ const bench = (await (
+ await request.post("/api/report/chrome/preview", { data: { project: PROJECT, posts: false } })
+ ).json()) as { posts: { windows: unknown[] } };
+ expect(bench.posts.windows).toEqual([]);
+
+ await openSection(page);
+ await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 });
+ await expect(page.locator("[data-posts-window]")).toHaveCount(2);
+ // Outside every window: nothing over the footage. k01 starts after the last.
+ await page.locator('[data-seg-jump="k01"]').click();
+ await expect(page.getByTestId("onscreen-current")).toHaveText("k01");
+ await expect(page.getByTestId("onscreen-posts-preview")).toHaveCount(0);
+
+ await page.locator('[data-posts-window="c02"]').click();
+ const overlay = page.getByTestId("onscreen-posts-preview");
+ await expect(overlay).toHaveAttribute("data-segment", "c02");
+ await expect(postRow(page, "p-late")).toHaveAttribute("data-current", "1");
+ await expect(postRow(page, "p-mid")).toHaveAttribute("data-current", "0");
+ await expect(overlay).toHaveAttribute("data-posts-ready", "1", { timeout: 30_000 });
+ await expect(page.getByTestId("onscreen-posts-preview-iframe")).toHaveAttribute("src", /posts-preview-c02\/index\.html/);
+ await expect(overlay.getByTestId("onscreen-posts-preview-error")).toHaveCount(0);
+
+ // The overlay sits at the posts rect inside the 16:9 frame.
+ const frame = (await page.getByTestId("onscreen-preview").boundingBox())!;
+ const box = (await overlay.boundingBox())!;
+ const W = 1920;
+ expect(Math.abs((box.x - frame.x) / frame.width - pv.posts.geometry.x / W)).toBeLessThan(0.01);
+ expect(Math.abs(box.width / frame.width - pv.posts.geometry.width / W)).toBeLessThan(0.01);
+});
diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs
@@ -30,7 +30,7 @@ import {
rolesGaps,
} from "umtool-report-to-video/ledger-totals";
import { isCalendarDate } from "umtool-report-to-video/attribution";
-import { normalizeOnscreen, validateChrome } from "umtool-report-to-video/deck";
+import { normalizeOnscreen, validateChrome, validatePosts } from "umtool-report-to-video/deck";
// Its own write queue, not lib/state.ts's.
//
@@ -552,3 +552,129 @@ export class ChromeRefused extends Error {
this.errors = errors;
}
}
+
+
+// ---------------------------------------------------------------------------
+// POSTS: the two things a person decides about a post once an agent has put it
+// in the manifest -- which clip it rides on, and whether it is shown at all.
+//
+// Adding, removing or rewording a post is not here. A post is a quotation of
+// somebody else's words with a permalink; it is written by whoever cites it,
+// into the manifest, and this writer refuses to be a second way to do that.
+// ---------------------------------------------------------------------------
+
+const POST_PATCH_KEYS = ["attachTo", "hide"];
+
+/**
+ * A batch of post patches, checked for shape before anything is read: post id
+ * → `{ attachTo?: string | null, hide?: boolean }`. `attachTo: ""` is the
+ * same as null (a select's "auto").
+ *
+ * @param {unknown} posts
+ * @returns {Record<string, { attachTo?: string | null, hide?: boolean }>}
+ */
+export function normalizePostPatches(posts) {
+ if (!posts || typeof posts !== "object" || Array.isArray(posts)) {
+ throw new Error("posts must be an object of post id → { attachTo, hide }");
+ }
+ const ids = Object.keys(posts);
+ if (!ids.length) throw new Error("nothing to change");
+ /** @type {Record<string, { attachTo?: string | null, hide?: boolean }>} */
+ const out = {};
+ for (const id of ids) {
+ const v = /** @type {Record<string, unknown>} */ (posts)[id];
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
+ throw new Error(`${id}: a post patch must be an object with attachTo and/or hide`);
+ }
+ for (const k of Object.keys(v)) {
+ if (!POST_PATCH_KEYS.includes(k)) {
+ throw new Error(`${id}: ${k} is not something this writer changes (only attachTo and hide)`);
+ }
+ }
+ /** @type {{ attachTo?: string | null, hide?: boolean }} */
+ const p = {};
+ if ("attachTo" in v) {
+ const a = /** @type {Record<string, unknown>} */ (v).attachTo;
+ if (a === null || a === undefined || a === "") p.attachTo = null;
+ else if (typeof a === "string") p.attachTo = a;
+ else throw new Error(`${id}: attachTo must be a clip id, or null for the automatic clip`);
+ }
+ if ("hide" in v) {
+ const h = /** @type {Record<string, unknown>} */ (v).hide;
+ if (typeof h !== "boolean") throw new Error(`${id}: hide must be true or false`);
+ p.hide = h;
+ }
+ out[id] = p;
+ }
+ return out;
+}
+
+/**
+ * Patch the `attachTo` and `hide` of any number of posts, in one write.
+ *
+ * `attachTo: null` and `hide: false` DELETE the key -- the automatic clip and
+ * a shown post are what an absent key already says, and a manifest read by
+ * humans should not carry `"hide": false` as if somebody decided it. A key the
+ * patch does not name is left alone.
+ *
+ * ALL OR NOTHING: an unknown post id, a shape this writer does not take, or a
+ * result validatePosts refuses (an attachTo naming no clip in the timeline)
+ * fails the whole call before anything is written. validatePosts is the
+ * build's own check, so a manifest this accepts is one the build accepts.
+ *
+ * @param {string} dir
+ * @param {Record<string, { attachTo?: string | null, hide?: boolean }>} posts
+ * @param {{ token?: string | null }} [opts]
+ * @returns {Promise<{ posts: Record<string, { attachTo: string | null, hide: boolean }>, token: string | null }>}
+ */
+export async function updatePosts(dir, posts, { token = null } = {}) {
+ const next = normalizePostPatches(posts);
+ const ids = Object.keys(next);
+
+ return withManifestLock(async () => {
+ const current = await manifestToken(dir);
+ if (token !== null && current !== token) throw new StaleToken(token, current);
+
+ const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8"));
+ if (!Array.isArray(manifest.posts)) {
+ throw new Error("this manifest has no posts — they are added by editing the manifest, not here");
+ }
+ const byId = new Map(manifest.posts.map((p) => [p?.id, p]));
+ const unknown = ids.filter((id) => !byId.has(id));
+ if (unknown.length) throw new Error(`no post with id ${unknown.join(", ")} — nothing was written`);
+
+ for (const id of ids) {
+ const post = byId.get(id);
+ const p = next[id];
+ if ("attachTo" in p) {
+ if (p.attachTo) post.attachTo = p.attachTo;
+ else delete post.attachTo;
+ }
+ if ("hide" in p) {
+ if (p.hide) post.hide = true;
+ else delete post.hide;
+ }
+ }
+ const errors = validatePosts(manifest.posts, manifest.timeline ?? [], manifest.render);
+ if (errors.length) throw new PostsRefused(errors);
+
+ const nextToken = await writeManifestAtomic(dir, manifest);
+ /** @type {Record<string, { attachTo: string | null, hide: boolean }>} */
+ const saved = {};
+ for (const id of ids) {
+ const post = byId.get(id);
+ saved[id] = { attachTo: post.attachTo ?? null, hide: post.hide === true };
+ }
+ return { posts: saved, token: nextToken };
+ });
+}
+
+/** validatePosts' sentences, thrown whole so a route can return each one. */
+export class PostsRefused extends Error {
+ /** @param {string[]} errors */
+ constructor(errors) {
+ super(`posts: ${errors.join("; ")}`);
+ this.name = "PostsRefused";
+ this.errors = errors;
+ }
+}
diff --git a/umtool/lib/report/manifest.test.mjs b/umtool/lib/report/manifest.test.mjs
@@ -13,11 +13,14 @@ import test from "node:test";
import {
ChromeRefused,
MANIFEST_NAME,
+ PostsRefused,
StaleToken,
manifestToken,
+ normalizePostPatches,
updateChrome,
updateClip,
updateOnscreen,
+ updatePosts,
} from "./manifest.mjs";
const base = () => ({
@@ -294,3 +297,96 @@ test("the writers queue: concurrent saves of different fields all land", async (
await rm(dir, { recursive: true, force: true });
}
});
+
+
+// ---- updatePosts ------------------------------------------------------------
+
+const withPosts = () => ({
+ ...base(),
+ posts: [
+ { id: "p1", platform: "bluesky", handle: "a.test", date: "2024-09-05T10:00:00Z", text: "one", url: "https://bsky.app/p/1" },
+ { id: "p2", platform: "x", handle: "b", date: "2024-09-12", text: "two", url: "https://x.com/b/status/2", attachTo: "c01", hide: true },
+ ],
+});
+const post = (m, id) => m.posts.find((p) => p.id === id);
+
+test("updatePosts: attachTo and hide round trip; null and false delete the key; the rest is untouched", async () => {
+ const dir = await project(withPosts());
+ try {
+ const token = await manifestToken(dir);
+ const res = await updatePosts(dir, { p1: { attachTo: "c02", hide: true }, p2: { attachTo: null, hide: false } }, { token });
+ assert.deepEqual(res.posts, { p1: { attachTo: "c02", hide: true }, p2: { attachTo: null, hide: false } });
+ assert.equal(res.token, await manifestToken(dir));
+ const raw = await readRaw(dir);
+ assert.ok(raw.startsWith('{\n "slug"') && raw.endsWith("}\n"), "the CLI's formatting kept");
+ const m = JSON.parse(raw);
+ assert.equal(post(m, "p1").attachTo, "c02");
+ assert.equal(post(m, "p1").hide, true);
+ assert.ok(!("attachTo" in post(m, "p2")), "attachTo: null deletes the key");
+ assert.ok(!("hide" in post(m, "p2")), "hide: false deletes the key");
+ assert.equal(post(m, "p2").text, "two");
+ assert.deepEqual(m.timeline, base().timeline);
+ assert.ok(await stat(path.join(dir, `${MANIFEST_NAME}.bak`)));
+
+ // A key the patch does not name stays; "" is the select's "auto".
+ await updatePosts(dir, { p1: { hide: false } });
+ assert.equal(post(await read(dir), "p1").attachTo, "c02");
+ await updatePosts(dir, { p1: { attachTo: "" } });
+ assert.deepEqual(post(await read(dir), "p1"), withPosts().posts[0]);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updatePosts: an unknown post id refuses the WHOLE batch and writes nothing", async () => {
+ const dir = await project(withPosts());
+ try {
+ const before = await readRaw(dir);
+ await assert.rejects(updatePosts(dir, { p1: { hide: true }, nope: { hide: true } }), /no post with id nope/);
+ assert.equal(await readRaw(dir), before);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updatePosts: an attachTo validatePosts refuses comes back as its sentence, and nothing is written", async () => {
+ const dir = await project(withPosts());
+ try {
+ const before = await readRaw(dir);
+ // k1 is a card, not a clip: a post rides on footage.
+ await assert.rejects(updatePosts(dir, { p1: { hide: true }, p2: { attachTo: "k1" } }), (e) => {
+ assert.ok(e instanceof PostsRefused);
+ assert.deepEqual(e.errors, ['posts[1].attachTo "k1" is not a clip in the timeline']);
+ return true;
+ });
+ assert.equal(await readRaw(dir), before);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updatePosts: a stale token is refused and writes nothing", async () => {
+ const dir = await project(withPosts());
+ try {
+ const before = await readRaw(dir);
+ await assert.rejects(updatePosts(dir, { p1: { hide: true } }, { token: "1" }), (e) => e instanceof StaleToken);
+ assert.equal(await readRaw(dir), before);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updatePosts: no posts in the manifest, or a patch it does not take, is refused", async () => {
+ const dir = await project();
+ try {
+ await assert.rejects(updatePosts(dir, { p1: { hide: true } }), /has no posts/);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ assert.throws(() => normalizePostPatches({}), /nothing to change/);
+ assert.throws(() => normalizePostPatches([]), /must be an object/);
+ assert.throws(() => normalizePostPatches({ p1: { text: "new words" } }), /text is not something this writer changes/);
+ assert.throws(() => normalizePostPatches({ p1: { hide: "yes" } }), /p1: hide must be true or false/);
+ assert.throws(() => normalizePostPatches({ p1: { attachTo: 3 } }), /p1: attachTo must be a clip id/);
+ assert.deepEqual(normalizePostPatches({ p1: { attachTo: "" }, p2: {} }), { p1: { attachTo: null }, p2: {} });
+});
diff --git a/umtool/lib/report/onscreen.mjs b/umtool/lib/report/onscreen.mjs
@@ -19,7 +19,16 @@ import { mkdir, readFile, rm } from "node:fs/promises";
import path from "node:path";
import { composeChrome } from "umtool-report-to-video/compose-chrome";
import { selectVariant } from "umtool-report-to-video/build-video";
-import { deckText, estimateSchedule, normalizeOnscreen, resolveDeck } from "umtool-report-to-video/deck";
+import {
+ attachPosts,
+ clipDay,
+ deckText,
+ estimateSchedule,
+ normalizeOnscreen,
+ postSchedule,
+ postWindows,
+ resolveDeck,
+} from "umtool-report-to-video/deck";
import {
channelsDirFor,
cuePathFor,
@@ -27,7 +36,8 @@ import {
manifestPath,
readCues,
} from "../projects/report.mjs";
-import { deckPreviewDir } from "./serve.mjs";
+import { normalizePostPatches } from "./manifest.mjs";
+import { deckPreviewDir, postsPreviewDir } from "./serve.mjs";
/** The schedule document deck.mjs defines, built or estimated. */
/** @typedef {ReturnType<typeof estimateSchedule>} DeckSchedule */
@@ -107,16 +117,25 @@ export function scheduleMatches(schedule, entries) {
* subtitle override and no cue file to read keeps the build's auto subtitle,
* which came from the fetched file's own metadata.
*
+ * The posts are placed again the same way, with postSchedule over the
+ * build's segments: an override or a hide saved since the build moves them,
+ * and the build's `posts` would show where they were.
+ *
* Without a build schedule the draft is applied to the entries and the whole
* cut is estimated.
*
+ * `postsDraft` (post id → `{attachTo?, hide?}`, the shape PUT
+ * /api/report/posts takes) is applied to the manifest's posts in both cases.
+ *
* @param {{ variantManifest: Record<string, any>, built: Record<string, any> | null,
* draft: Map<string, { title?: string, subtitle?: string } | null>,
- * metas: Array<Record<string, any> | null> }} args
+ * metas: Array<Record<string, any> | null>,
+ * postsDraft?: Record<string, { attachTo?: string | null, hide?: boolean }> }} args
* @returns {DeckSchedule}
*/
-export function previewSchedule({ variantManifest, built, draft, metas }) {
+export function previewSchedule({ variantManifest, built, draft, metas, postsDraft = {} }) {
const entries = variantManifest.timeline ?? [];
+ const posts = applyPostsDraft(variantManifest.posts ?? [], postsDraft);
const patched = (e) => {
if (!draft.has(e.id)) return e;
const v = draft.get(e.id);
@@ -128,20 +147,134 @@ export function previewSchedule({ variantManifest, built, draft, metas }) {
const render = variantManifest.render ?? {};
const deck = resolveDeck(render);
const provenance = variantManifest.provenance ?? {};
+ const patchedEntries = entries.map(patched);
+ const { posts: _builtPosts, ...rest } = built;
+ const placed = deck.posts.show
+ ? postSchedule({
+ posts,
+ entries: patchedEntries,
+ metas,
+ segments: built.segments,
+ D: built.transition,
+ total: built.total,
+ render,
+ })
+ : [];
+ const round = (v) => Math.round(v * 1000) / 1000;
return {
- ...built,
+ ...rest,
segments: built.segments.map((s, i) => {
- const e = patched(entries[i]);
+ const e = patchedEntries[i];
const meta = metas[i] ?? null;
const { title, subtitle } = deckText(e, meta, provenance, deck, built.multiChannel);
const keepBuilt = e.type === "clip" && e.onscreen?.subtitle === undefined && !meta;
return { ...s, title, subtitle: keepBuilt ? s.subtitle : subtitle };
}),
+ ...(placed.length ? { posts: placed.map((p) => ({ ...p, appear: round(p.appear), out: p.out.map(round) })) } : {}),
};
}
- return estimateSchedule({ ...variantManifest, timeline: entries.map(patched) }, { metas });
+ return estimateSchedule({ ...variantManifest, posts, timeline: entries.map(patched) }, { metas });
+}
+
+/**
+ * The manifest's posts with unsaved patches on top, by updatePosts' rule:
+ * `attachTo: null` and `hide: false` remove the key. Pure; ids the draft names
+ * that are not posts are ignored here (the writer is what refuses them).
+ *
+ * @param {Array<Record<string, any>>} posts
+ * @param {Record<string, { attachTo?: string | null, hide?: boolean }>} draft
+ */
+export function applyPostsDraft(posts, draft = {}) {
+ if (!draft || !Object.keys(draft).length) return posts;
+ return posts.map((p) => {
+ const d = draft[p.id];
+ if (!d) return p;
+ const out = { ...p };
+ if ("attachTo" in d) {
+ if (d.attachTo) out.attachTo = d.attachTo;
+ else delete out.attachTo;
+ }
+ if ("hide" in d) {
+ if (d.hide) out.hide = true;
+ else delete out.hide;
+ }
+ return out;
+ });
+}
+
+/**
+ * A posts draft from the client, normalised the writer's way; an absent or
+ * empty draft is `{}`.
+ *
+ * @param {unknown} draft
+ */
+export function normalizePostsDraft(draft) {
+ if (draft === undefined || draft === null) return {};
+ if (typeof draft === "object" && !Array.isArray(draft) && !Object.keys(draft).length) return {};
+ return normalizePostPatches(draft);
+}
+
+/**
+ * What a person reads off a clip in a "rides on" select: the deck title when
+ * there is one, else the quote, else the stream's own title.
+ *
+ * @param {Record<string, any>} entry
+ * @param {Record<string, any> | null} meta
+ */
+export function clipLabel(entry, meta) {
+ return String(entry.onscreen?.title ?? entry.quote ?? entry.title ?? meta?.title ?? "").trim();
+}
+
+/**
+ * The posts table's rows, for one cut. Pure.
+ *
+ * Every post the manifest carries, hidden or not, with:
+ * - `auto`: the clip the date rule picks with no override and not hidden --
+ * attachPosts on the post stripped of `attachTo` and `hide`, so a hidden
+ * post still says where it WOULD ride;
+ * - `effective`: where it rides as saved (null when hidden);
+ * - `timing`: its slot in `schedule.posts` when a schedule places it.
+ *
+ * `clips` is every clip of the cut, in order, for the override select.
+ *
+ * @param {{ variantManifest: Record<string, any>, metas: Array<Record<string, any> | null>,
+ * schedule?: Record<string, any> | null }} args
+ */
+export function postRows({ variantManifest, metas, schedule = null }) {
+ const entries = variantManifest.timeline ?? [];
+ const posts = Array.isArray(variantManifest.posts) ? variantManifest.posts : [];
+ const labelOf = new Map(entries.map((e, i) => [e.id, clipLabel(e, metas[i] ?? null)]));
+ const bare = posts.map(({ attachTo: _a, hide: _h, ...p }) => p);
+ const auto = new Map(attachPosts({ posts: bare, entries, metas }).map((a) => [a.id, a]));
+ const effective = new Map(attachPosts({ posts, entries, metas }).map((a) => [a.id, a]));
+ const timing = new Map((schedule?.posts ?? []).map((p) => [p.id, p]));
+ const where = (a) => (a ? { entryId: a.entryId, rule: a.rule, clipDay: a.clipDay, label: labelOf.get(a.entryId) ?? "" } : null);
+ return {
+ posts: posts.map((p) => {
+ const t = timing.get(p.id);
+ return {
+ id: p.id,
+ platform: p.platform,
+ author: p.author ?? "",
+ handle: p.handle ?? "",
+ date: p.date,
+ text: p.text,
+ url: p.url,
+ attachTo: p.attachTo ?? null,
+ hide: p.hide === true,
+ auto: where(auto.get(p.id)),
+ effective: p.hide ? null : where(effective.get(p.id)),
+ timing: t ? { segment: t.segment, slot: t.slot, of: t.of, appear: t.appear, out: t.out } : null,
+ };
+ }),
+ clips: entries
+ .map((e, i) => ({ e, i }))
+ .filter(({ e }) => e.type === "clip")
+ .map(({ e, i }) => ({ id: e.id, label: labelOf.get(e.id) ?? "", day: clipDay(e, metas[i] ?? null) })),
+ };
}
+
/** out/<variant>/schedule.json when it is the deck's, else null. */
async function readBuiltSchedule(dir, variant) {
try {
@@ -161,14 +294,14 @@ async function readBuiltSchedule(dir, variant) {
* @param {string} variant
* @param {Map<string, { title?: string, subtitle?: string } | null>} draft
*/
-export async function scheduleForPreview(project, manifest, variant, draft = new Map()) {
+export async function scheduleForPreview(project, manifest, variant, draft = new Map(), postsDraft = {}) {
const variantManifest = selectVariant(manifest, variant);
const entries = variantManifest.timeline ?? [];
const [built, metas] = await Promise.all([
readBuiltSchedule(project.dir, variant),
deckMetas(project.dir, manifest, entries),
]);
- return { variantManifest, schedule: previewSchedule({ variantManifest, built, draft, metas }) };
+ return { variantManifest, metas, schedule: previewSchedule({ variantManifest, built, draft, metas, postsDraft }) };
}
// compose-chrome writes one directory per cut. Two requests composing into it
@@ -221,6 +354,67 @@ export async function composeDeckPreview(project, variant, schedule) {
}
/**
+ * Compose the PREVIEW of the posts region for one window. No render.
+ *
+ * The posts region is compose-chrome's (`region: "posts"`, one project per
+ * window under out/<variant>/chrome/posts-preview-<segment>/), and this is the
+ * ONE place umtool calls it: if its arguments change, they change here.
+ * `compose` is injectable for the unit test; the routes never pass it.
+ *
+ * @param {{ dir: string }} project
+ * @param {string} variant
+ * @param {Record<string, any>} schedule
+ * @param {{ segment: string, from: number, to: number }} window
+ * @param {{ compose?: (args: Record<string, unknown>) => Promise<any> }} [opts]
+ */
+export async function composePostsPreview(project, variant, schedule, window, { compose = composeChrome } = {}) {
+ const outDir = path.join(project.dir, "out", variant);
+ const want = postsPreviewDir(project.dir, variant, window.segment);
+ return serialised(want, async () => {
+ /** @type {Record<string, unknown>} */
+ const args = {
+ manifestPath: manifestPath(project.dir),
+ outDir,
+ variant,
+ region: "posts",
+ window: { segment: window.segment, from: window.from, to: window.to },
+ schedule,
+ preview: true,
+ };
+ const r = await compose(/** @type {any} */ (args));
+ if (r?.projDir && path.resolve(r.projDir) !== path.resolve(want)) {
+ throw new Error(`compose-chrome wrote the posts preview to ${r.projDir}, not ${want}`);
+ }
+ return r;
+ });
+}
+
+/**
+ * Every posts window of a schedule, composed for the preview. A window that
+ * fails says why beside it rather than failing the deck's preview: the deck
+ * is drawn either way, and a posts region that is not there yet is a fact
+ * about the pipeline, not about this cut.
+ *
+ * @param {{ dir: string }} project
+ * @param {string} variant
+ * @param {Record<string, any>} schedule
+ * @param {{ compose?: (args: Record<string, unknown>) => Promise<any> }} [opts]
+ * @returns {Promise<Array<{ segment: string, from: number, to: number, ok: boolean, error?: string }>>}
+ */
+export async function composePostsPreviews(project, variant, schedule, opts = {}) {
+ const out = [];
+ for (const w of postWindows(schedule)) {
+ try {
+ await composePostsPreview(project, variant, schedule, w, opts);
+ out.push({ ...w, ok: true });
+ } catch (e) {
+ out.push({ ...w, ok: false, error: e instanceof Error ? e.message : String(e) });
+ }
+ }
+ return out;
+}
+
+/**
* A true still of the deck at `t`: the composition, screenshotted by the
* render browser, as PNG bytes. Composed into the preview project (never the
* build's), into a scratch file that is removed once read.
diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs
@@ -2,10 +2,23 @@
//
// Run with: pnpm test:scripts
import assert from "node:assert/strict";
+import path from "node:path";
import test from "node:test";
import { estimateSchedule } from "umtool-report-to-video/deck";
-import { normalizeDraft, previewSchedule, scheduleMatches, stillTimeOf } from "./onscreen.mjs";
+import { postWindows } from "umtool-report-to-video/deck";
+import {
+ applyPostsDraft,
+ clipLabel,
+ composePostsPreview,
+ composePostsPreviews,
+ normalizeDraft,
+ normalizePostsDraft,
+ postRows,
+ previewSchedule,
+ scheduleMatches,
+ stillTimeOf,
+} from "./onscreen.mjs";
const cut = () => ({
slug: "t",
@@ -103,3 +116,131 @@ test("stillTimeOf: the middle of an entry's segment", () => {
assert.equal(stillTimeOf(built(), "c01"), 9.45);
assert.equal(stillTimeOf(built(), "nope"), null);
});
+
+
+// ---- posts --------------------------------------------------------------------
+
+// c01's record is from Sep 3, c02's from Sep 10 (metas above).
+const POSTS = [
+ { id: "p1", platform: "bluesky", handle: "a.test", date: "2024-09-05T10:00:00Z", text: "one", url: "https://bsky.app/p/1" },
+ { id: "p2", platform: "x", handle: "b", date: "2024-09-12", text: "two", url: "https://x.com/b/status/2" },
+ { id: "p3", platform: "bluesky", handle: "a.test", date: "2024-08-01", text: "older than every clip", url: "https://bsky.app/p/3" },
+];
+const withPosts = (posts = POSTS) => ({ ...cut(), posts: posts.map((p) => ({ ...p })) });
+
+test("a matching build schedule: posts are placed again over the build's segments, from the manifest NOW", () => {
+ // The build placed them somewhere else; a hide saved since must not show.
+ const b = { ...built(), posts: [{ id: "p2", segment: "c01", slot: 0, of: 1, appear: 1, out: [2, 3] }] };
+ const m = withPosts();
+ m.posts[1].attachTo = "c01";
+ const s = previewSchedule({ variantManifest: m, built: b, draft: new Map(), metas });
+ const by = Object.fromEntries(s.posts.map((p) => [p.id, p]));
+ // c01 leaves at c02's start (13.9) and carries p3, p1 and p2, oldest first.
+ assert.deepEqual(s.posts.filter((p) => p.segment === "c01").map((p) => p.id), ["p3", "p1", "p2"]);
+ assert.equal(by.p2.appear, 11.9);
+ assert.deepEqual(by.p2.out, [13.9, 14.4]);
+ assert.equal(by.p3.appear, 7.9);
+
+ // A draft unhides nothing and moves p2 back to its own date's clip.
+ const d = previewSchedule({ variantManifest: m, built: b, draft: new Map(), metas, postsDraft: { p2: { attachTo: null } } });
+ assert.equal(d.posts.find((p) => p.id === "p2").segment, "c02");
+ // c02 is the last segment: p2 leaves 0.3 s before the end.
+ assert.deepEqual(d.posts.find((p) => p.id === "p2").out, [25.6, 25.9]);
+ assert.deepEqual(postWindows(d).map((w) => w.segment), ["c01", "c02"]);
+
+ // All hidden: no posts key, as a cut without them.
+ const none = previewSchedule({
+ variantManifest: m, built: b, draft: new Map(), metas,
+ postsDraft: { p1: { hide: true }, p2: { hide: true }, p3: { hide: true } },
+ });
+ assert.ok(!("posts" in none));
+});
+
+test("no build schedule: the estimate places the posts with the draft applied", () => {
+ const s = previewSchedule({ variantManifest: withPosts(), built: null, draft: new Map(), metas, postsDraft: { p3: { hide: true } } });
+ assert.equal(s.estimated, true);
+ assert.deepEqual(s.posts.map((p) => [p.id, p.segment]), [["p1", "c01"], ["p2", "c02"]]);
+});
+
+test("applyPostsDraft: the writer's rule, on a copy", () => {
+ const posts = withPosts().posts;
+ posts[0].hide = true;
+ const out = applyPostsDraft(posts, { p1: { hide: false, attachTo: "c02" }, p2: { attachTo: null }, zz: { hide: true } });
+ assert.ok(!("hide" in out[0]));
+ assert.equal(out[0].attachTo, "c02");
+ assert.ok(!("attachTo" in out[1]));
+ assert.equal(posts[0].hide, true, "the input is not mutated");
+ assert.equal(applyPostsDraft(posts, {}), posts);
+ assert.deepEqual(normalizePostsDraft(undefined), {});
+ assert.deepEqual(normalizePostsDraft({}), {});
+ assert.throws(() => normalizePostsDraft({ p1: { text: "x" } }), /only attachTo and hide/);
+});
+
+test("postRows: the automatic clip, the effective one, and the slot in the schedule", () => {
+ const m = withPosts();
+ m.posts[0].attachTo = "c02"; // p1 overridden
+ m.posts[1].hide = true; // p2 hidden
+ const schedule = previewSchedule({ variantManifest: m, built: built(), draft: new Map(), metas });
+ const { posts, clips } = postRows({ variantManifest: m, metas, schedule });
+ const by = Object.fromEntries(posts.map((p) => [p.id, p]));
+
+ assert.deepEqual(by.p1.auto, { entryId: "c01", rule: "date", clipDay: "2024-09-03", label: "Stream one" });
+ assert.equal(by.p1.effective.entryId, "c02");
+ assert.equal(by.p1.effective.rule, "attachTo");
+ assert.equal(by.p1.attachTo, "c02");
+ assert.equal(by.p1.effective.label, "Saved", "a clip's label is its deck title when it has one");
+
+ // Hidden: it still says where it WOULD ride, and rides nowhere.
+ assert.equal(by.p2.hide, true);
+ assert.equal(by.p2.auto.entryId, "c02");
+ assert.equal(by.p2.effective, null);
+ assert.equal(by.p2.timing, null);
+
+ assert.equal(by.p3.auto.rule, "first");
+ assert.deepEqual(by.p3.timing, { segment: "c01", slot: 0, of: 1, appear: 11.9, out: [13.9, 14.4] });
+ assert.deepEqual(clips, [
+ { id: "c01", label: "Stream one", day: "2024-09-03" },
+ { id: "c02", label: "Saved", day: "2024-09-10" },
+ ]);
+ assert.deepEqual(postRows({ variantManifest: cut(), metas }).posts, []);
+});
+
+test("clipLabel: deck title, then the quote, then the stream's title", () => {
+ assert.equal(clipLabel({ onscreen: { title: "T" }, quote: "q" }, { title: "m" }), "T");
+ assert.equal(clipLabel({ quote: " q " }, { title: "m" }), "q");
+ assert.equal(clipLabel({}, { title: "m" }), "m");
+ assert.equal(clipLabel({}, null), "");
+});
+
+test("composePostsPreview: ONE call per window, region posts, preview, into posts-preview-<segment>", async () => {
+ const calls = [];
+ const project = { dir: "/proj" };
+ const compose = async (args) => {
+ calls.push(args);
+ return { projDir: path.join(args.outDir, "chrome", `posts-preview-${args.window.segment}`) };
+ };
+ const schedule = previewSchedule({ variantManifest: withPosts(), built: built(), draft: new Map(), metas });
+ const res = await composePostsPreviews(project, "sourced", schedule, { compose });
+ assert.deepEqual(res.map((w) => [w.segment, w.ok]), [["c01", true], ["c02", true]]);
+ assert.deepEqual(calls[0], {
+ manifestPath: path.join("/proj", "video.manifest.json"),
+ outDir: path.join("/proj", "out", "sourced"),
+ variant: "sourced",
+ region: "posts",
+ window: postWindows(schedule)[0],
+ schedule,
+ preview: true,
+ });
+
+ // A composition written anywhere else is an error, said beside its window.
+ const wrong = async () => ({ projDir: "/elsewhere" });
+ await assert.rejects(
+ composePostsPreview(project, "sourced", schedule, postWindows(schedule)[0], { compose: wrong }),
+ /not \/proj\/out\/sourced\/chrome\/posts-preview-c01/,
+ );
+ const failing = async () => {
+ throw new Error("unknown chrome region: posts");
+ };
+ const failed = await composePostsPreviews(project, "sourced", schedule, { compose: failing });
+ assert.deepEqual(failed.map((w) => [w.ok, w.error]), [[false, "unknown chrome region: posts"], [false, "unknown chrome region: posts"]]);
+});
diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs
@@ -312,3 +312,51 @@ export async function deckPreviewFile(dir, segments) {
if (!st?.isFile()) return null;
return { abs: realAbs, size: st.size };
}
+
+// ---------------------------------------------------------------------------
+// The posts region's preview compositions: one per window, each its own
+// project under out/<variant>/chrome/posts-preview-<segment>/ (compose-chrome
+// names it; onscreen.mjs checks the name). Served by the same files route
+// under one more path segment, `posts-preview-<segment>`, and confined by the
+// same deckPreviewFile once the directory is chosen.
+// ---------------------------------------------------------------------------
+
+const POSTS_PREVIEW_PREFIX = "posts-preview-";
+
+/** The preview project of the posts window on one segment. */
+export const postsPreviewDir = (projectDir, variant, segment) =>
+ path.join(projectDir, "out", variant, "chrome", `${POSTS_PREVIEW_PREFIX}${segment}`);
+
+/** The iframe src for one posts window's preview composition. */
+export const postsPreviewSrc = (projectId, variant, segment) =>
+ `/api/report/chrome/files/${encodeProjectSegment(projectId)}/${variant}/${POSTS_PREVIEW_PREFIX}${encodeURIComponent(segment)}/index.html`;
+
+/**
+ * Which preview directory a files request is for, and the segments left to
+ * resolve inside it.
+ *
+ * `posts-preview-<segment>/…` is a posts window's project when `<segment>` is
+ * one of `segmentIds` -- the cut's own entry ids, which the caller reads from
+ * the manifest, so a name the client made up is not a directory this serves.
+ * Anything else is a file of the deck's preview, as it always was (the deck's
+ * project holds `index.html`, `assets/` and `hyperframes.json`, nothing named
+ * like this). The per-segment rules (no `..`, no slash) are deckPreviewFile's
+ * and still apply to everything after.
+ *
+ * @param {string} projectDir
+ * @param {string} variant
+ * @param {string[]} rest the url segments after `<project>/<variant>/`
+ * @param {Iterable<string>} segmentIds
+ * @returns {{ dir: string, rest: string[] } | null}
+ */
+export function previewDirFor(projectDir, variant, rest, segmentIds) {
+ if (!Array.isArray(rest) || !rest.length) return null;
+ const head = rest[0];
+ if (typeof head === "string" && head.startsWith(POSTS_PREVIEW_PREFIX)) {
+ const seg = head.slice(POSTS_PREVIEW_PREFIX.length);
+ if (!seg || /[\/\\\0]/.test(seg) || seg === "." || seg === "..") return null;
+ if (!new Set(segmentIds).has(seg)) return null;
+ return { dir: postsPreviewDir(projectDir, variant, seg), rest: rest.slice(1) };
+ }
+ return { dir: deckPreviewDir(projectDir, variant), rest };
+}
diff --git a/umtool/lib/report/serve.test.mjs b/umtool/lib/report/serve.test.mjs
@@ -15,6 +15,9 @@ import {
deckPreviewFile,
deckPreviewSrc,
encodeProjectSegment,
+ postsPreviewDir,
+ postsPreviewSrc,
+ previewDirFor,
rangeResponse,
} from "./serve.mjs";
@@ -183,3 +186,54 @@ async function realDir(p) {
const { realpath } = await import("node:fs/promises");
return realpath(p);
}
+
+
+test("previewDirFor: posts-preview-<segment> is a window's project only for an entry of the cut", () => {
+ const ids = ["c01", "c02", "k1"];
+ assert.deepEqual(previewDirFor("/p", "sourced", ["posts-preview-c02", "index.html"], ids), {
+ dir: postsPreviewDir("/p", "sourced", "c02"),
+ rest: ["index.html"],
+ });
+ assert.equal(postsPreviewDir("/p", "sourced", "c02"), path.join("/p", "out", "sourced", "chrome", "posts-preview-c02"));
+ // Everything else is the deck's preview, as before.
+ assert.deepEqual(previewDirFor("/p", "sourced", ["assets", "gsap.min.js"], ids), {
+ dir: deckPreviewDir("/p", "sourced"),
+ rest: ["assets", "gsap.min.js"],
+ });
+ // A name the client made up, or one that walks, is no directory at all.
+ for (const head of ["posts-preview-nope", "posts-preview-", "posts-preview-..", "posts-preview-.", "posts-preview-a/b"]) {
+ assert.equal(previewDirFor("/p", "sourced", [head, "index.html"], [...ids, "..", ".", "a/b"]), null, head);
+ }
+ assert.equal(previewDirFor("/p", "sourced", [], ids), null);
+ // The src the preview route hands out names the same directory.
+ assert.match(postsPreviewSrc("reports/x", "sourced", "c02"), /\/sourced\/posts-preview-c02\/index\.html$/);
+});
+
+test("deckPreviewFile under a posts window: confined to THAT window's directory", async () => {
+ const root = await mkdtemp(path.join(tmpdir(), "umtool-posts-preview-"));
+ try {
+ const win = postsPreviewDir(root, "sourced", "c02");
+ await mkdir(path.join(win, "assets"), { recursive: true });
+ await writeFile(path.join(win, "index.html"), "<html>posts</html>");
+ await writeFile(path.join(win, "assets", "qr.png"), "png");
+ await mkdir(deckPreviewDir(root, "sourced"), { recursive: true });
+ await writeFile(path.join(deckPreviewDir(root, "sourced"), "index.html"), "<html>deck</html>");
+
+ const at = (rest) => {
+ const w = previewDirFor(root, "sourced", rest, ["c02"]);
+ return w ? deckPreviewFile(w.dir, w.rest) : null;
+ };
+ assert.equal((await at(["posts-preview-c02", "index.html"]))?.abs, path.join(await realpathOf(win), "index.html"));
+ assert.ok(await at(["posts-preview-c02", "assets", "qr.png"]));
+ // The window's dir is no way up to the deck's, or out.
+ assert.equal(await at(["posts-preview-c02", "..", "deck-preview", "index.html"]), null);
+ assert.equal(await at(["posts-preview-c02"]), null);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+async function realpathOf(p) {
+ const { realpath } = await import("node:fs/promises");
+ return realpath(p);
+}
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` : "") +
@@ -944,7 +946,8 @@ async function qrForEntry(entry, provenance, render, outDir) {
"-s", String(q.scale ?? 4),
"-m", String(q.quiet ?? 3),
"-l", q.ecc ?? "M",
- url,
+ // `--`: a URL is data, never an option, whatever it starts with.
+ "--", url,
]);
return { png, url };
}
@@ -1724,9 +1727,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 +1750,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 +1766,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 +2322,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 +2349,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 +2489,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 +2594,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 ?? [], whole.render);
+ if (errors.length) throw new Error(`posts: ${errors.join("; ")}`);
+ }
// An `image` entry's `src` is relative to the MANIFEST, which is checked in
// 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,411 @@
+// 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, "&")
+ .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 });
+
+/** 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);
+}
+
+/**
+ * A module function written into the page under a FIXED name. The page calls
+ * it by that name, and `fn.toString()` alone would declare whatever name the
+ * function has here -- which a bundler minifying server code renames (umtool's
+ * production build turned `postsCues` into `d`, and the page then threw a
+ * ReferenceError and drew nothing). As a named const of a parenthesised
+ * function expression, the page's name never depends on the module's.
+ */
+export function embedFn(name, fn) {
+ return `const ${name} = (${fn.toString()});`;
+}
+
+/**
+ * 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;
+ ${embedFn("postsCues", postsCues)}
+
+ 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,433 @@
+// 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 });
+ }
+});
+
+test("embedFn: the page declares the planner under its own name, whatever the bundler called it", async () => {
+ const { embedFn } = await import("./chrome-posts.mjs");
+ // A minifier renames the module function; the page must still find `postsCues`.
+ const renamed = function d(a) { return a * 2; };
+ const src = embedFn("postsCues", renamed);
+ assert.equal(new Function(`${src}; return postsCues(21);`)(), 42);
+ // And the page the module writes uses it, not a bare toString().
+ const { postsCues } = await import("./chrome-posts.mjs");
+ assert.match(embedFn("postsCues", postsCues), /^const postsCues = \(function postsCues\(/);
+});
diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs
@@ -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);
@@ -545,7 +548,7 @@ export function chartBandHtml(manifest, totals, schedule, opts = {}) {
export async function qrPng(url, render, outPath, size) {
const q = render.qr ?? {};
const raw = `${outPath}.raw.png`;
- await run(QRENCODE, ["-o", raw, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3), "-l", q.ecc ?? "M", url]);
+ await run(QRENCODE, ["-o", raw, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3), "-l", q.ecc ?? "M", "--", url]);
await run(MAGICK, [raw, "-filter", "point", "-resize", `${size}x${size}!`, "-strip", outPath]);
await rm(raw, { force: true });
return outPath;
@@ -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
@@ -36,6 +36,9 @@ export const DECK_DEFAULTS = Object.freeze({
qr: Object.freeze({ show: true, size: 150 }),
overCards: "hide", // | "show"
motion: Object.freeze({ out: 0.3, in: 0.45, pip: 0.7 }),
+ // The manifest's `posts`, drawn as cards over the footage at the end of the
+ // clip each one is attached to. position | "top-left".
+ posts: Object.freeze({ show: true, seconds: 2, position: "top-right", width: 600, qrSize: 120, maxLines: 7, inset: 24 }),
});
/** Subtitle tokens a `subtitle.parts` array may name. "auto" picks from these. */
@@ -164,6 +167,21 @@ export function validateChrome(chrome, render = {}) {
errors.push(`${w}.qr.size ${size} does not fit a ${h}px deck (at most ${h - 20})`);
}
});
+ sub("posts", ["show", "seconds", "position", "width", "qrSize", "maxLines", "inset"], (p) => {
+ if (p.show !== undefined && typeof p.show !== "boolean") errors.push(`${w}.posts.show must be true or false`);
+ if (p.seconds !== undefined && !numIn(p.seconds, 0.5, 10)) errors.push(`${w}.posts.seconds must be from 0.5 to 10`);
+ if (p.position !== undefined && !["top-right", "top-left"].includes(p.position)) {
+ errors.push(`${w}.posts.position must be "top-right" or "top-left"`);
+ }
+ if (p.width !== undefined && !(Number.isInteger(p.width) && numIn(p.width, 320, 900))) {
+ errors.push(`${w}.posts.width must be a whole number of pixels from 320 to 900`);
+ }
+ if (p.qrSize !== undefined && !numIn(p.qrSize, 80, 200)) errors.push(`${w}.posts.qrSize must be from 80 to 200`);
+ if (p.maxLines !== undefined && !(Number.isInteger(p.maxLines) && numIn(p.maxLines, 2, 14))) {
+ errors.push(`${w}.posts.maxLines must be a whole number from 2 to 14`);
+ }
+ if (p.inset !== undefined && !numIn(p.inset, 0, 80)) errors.push(`${w}.posts.inset must be from 0 to 80`);
+ });
sub("motion", ["out", "in", "pip"], (m) => {
for (const k of ["out", "in"]) {
if (m[k] !== undefined && !numIn(m[k], 0, 2)) errors.push(`${w}.motion.${k} must be from 0 to 2 seconds`);
@@ -175,6 +193,10 @@ export function validateChrome(chrome, render = {}) {
// make the setting lie about what was drawn.
if (!errors.length) {
const g = deckGeometry({ ...render, chrome });
+ // Only a deck that sets `posts` and draws them is held to the column's fit
+ // here; one with posts on the defaults is checked by validatePosts, which
+ // sees the posts.
+ if (d.posts !== undefined && resolveDeck({ chrome }).posts.show) errors.push(...postsFitErrors({ ...render, chrome }));
const room = g.H - g.deck.height;
if (g.footage.height > room) {
const max = Math.floor((room / g.H) * 1000) / 1000;
@@ -431,11 +453,15 @@ export function deckQrUrl(entry, provenance = {}) {
* end: number, title: string, subtitle: string, qrUrl: string|null,
* hideDeck: boolean }> }}
*/
-export function deckSchedule({ entries, durs, D, render, provenance = {}, metas = [], estimated = false }) {
+export function deckSchedule({
+ entries, durs, D, render, provenance = {}, metas = [], estimated = false, posts = [],
+}) {
const deck = resolveDeck(render);
const { starts, total } = scheduleFrom(durs, D);
const multiChannel = isMultiChannel(entries, provenance);
const round = (v) => Math.round(v * 1000) / 1000;
+ const segs = entries.map((e, i) => ({ id: e.id, start: starts[i], duration: durs[i] }));
+ const placed = deck.posts.show ? postSchedule({ posts, entries, metas, segments: segs, D, total, render }) : [];
return {
version: 1,
kind: "deck",
@@ -458,6 +484,9 @@ export function deckSchedule({ entries, durs, D, render, provenance = {}, metas
hideDeck: hidesDeck(e, deck),
};
}),
+ // Present only when there are posts to draw, so a cut without them writes
+ // the schedule it always did.
+ ...(placed.length ? { posts: placed.map((p) => ({ ...p, appear: round(p.appear), out: p.out.map(round) })) } : {}),
};
}
@@ -467,6 +496,7 @@ export function estimateSchedule(manifest, { metas = [], noXfade = false } = {})
const entries = manifest.timeline ?? [];
return deckSchedule({
entries,
+ posts: manifest.posts ?? [],
durs: entries.map((e) => estimatedDuration(e, render)),
D: transitionOf(render, { noXfade }),
render,
@@ -540,6 +570,218 @@ export function pipSegments(schedule) {
}
// ---------------------------------------------------------------------------
+// Posts: written statements (a Bluesky or X post) drawn as cards over the
+// footage. A post is not a segment -- it has no footage of its own -- so it
+// rides on a clip: the clip whose recording most closely PRECEDES it, unless
+// the post names one with `attachTo`. A clip's posts appear at the end of it,
+// one every `posts.seconds`, stacking, so the last is up for the clip's last
+// `seconds`; all of them leave in the transition to the next segment.
+// ---------------------------------------------------------------------------
+
+export const POST_PLATFORMS = Object.freeze(["bluesky", "x"]);
+const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide"];
+
+/**
+ * Every reason the manifest's `posts` cannot be built, as sentences. `timeline`
+ * is the whole manifest's: an `attachTo` must name one of its clips.
+ *
+ * @returns {string[]}
+ */
+export function validatePosts(posts, timeline = [], render = null) {
+ if (posts === undefined || posts === null) return [];
+ if (!Array.isArray(posts)) return ["posts must be a list"];
+ const errors = [];
+ const clips = new Set(timeline.filter((e) => e.type === "clip").map((e) => e.id));
+ const seen = new Set();
+ posts.forEach((p, i) => {
+ const w = `posts[${i}]`;
+ if (!isObj(p)) { errors.push(`${w} must be an object`); return; }
+ unknownKeys(p, POST_KEYS, w, errors);
+ if (typeof p.id !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(p.id)) {
+ errors.push(`${w}.id must be letters, digits, dashes or underscores`);
+ } else if (seen.has(p.id)) errors.push(`${w}.id ${p.id} is used twice`);
+ else seen.add(p.id);
+ if (!POST_PLATFORMS.includes(p.platform)) errors.push(`${w}.platform must be ${POST_PLATFORMS.join(" or ")}`);
+ if (typeof p.date !== "string" || !/^\d{4}-\d{2}-\d{2}/.test(p.date) || Number.isNaN(Date.parse(p.date))) {
+ errors.push(`${w}.date must be an ISO date or date-time`);
+ }
+ if (typeof p.text !== "string" || !p.text.trim()) errors.push(`${w}.text must be the post's words`);
+ else if (p.text.length > 3000) errors.push(`${w}.text is ${p.text.length} characters (at most 3000)`);
+ if (typeof p.url !== "string" || !/^https:\/\/\S+$/.test(p.url)) errors.push(`${w}.url must be the post's https link`);
+ for (const k of ["author", "handle"]) {
+ if (p[k] !== undefined && typeof p[k] !== "string") errors.push(`${w}.${k} must be a string`);
+ }
+ if (p.attachTo !== undefined && p.attachTo !== null && !clips.has(p.attachTo)) {
+ errors.push(`${w}.attachTo ${JSON.stringify(p.attachTo)} is not a clip in the timeline`);
+ }
+ if (p.hide !== undefined && typeof p.hide !== "boolean") errors.push(`${w}.hide must be true or false`);
+ });
+ // Posts that will be drawn need a column that fits the footage box.
+ if (render && deckOn(render) && resolveDeck(render).posts.show && posts.some((p) => isObj(p) && !p.hide)) {
+ errors.push(...postsFitErrors(render));
+ }
+ return errors;
+}
+
+/** Why the posts column cannot be drawn in this frame, as sentences (empty: it can). */
+export function postsFitErrors(render) {
+ const w = "render.chrome.deck";
+ const g = deckGeometry(render);
+ const pp = resolveDeck(render).posts;
+ const errors = [];
+ if (pp.width + 2 * pp.inset > g.footage.width) {
+ errors.push(`${w}.posts.width ${pp.width} with inset ${pp.inset} does not fit the ${g.footage.width}px footage box`);
+ }
+ if (pp.qrSize > pp.width / 2) errors.push(`${w}.posts.qrSize ${pp.qrSize} is more than half the card's width`);
+ return errors;
+}
+
+/** The day a clip's recording is dated by: the clip's own `date`, else its record's upload date. */
+export function clipDay(entry, meta) {
+ if (entry.date) return entry.date;
+ const d = String(meta?.uploadDate ?? "");
+ return /^\d{8}$/.test(d) ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : null;
+}
+
+/**
+ * Which clip each post rides on. A post names its clip with `attachTo`, or
+ * takes the clip with the LATEST day on or before its own (ties: the later in
+ * the cut); a post older than every clip goes on the first. Hidden posts, and
+ * posts in a cut with no clips, are not placed.
+ *
+ * @returns {Array<{ id: string, entryId: string, rule: "attachTo"|"date"|"first", clipDay: string|null }>}
+ */
+export function attachPosts({ posts = [], entries = [], metas = [] }) {
+ const clips = entries
+ .map((e, i) => ({ e, i, day: e.type === "clip" ? clipDay(e, metas[i]) : null }))
+ .filter((c) => c.e.type === "clip");
+ if (!clips.length) return [];
+ const out = [];
+ for (const p of posts) {
+ if (p.hide) continue;
+ if (p.attachTo) {
+ const c = clips.find((x) => x.e.id === p.attachTo);
+ if (c) { out.push({ id: p.id, entryId: c.e.id, rule: "attachTo", clipDay: c.day }); continue; }
+ // Not in this cut (a variant left it out): fall through to the date rule.
+ }
+ const day = String(p.date).slice(0, 10);
+ let best = null;
+ for (const c of clips) {
+ if (!c.day || c.day > day) continue;
+ if (!best || c.day > best.day || (c.day === best.day && c.i > best.i)) best = c;
+ }
+ out.push(best
+ ? { id: p.id, entryId: best.e.id, rule: "date", clipDay: best.day }
+ : { id: p.id, entryId: clips[0].e.id, rule: "first", clipDay: clips[0].day });
+ }
+ return out;
+}
+
+/**
+ * When each placed post is on screen, in the cut's clock.
+ *
+ * A clip's posts (oldest first) share an anchor A: the start of the outgoing
+ * transition -- the next segment's start with a crossfade, 0.3 s before the cut
+ * on a hard cut, 0.3 s before the end on the last segment. Post j of k appears
+ * at A − step·(k − j), step = `posts.seconds`, so each has its seconds alone
+ * before the next stacks on and the last has the final seconds. A clip too
+ * short for that shares what it has after its incoming dissolve evenly. They
+ * all leave together over `out`.
+ *
+ * @returns {Array<{ id, segment, slot, of, appear, out: [number, number], date, text,
+ * author, handle, platform, url, qrUrl }>}
+ */
+export function postSchedule({ posts = [], entries, metas = [], segments, D, total, render }) {
+ const settings = resolveDeck(render).posts;
+ const byId = new Map(posts.map((p) => [p.id, p]));
+ const groups = new Map();
+ for (const a of attachPosts({ posts, entries, metas })) {
+ if (!groups.has(a.entryId)) groups.set(a.entryId, []);
+ groups.get(a.entryId).push(byId.get(a.id));
+ }
+ const out = [];
+ segments.forEach((seg, i) => {
+ const group = groups.get(seg.id);
+ if (!group) return;
+ group.sort((x, y) => Date.parse(x.date) - Date.parse(y.date));
+ const last = i === segments.length - 1;
+ const leave = last
+ ? [total - 0.3, total]
+ : D > 0
+ ? [segments[i + 1].start, segments[i + 1].start + D]
+ : [segments[i + 1].start - 0.3, segments[i + 1].start];
+ const A = leave[0];
+ const from = seg.start + (i > 0 ? D : 0);
+ const k = group.length;
+ const step = Math.min(settings.seconds, Math.max(0, A - from) / k);
+ group.forEach((p, j) => {
+ out.push({
+ id: p.id,
+ segment: seg.id,
+ slot: j,
+ of: k,
+ appear: A - step * (k - j),
+ out: leave,
+ date: p.date,
+ text: p.text,
+ author: p.author ?? "",
+ handle: p.handle ?? "",
+ platform: p.platform,
+ url: p.url,
+ qrUrl: p.url,
+ });
+ });
+ });
+ return out;
+}
+
+/**
+ * The windows the posts region is rendered for: one per clip that carries
+ * posts, from its first post's appearance to the end of its leave. Frames are
+ * only made for these seconds; the overlay places each at its `from`.
+ */
+export function postWindows(schedule) {
+ const by = new Map();
+ for (const p of schedule.posts ?? []) {
+ const w = by.get(p.segment) ?? { segment: p.segment, from: Infinity, to: -Infinity };
+ w.from = Math.min(w.from, p.appear);
+ w.to = Math.max(w.to, p.out[1]);
+ by.set(p.segment, w);
+ }
+ return [...by.values()].sort((a, b) => a.from - b.from);
+}
+
+/**
+ * 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.
+ */
+export function postsGeometry(render) {
+ const { footage: f } = deckGeometry(render);
+ const p = resolveDeck(render).posts;
+ const width = even(p.width);
+ const height = even(f.height - 2 * p.inset);
+ const x = p.position === "top-left" ? f.x + p.inset : f.x + f.width - p.inset - width;
+ return { x: Math.round(x), y: Math.round(f.y + p.inset), width, height };
+}
+
+// ---------------------------------------------------------------------------
// The render cache and the renderer command.
// ---------------------------------------------------------------------------
diff --git a/umtool/report-to-video/deck.test.mjs b/umtool/report-to-video/deck.test.mjs
@@ -239,3 +239,130 @@ test("hyperframesCommand: pinned by default, overridable", () => {
assert.equal(hyperframesCommand({ HYPERFRAMES_PKG: "hyperframes@1.0.0" }).version, "hyperframes@1.0.0");
assert.deepEqual(hyperframesCommand({ HYPERFRAMES_BIN: "/stub" }), { cmd: "/stub", args: [], version: "bin:/stub" });
});
+
+// ---- posts -----------------------------------------------------------------
+import { attachPosts, clipDay, postSchedule, postsGeometry, postWindows, validatePosts } from "./deck.mjs";
+
+const POST = (id, date, extra = {}) => ({
+ id, platform: "bluesky", date, text: `post ${id}`, url: `https://bsky.app/profile/a/post/${id}`, ...extra,
+});
+const CLIPS = [
+ { id: "c1", type: "clip", video: "a", start: 0, end: 10, date: "2024-09-05" },
+ { id: "c2", type: "clip", video: "b", start: 0, end: 12 }, // dated by its record
+ { id: "k1", type: "card", seconds: 4 },
+ { id: "c3", type: "clip", video: "c", start: 0, end: 4, date: "2025-12-08" },
+];
+const METAS = [null, { uploadDate: "20240929" }, null, null];
+
+test("clipDay: the clip's own date wins over its record's upload date", () => {
+ assert.equal(clipDay({ date: "2025-04-01" }, { uploadDate: "20260815" }), "2025-04-01");
+ assert.equal(clipDay({}, { uploadDate: "20240929" }), "2024-09-29");
+ assert.equal(clipDay({}, null), null);
+});
+
+test("attachPosts: the latest clip on or before the post; ties go later; attachTo wins; hidden skipped", () => {
+ const posts = [
+ POST("p1", "2024-10-19T16:53:06Z"), // after c2 (09-29), before c3
+ POST("p2", "2024-09-05T23:00:00Z"), // same day as c1
+ POST("p3", "2020-01-01"), // before every clip
+ POST("p4", "2026-02-09", { attachTo: "c1" }),
+ POST("p5", "2026-02-09", { hide: true }),
+ POST("p6", "2026-01-22"),
+ ];
+ assert.deepEqual(attachPosts({ posts, entries: CLIPS, metas: METAS }), [
+ { id: "p1", entryId: "c2", rule: "date", clipDay: "2024-09-29" },
+ { id: "p2", entryId: "c1", rule: "date", clipDay: "2024-09-05" },
+ { id: "p3", entryId: "c1", rule: "first", clipDay: "2024-09-05" },
+ { id: "p4", entryId: "c1", rule: "attachTo", clipDay: "2024-09-05" },
+ { id: "p6", entryId: "c3", rule: "date", clipDay: "2025-12-08" },
+ ]);
+ // Two clips on one day: the later in the cut.
+ const twin = [{ ...CLIPS[0] }, { ...CLIPS[0], id: "c1b" }];
+ assert.equal(attachPosts({ posts: [POST("q", "2024-09-06")], entries: twin })[0].entryId, "c1b");
+ assert.deepEqual(attachPosts({ posts: [POST("q", "2024-09-06")], entries: [{ id: "k", type: "card" }] }), []);
+});
+
+test("postSchedule: stacked from the end, one every `seconds`, leaving in the transition", () => {
+ const segments = [
+ { id: "c1", start: 0, duration: 10 },
+ { id: "c2", start: 9.5, duration: 12 },
+ { id: "k1", start: 21, duration: 4 },
+ { id: "c3", start: 24.5, duration: 4 },
+ ];
+ const posts = [POST("a", "2024-10-19"), POST("b", "2024-11-27"), POST("z", "2026-01-22")];
+ const s = postSchedule({ posts, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: RENDER });
+ // c2 carries a and b; its leave is the dissolve into k1 at 21.
+ assert.deepEqual(s.filter((p) => p.segment === "c2").map((p) => [p.id, p.slot, p.of, p.appear, p.out]), [
+ ["a", 0, 2, 17, [21, 21.5]],
+ ["b", 1, 2, 19, [21, 21.5]],
+ ]);
+ // The last segment leaves 0.3 s before the end; a 4 s clip with 3.2 s after
+ // its incoming dissolve still gives its one post the full 2 s.
+ const z = s.find((p) => p.id === "z");
+ assert.deepEqual([z.segment, z.appear, z.out], ["c3", 26.2, [28.2, 28.5]]);
+ assert.equal(z.qrUrl, z.url);
+ // A hard cut: the leave ends AT the cut.
+ const h = postSchedule({ posts, entries: CLIPS, metas: METAS, segments: [
+ { id: "c1", start: 0, duration: 10 }, { id: "c2", start: 10, duration: 12 },
+ { id: "k1", start: 22, duration: 4 }, { id: "c3", start: 26, duration: 4 },
+ ], D: 0, total: 30, render: RENDER });
+ assert.deepEqual(h.find((p) => p.id === "b").out, [21.7, 22]);
+ // Too short for k × seconds: what is left after the incoming dissolve is shared.
+ const many = ["m1", "m2", "m3", "m4"].map((id) => POST(id, "2026-01-01"));
+ const sq = postSchedule({ posts: many, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: RENDER });
+ assert.deepEqual(sq.map((p) => Number(p.appear.toFixed(3))), [25, 25.8, 26.6, 27.4]);
+ // Windows: one per carrying clip, first appearance to the end of the leave.
+ assert.deepEqual(postWindows({ posts: s }), [
+ { segment: "c2", from: 17, to: 21.5 },
+ { segment: "c3", from: 26.2, to: 28.5 },
+ ]);
+});
+
+test("deckSchedule carries posts only when there are some; posts.show false drops them", () => {
+ const timeline = CLIPS;
+ const base = estimateSchedule({ render: RENDER, provenance: PROV, timeline });
+ assert.equal("posts" in base, false);
+ const withPosts = estimateSchedule({ render: RENDER, provenance: PROV, timeline, posts: [POST("p", "2026-01-01")] });
+ assert.equal(withPosts.posts.length, 1);
+ assert.equal(withPosts.posts[0].segment, "c3");
+ const off = { ...RENDER, chrome: { ...CHROME, deck: { posts: { show: false } } } };
+ assert.equal("posts" in estimateSchedule({ render: off, provenance: PROV, timeline, posts: [POST("p", "2026-01-01")] }), false);
+});
+
+test("postsGeometry: a column inside the footage box", () => {
+ assert.deepEqual(postsGeometry(RENDER), { x: 173 + 1574 - 24 - 600, y: 26, width: 600, height: 838 });
+ const left = { ...RENDER, chrome: { ...CHROME, deck: { posts: { position: "top-left", width: 500, inset: 10 } } } };
+ assert.deepEqual(postsGeometry(left), { x: 183, y: 12, width: 500, height: 866 });
+});
+
+test("validatePosts and the posts settings refuse in sentences", () => {
+ assert.deepEqual(validatePosts(undefined), []);
+ assert.deepEqual(validatePosts([POST("p1", "2024-10-19T16:53:06.587Z")], CLIPS), []);
+ const errs = validatePosts([
+ POST("p1", "yesterday"), POST("p1", "2024-01-01", { platform: "mastodon", url: "http://x", attachTo: "k1", extra: 1 }),
+ ], CLIPS);
+ for (const re of [/date/, /used twice/, /platform/, /https link/, /attachTo "k1" is not a clip/, /extra is not a deck setting/]) {
+ assert.ok(errs.some((e) => re.test(e)), `missing ${re}: ${errs.join(" | ")}`);
+ }
+ assert.match(validateChrome({ ...CHROME, deck: { posts: { width: 2000 } } }, RENDER)[0], /posts.width/);
+ assert.match(validateChrome({ ...CHROME, deck: { footageScale: 0.5, posts: { width: 900, inset: 80 } } }, RENDER).join(" "), /does not fit the 960px footage box/);
+ assert.match(validateChrome({ ...CHROME, deck: { posts: { position: "middle" } } }, RENDER)[0], /position/);
+ assert.deepEqual(validateChrome({ ...CHROME, deck: { posts: { show: false } } }, RENDER), []);
+});
+
+test("posts fit: a deck without posts is never refused for the column; drawn posts are", () => {
+ // 1280×720 at 0.5: a 640 px footage box, narrower than the default column.
+ const narrow = { width: 1280, height: 720, fps: 30, transition: 0.5, chrome: { ...CHROME, deck: { footageScale: 0.5 } } };
+ assert.deepEqual(validateChrome(narrow.chrome, narrow), []);
+ // Posts that will be drawn there are refused, with the column's sentence.
+ assert.match(validatePosts([POST("p", "2024-01-01")], CLIPS, narrow).join(" "), /does not fit the 640px footage box/);
+ // Hidden, switched off, or no render to check against: nothing to fit.
+ assert.deepEqual(validatePosts([POST("p", "2024-01-01", { hide: true })], CLIPS, narrow), []);
+ const off = { ...narrow, chrome: { ...CHROME, deck: { footageScale: 0.5, posts: { show: false } } } };
+ assert.deepEqual(validatePosts([POST("p", "2024-01-01")], CLIPS, off), []);
+ assert.deepEqual(validatePosts([POST("p", "2024-01-01")], CLIPS), []);
+ // A deck that SETS posts is still held to the fit by validateChrome.
+ assert.match(validateChrome({ ...CHROME, deck: { footageScale: 0.5, posts: { width: 600 } } }, narrow).join(" "), /does not fit/);
+ // ...unless it switches them off.
+ assert.deepEqual(validateChrome({ ...CHROME, deck: { footageScale: 0.5, posts: { show: false } } }, narrow), []);
+});
diff --git a/umtool/report-to-video/render-cards.mjs b/umtool/report-to-video/render-cards.mjs
@@ -764,7 +764,7 @@ async function qrTileStrip(entries, provenance, render, g, outDir) {
const png = path.join(qrDir, `q${seen.size.toString().padStart(2, "0")}.png`);
await execFileP(QRENCODE, [
"-o", png, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3),
- "-l", q.ecc ?? "M", url,
+ "-l", q.ecc ?? "M", "--", url,
]);
// Nearest-neighbour to an exact box: a resampled QR blurs its module edges
// and stops scanning, and the geometry has to be known before this runs.
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");