commit 3740bd67dce7bf32699ef0565cbc26181c7f18a6
parent 3ca9da34bfc7f485ecce18f7008a42b7468746b6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 21:17:25 -0400
deck S4: the on-screen routes — chrome, onscreen, preview, files, still, video
- GET/PUT /api/report/chrome — the render.chrome block (with defaults filled
and any validation errors on read); PUT writes through updateChrome, 400
with `errors` on a refusal, 409 on a stale token.
- GET/PUT /api/report/onscreen — the table's rows (saved onscreen plus the
auto title and subtitle as placeholders); PUT writes through
updateOnscreen, all or nothing.
- POST /api/report/chrome/preview — composes out/<variant>/chrome/
deck-preview/ (no render) and returns { src, geometry, layout, schedule }.
- GET /api/report/chrome/files/<project>/<variant>/<file…> — serves that
directory through deckPreviewFile.
- GET /api/report/still — a PNG of the deck at a clip's midpoint or a time.
- GET /api/report/video — the cut or its --chrome-preview window, ranged.
lib/report/onscreen.mjs picks the preview's schedule: the build's
schedule.json when it is the deck's and still lists the cut's entries in
order (its timings and QRs kept, its text re-derived from the manifest as it
is now with the draft applied), else estimateSchedule with cue-file metadata.
The compose and still calls are serialised per preview directory and code
against compose-chrome's deck contract; a preview written anywhere but
deckPreviewDir is an error.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 783 insertions(+), 0 deletions(-)
diff --git a/umtool/app/api/report/chrome/files/[...path]/route.ts b/umtool/app/api/report/chrome/files/[...path]/route.ts
@@ -0,0 +1,65 @@
+import path from "node:path";
+import {
+ decodeProjectSegment,
+ deckPreviewDir,
+ deckPreviewFile,
+ rangeResponse,
+ resolveReport,
+} from "@/lib/report/serve.mjs";
+
+export const dynamic = "force-dynamic";
+
+// The deck's preview composition, served to the iframe.
+//
+// /api/report/chrome/files/<project>/<variant>/<file…>, where <project> is the
+// project id base64url-encoded into one segment (POST /api/report/chrome/preview
+// hands out the src; nothing builds it by hand). The project and the variant
+// travel IN THE PATH because the composition names its assets by relative url,
+// and a relative url resolved against `index.html?project=…` drops the query.
+//
+// This is the one report route that takes a path from the client, so it is
+// confined twice: the project and the variant must be members of the server's
+// 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.
+
+const TYPES: Record<string, string> = {
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".mjs": "text/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".json": "application/json",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".webp": "image/webp",
+ ".svg": "image/svg+xml",
+ ".woff2": "font/woff2",
+ ".woff": "font/woff",
+ ".ttf": "font/ttf",
+ ".otf": "font/otf",
+};
+
+export async function GET(request: Request, ctx: { params: Promise<{ path: string[] }> }) {
+ const { path: segments } = await ctx.params;
+ if (!Array.isArray(segments) || segments.length < 3) return new Response("not found", { status: 404 });
+ const [projectSeg, variant, ...rest] = segments;
+
+ const projectId = decodeProjectSegment(projectSeg);
+ if (!projectId) return new Response("not found", { status: 404 });
+ 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);
+ if (!file) return new Response("not found", { status: 404 });
+
+ return rangeResponse(request, {
+ abs: file.abs,
+ size: file.size,
+ headers: {
+ "content-type": TYPES[path.extname(file.abs).toLowerCase()] ?? "application/octet-stream",
+ "cache-control": "no-store",
+ "x-content-type-options": "nosniff",
+ },
+ });
+}
diff --git a/umtool/app/api/report/chrome/preview/route.ts b/umtool/app/api/report/chrome/preview/route.ts
@@ -0,0 +1,71 @@
+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";
+
+export const dynamic = "force-dynamic";
+
+// Compose the deck's PREVIEW for one cut, and say where to load it.
+//
+// No render. compose-chrome writes out/<variant>/chrome/deck-preview/ -- never
+// the build's chrome/deck/ or its frame cache -- and the page loads that in an
+// iframe from `src`, at `geometry.deck`'s rect over the footage, seeking it with
+// postMessage. Live typing is patched in by message too (`deck:text`); this
+// route is for when the text or the timing changes enough to recompose.
+//
+// The schedule is the build's own when one exists and still matches the cut,
+// else an estimate from the manifest (`schedule.estimated`), and the request's
+// `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.
+export async function POST(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 ?? ""), body.variant ? String(body.variant) : null);
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+
+ let draft;
+ try {
+ draft = normalizeDraft(body.draft);
+ } 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);
+ const render = (variantManifest.render ?? {}) as Record<string, unknown>;
+ if (!deckOn(render)) {
+ return Response.json(
+ { error: "the deck is off for this manifest — save render.chrome first", deckOn: false },
+ { status: 409 },
+ );
+ }
+ const { chrome, ...rest } = render;
+ const errors = validateChrome(chrome, rest);
+ if (errors.length) {
+ return Response.json({ error: `render.chrome: ${errors.join("; ")}`, errors }, { status: 400 });
+ }
+
+ try {
+ await composeDeckPreview(r.project, r.variant, schedule);
+ } catch (e) {
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 });
+ }
+
+ 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()}`,
+ variant: r.variant,
+ geometry: deckGeometry(render),
+ layout: deckLayout(render),
+ schedule,
+ },
+ { headers: { "cache-control": "no-store" } },
+ );
+}
diff --git a/umtool/app/api/report/chrome/route.ts b/umtool/app/api/report/chrome/route.ts
@@ -0,0 +1,80 @@
+import { ChromeRefused, StaleToken, manifestToken, updateChrome } from "@/lib/report/manifest.mjs";
+import { resolveReport } from "@/lib/report/serve.mjs";
+import { DECK_DEFAULTS, deckOn, resolveDeck, validateChrome } from "umtool-report-to-video/deck";
+
+export const dynamic = "force-dynamic";
+
+// The deck's settings: `render.chrome` in the manifest.
+//
+// The client sends a project id, the WHOLE chrome block (or null to turn the
+// deck off) and the token it was given when it read the manifest. It never
+// sends a path. Validation is deck.mjs's validateChrome, run by the writer, so
+// a block this route accepts is one the build accepts; a refusal comes back
+// as the same sentences the build would print, one per problem.
+
+type Body = Record<string, unknown>;
+
+const noStore = { "cache-control": "no-store" };
+
+/** What the settings form starts from: the block as written, and with every default filled. */
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const r = await resolveReport(url.searchParams.get("project") ?? "");
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+ const render = (r.manifest.render ?? {}) as Record<string, unknown>;
+ const { chrome = null, ...rest } = render;
+ return Response.json(
+ {
+ chrome,
+ deckOn: deckOn(render),
+ deck: chrome ? resolveDeck(render) : null,
+ defaults: DECK_DEFAULTS,
+ // A block somebody hand-edited into a state the build would refuse. The
+ // form shows these rather than pretending the saved settings are fine.
+ errors: chrome ? validateChrome(chrome, rest) : [],
+ token: await manifestToken(r.project.dir),
+ },
+ { headers: noStore },
+ );
+}
+
+export async function PUT(request: Request) {
+ let body: Body;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "expected JSON" }, { status: 400 });
+ }
+ if (!("chrome" in body)) {
+ return Response.json({ error: "chrome is required — an object, or null to turn the deck off" }, { 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 updateChrome(r.project.dir, (body.chrome ?? null) as Record<string, unknown> | null, {
+ token: body.token === undefined ? null : String(body.token),
+ });
+ return Response.json(
+ {
+ ok: true,
+ chrome: res.chrome,
+ deck: res.chrome ? resolveDeck({ ...(r.manifest.render ?? {}), chrome: res.chrome }) : null,
+ 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 ChromeRefused) {
+ 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/app/api/report/onscreen/route.ts b/umtool/app/api/report/onscreen/route.ts
@@ -0,0 +1,90 @@
+import { StaleToken, manifestToken, updateOnscreen } from "@/lib/report/manifest.mjs";
+import { deckMetas } 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";
+
+// The deck's per-entry text: `onscreen: { title?, subtitle? }` on any
+// timeline entry, clip, still or card.
+//
+// PUT is the On-screen table's one save: a map of entry id → the row, or null
+// to clear it. A row REPLACES the entry's whole `onscreen` (`{ title }` alone
+// clears a subtitle override), blanks are dropped, and an entry left with
+// nothing loses the key. One unknown id or one bad value refuses the WHOLE
+// batch -- nothing is written -- because a table that saved all but one row
+// reads as saved.
+
+type Entry = Record<string, unknown> & { id: string; type?: string; onscreen?: Record<string, string> };
+
+const noStore = { "cache-control": "no-store" };
+
+/**
+ * The table's rows for one cut: each entry's saved `onscreen` and what the
+ * deck says with no override at all -- the AUTO title and subtitle the form
+ * 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.
+ */
+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 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 rows = entries.map((e, i) => {
+ const { onscreen, ...bare } = e;
+ return {
+ id: e.id,
+ type: e.type ?? "entry",
+ onscreen: onscreen ?? null,
+ auto: deckText(bare, metas[i] ?? null, provenance, deck, multi),
+ };
+ });
+ return Response.json(
+ {
+ variant: r.variant,
+ rows,
+ maxChars: deck.title.maxChars,
+ multiChannel: multi,
+ token: await manifestToken(r.project.dir),
+ },
+ { headers: noStore },
+ );
+}
+
+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 updateOnscreen(
+ r.project.dir,
+ body.onscreen as Record<string, { title?: string; subtitle?: string } | null>,
+ { token: body.token === undefined ? null : String(body.token) },
+ );
+ return Response.json({ ok: true, onscreen: res.onscreen, 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 },
+ );
+ }
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
+ }
+}
diff --git a/umtool/app/api/report/still/route.ts b/umtool/app/api/report/still/route.ts
@@ -0,0 +1,63 @@
+import { deckStill, scheduleForPreview, stillTimeOf } from "@/lib/report/onscreen.mjs";
+import { resolveReport } from "@/lib/report/serve.mjs";
+import { deckOn, validateChrome } from "umtool-report-to-video/deck";
+
+export const dynamic = "force-dynamic";
+
+// A TRUE still of the deck: the composition at one moment, screenshotted by
+// the same browser engine the render uses, as a PNG of the deck region
+// (1920 × deck.height, transparent outside the panel).
+//
+// The live preview is an iframe in the page's own browser, which is close to
+// the render and not the same thing -- fonts, text fitting and the QR are
+// where they differ. This is the answer to "is that what the video will show".
+//
+// ?project&variant& one of:
+// clip=<entry id> the middle of that entry's segment
+// at=<seconds> a moment in the cut's clock
+//
+// Drawn from the same schedule the preview uses (the build's when it matches
+// the cut, else an estimate), saved text only -- a still is of what is saved.
+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 new Response(r.error, { status: r.status });
+
+ const { variantManifest, schedule } = await scheduleForPreview(r.project, r.manifest, r.variant);
+ const render = (variantManifest.render ?? {}) as Record<string, unknown>;
+ if (!deckOn(render)) return new Response("the deck is off for this manifest", { status: 409 });
+ const { chrome, ...rest } = render;
+ const errors = validateChrome(chrome, rest);
+ if (errors.length) return new Response(`render.chrome: ${errors.join("; ")}`, { status: 400 });
+
+ const clip = url.searchParams.get("clip");
+ const atRaw = url.searchParams.get("at");
+ let t: number | null;
+ if (clip) {
+ t = stillTimeOf(schedule, clip);
+ if (t === null) return new Response(`no entry ${clip} in the ${r.variant} cut`, { status: 404 });
+ } else if (atRaw !== null && atRaw !== "") {
+ t = Number(atRaw);
+ if (!Number.isFinite(t) || t < 0 || t > schedule.total) {
+ return new Response(`at must be a number of seconds from 0 to ${schedule.total}`, { status: 400 });
+ }
+ } else {
+ return new Response("name a clip= or an at=", { status: 400 });
+ }
+
+ let png: Buffer;
+ try {
+ png = await deckStill(r.project, r.variant, schedule, t);
+ } catch (e) {
+ return new Response(e instanceof Error ? e.message : String(e), { status: 500 });
+ }
+ return new Response(new Uint8Array(png), {
+ headers: {
+ "content-type": "image/png",
+ "content-length": String(png.length),
+ "cache-control": "no-store",
+ "x-still-at": String(t),
+ "x-schedule-estimated": String(!!schedule.estimated),
+ },
+ });
+}
diff --git a/umtool/app/api/report/video/route.ts b/umtool/app/api/report/video/route.ts
@@ -0,0 +1,44 @@
+import { rangeResponse, resolveReport, videoFor } from "@/lib/report/serve.mjs";
+
+export const dynamic = "force-dynamic";
+
+// A built CUT, to a <video> element: the deliverable (`kind=final`, the
+// default) or the short window `--chrome-preview` writes (`kind=preview`).
+//
+// ?project&variant&kind. Names, never paths: the file is the pipeline's own
+// variantPaths() for the manifest's slug and a variant from its list.
+//
+// The `v` contract is the segment route's: a re-render writes the SAME path, so
+// the client passes the mtime it was told about (`x-video-mtime`) as `v`, and
+// only a matching one may be cached.
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const kind = url.searchParams.get("kind") || "final";
+ if (kind !== "final" && kind !== "preview") {
+ return new Response("kind must be final or preview", { status: 400 });
+ }
+ const r = await resolveReport(url.searchParams.get("project") ?? "", url.searchParams.get("variant"));
+ if ("error" in r) return new Response(r.error, { status: r.status });
+
+ const video = await videoFor(r.project, r.manifest, r.variant, kind);
+ if (!video) {
+ // The normal state of a cut nobody has built yet; said in words.
+ return new Response(
+ kind === "preview" ? "no on-screen preview has been rendered for this cut yet" : "this cut has not been built yet",
+ { status: 404 },
+ );
+ }
+
+ const fresh = url.searchParams.get("v") === String(video.mtimeMs);
+ return rangeResponse(request, {
+ abs: video.abs,
+ size: video.size,
+ headers: {
+ "content-type": "video/mp4",
+ "accept-ranges": "bytes",
+ "cache-control": fresh ? "private, max-age=3600, immutable" : "private, no-store",
+ "x-video-mtime": String(video.mtimeMs),
+ "x-video": video.rel,
+ },
+ });
+}
diff --git a/umtool/lib/report/onscreen.mjs b/umtool/lib/report/onscreen.mjs
@@ -0,0 +1,265 @@
+// The deck's preview and stills, as umtool asks for them.
+//
+// Everything about WHAT the deck says and WHERE it sits is deck.mjs's; how it
+// is DRAWN is compose-chrome's. This module only decides which schedule a
+// preview is drawn from and hands it over:
+//
+// - the BUILD's schedule (out/<variant>/schedule.json, `kind: "deck"`) when
+// there is one and it still lists this cut's entries in this order -- its
+// durations are probed, so a handover lands where the render puts it;
+// - else an ESTIMATE from the manifest alone (`estimated: true`), so the
+// section previews before anything has been built.
+//
+// Either way a request's DRAFT -- the On-screen table's unsaved rows -- is
+// overlaid, so the preview shows what a save would build.
+//
+// The preview never renders and never touches the build's project or cache:
+// compose-chrome's `preview: true` writes out/<variant>/chrome/deck-preview/.
+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 {
+ channelsDirFor,
+ cuePathFor,
+ hasShadowChannels,
+ manifestPath,
+ readCues,
+} from "../projects/report.mjs";
+import { deckPreviewDir } from "./serve.mjs";
+
+/** The schedule document deck.mjs defines, built or estimated. */
+/** @typedef {ReturnType<typeof estimateSchedule>} DeckSchedule */
+
+/**
+ * A draft from the client, normalised: entry id → `{title?, subtitle?}` or
+ * null. Same rule as updateOnscreen -- a row REPLACES the entry's whole
+ * `onscreen` -- so the preview of a draft is the build of its save. Throws,
+ * naming the id, on a value the writer would refuse.
+ *
+ * @param {unknown} draft
+ * @returns {Map<string, { title?: string, subtitle?: string } | null>}
+ */
+export function normalizeDraft(draft) {
+ const out = new Map();
+ if (draft === undefined || draft === null) return out;
+ if (typeof draft !== "object" || Array.isArray(draft)) {
+ throw new Error("draft must be an object of entry id → { title, subtitle } or null");
+ }
+ for (const [id, v] of Object.entries(draft)) {
+ try {
+ out.set(id, normalizeOnscreen(v));
+ } catch (e) {
+ throw new Error(`${id}: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ }
+ return out;
+}
+
+/**
+ * Each entry's source metadata, aligned with the cut's timeline: what the
+ * auto subtitle needs (`{ title, uploadDate, channel }`) or null.
+ *
+ * From the archive's cue files -- the same documents, through the same
+ * memoised reader, that give the clip bench its source title and upload date.
+ * The build reads the fetched file's own metadata instead, which is why an
+ * estimate's subtitle can differ from the built one where the two disagree.
+ *
+ * @param {string} dir the project directory
+ * @param {Record<string, any>} manifest the WHOLE manifest (channels, provenance)
+ * @param {Array<Record<string, any>>} entries the cut's timeline
+ */
+export async function deckMetas(dir, manifest, entries) {
+ const channelsDir = channelsDirFor(dir, manifest, { shadowExists: await hasShadowChannels(dir) });
+ const reads = new Map();
+ return Promise.all(
+ entries.map(async (e) => {
+ if (e.type !== "clip" || !e.video) return null;
+ const file = cuePathFor(manifest, e, channelsDir);
+ if (!file) return null;
+ if (!reads.has(file)) reads.set(file, readCues(file).catch(() => null));
+ const doc = await reads.get(file);
+ return doc ? { title: doc.title ?? null, uploadDate: doc.uploadDate ?? null, channel: doc.channel ?? null } : null;
+ }),
+ );
+}
+
+/**
+ * Is this build schedule still the cut's? Same entries, same order. A clip
+ * added, dropped or moved since the build makes every later start wrong, and
+ * then the estimate is the better answer.
+ */
+export function scheduleMatches(schedule, entries) {
+ if (schedule?.kind !== "deck" || !Array.isArray(schedule.segments)) return false;
+ if (schedule.segments.length !== entries.length) return false;
+ return schedule.segments.every((s, i) => s.id === entries[i].id);
+}
+
+/**
+ * The schedule a preview draws. Pure: the caller reads the files.
+ *
+ * With a build schedule, its timings and QRs are kept and every segment's
+ * title and subtitle are re-derived with deckText -- the function the build
+ * used -- from the manifest as it is NOW with the draft applied. Not only the
+ * drafted rows: a row saved since the build would otherwise preview as the
+ * text the build drew. One exception, toward the build: a clip with no
+ * subtitle override and no cue file to read keeps the build's auto subtitle,
+ * which came from the fetched file's own metadata.
+ *
+ * Without a build schedule the draft is applied to the entries and the whole
+ * cut is estimated.
+ *
+ * @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
+ * @returns {DeckSchedule}
+ */
+export function previewSchedule({ variantManifest, built, draft, metas }) {
+ const entries = variantManifest.timeline ?? [];
+ const patched = (e) => {
+ if (!draft.has(e.id)) return e;
+ const v = draft.get(e.id);
+ const { onscreen: _o, ...rest } = e;
+ return v ? { ...rest, onscreen: v } : rest;
+ };
+
+ if (built && scheduleMatches(built, entries)) {
+ const render = variantManifest.render ?? {};
+ const deck = resolveDeck(render);
+ const provenance = variantManifest.provenance ?? {};
+ return {
+ ...built,
+ segments: built.segments.map((s, i) => {
+ const e = patched(entries[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 };
+ }),
+ };
+ }
+ return estimateSchedule({ ...variantManifest, timeline: entries.map(patched) }, { metas });
+}
+
+/** out/<variant>/schedule.json when it is the deck's, else null. */
+async function readBuiltSchedule(dir, variant) {
+ try {
+ const doc = JSON.parse(await readFile(path.join(dir, "out", variant, "schedule.json"), "utf8"));
+ return doc?.kind === "deck" ? doc : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * The cut, the schedule a preview of it draws, and the render block the
+ * geometry comes from.
+ *
+ * @param {{ dir: string }} project
+ * @param {Record<string, any>} manifest
+ * @param {string} variant
+ * @param {Map<string, { title?: string, subtitle?: string } | null>} draft
+ */
+export async function scheduleForPreview(project, manifest, variant, draft = new Map()) {
+ 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 }) };
+}
+
+// compose-chrome writes one directory per cut. Two requests composing into it
+// at once would interleave their writes, so they queue per directory.
+/** @type {Map<string, Promise<unknown>>} */
+const queues = new Map();
+/**
+ * @template T
+ * @param {string} key
+ * @param {() => Promise<T>} fn
+ * @returns {Promise<T>}
+ */
+function serialised(key, fn) {
+ const prev = queues.get(key) ?? Promise.resolve();
+ const run = prev.then(fn, fn);
+ const tail = run.then(
+ () => undefined,
+ () => undefined,
+ );
+ queues.set(key, tail);
+ tail.then(() => {
+ if (queues.get(key) === tail) queues.delete(key);
+ });
+ return run;
+}
+
+/**
+ * Compose the preview project for a cut from `schedule`. No render.
+ *
+ * compose-chrome decides where the project goes; the files route serves
+ * deckPreviewDir. If the two ever disagree the iframe would load nothing and
+ * say nothing, so a mismatch is an error here instead.
+ *
+ * @param {{ dir: string }} project
+ * @param {string} variant
+ * @param {Record<string, any>} schedule
+ */
+export async function composeDeckPreview(project, variant, schedule) {
+ const outDir = path.join(project.dir, "out", variant);
+ const want = deckPreviewDir(project.dir, variant);
+ return serialised(want, async () => {
+ /** @type {Record<string, unknown>} */
+ const args = { manifestPath: manifestPath(project.dir), outDir, variant, region: "deck", schedule, preview: true };
+ const r = await composeChrome(/** @type {any} */ (args));
+ if (r?.projDir && path.resolve(r.projDir) !== path.resolve(want)) {
+ throw new Error(`compose-chrome wrote the preview to ${r.projDir}, not ${want}`);
+ }
+ return r;
+ });
+}
+
+/**
+ * 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.
+ *
+ * @param {{ dir: string }} project
+ * @param {string} variant
+ * @param {Record<string, any>} schedule
+ * @param {number} t seconds in the cut's clock
+ * @returns {Promise<Buffer>}
+ */
+export async function deckStill(project, variant, schedule, t) {
+ const outDir = path.join(project.dir, "out", variant);
+ const want = deckPreviewDir(project.dir, variant);
+ const stills = path.join(outDir, "chrome", "deck-stills");
+ return serialised(want, async () => {
+ await mkdir(stills, { recursive: true });
+ const png = path.join(stills, `still-${process.pid}-${Math.random().toString(36).slice(2, 8)}.png`);
+ try {
+ /** @type {Record<string, unknown>} */
+ const args = {
+ manifestPath: manifestPath(project.dir),
+ outDir,
+ variant,
+ region: "deck",
+ schedule,
+ preview: true,
+ still: t,
+ png,
+ };
+ await composeChrome(/** @type {any} */ (args));
+ return await readFile(png);
+ } finally {
+ await rm(png, { force: true });
+ }
+ });
+}
+
+/** The moment a still of one entry shows: the middle of its segment. */
+export function stillTimeOf(schedule, id) {
+ const s = (schedule.segments ?? []).find((x) => x.id === id);
+ return s ? Math.round((s.start + s.duration / 2) * 1000) / 1000 : null;
+}
diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs
@@ -0,0 +1,105 @@
+// Which schedule umtool's deck preview draws, and what a draft does to it.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { estimateSchedule } from "umtool-report-to-video/deck";
+import { normalizeDraft, previewSchedule, scheduleMatches, stillTimeOf } from "./onscreen.mjs";
+
+const cut = () => ({
+ slug: "t",
+ variant: "sourced",
+ provenance: { siteOrigin: "https://example.test", channelSlug: "chan" },
+ render: { fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck" } },
+ timeline: [
+ { type: "card", id: "k1", heading: "Opening", sub: "a card", seconds: 5 },
+ { type: "clip", id: "c01", video: "v1", start: 10, end: 20 },
+ { type: "clip", id: "c02", video: "v2", start: 30, end: 41, onscreen: { title: "Saved", subtitle: "Saved sub" } },
+ ],
+});
+const metas = [null, { title: "Stream one", uploadDate: "20240903" }, { title: "Stream two", uploadDate: "20240910" }];
+
+/** A schedule as the build writes it: probed durations, the build's text. */
+const built = () => ({
+ version: 1,
+ kind: "deck",
+ estimated: false,
+ fps: 30,
+ transition: 0.5,
+ total: 25.9,
+ multiChannel: false,
+ segments: [
+ { id: "k1", type: "card", start: 0, duration: 5, end: 5, title: "Opening", subtitle: "a card", qrUrl: null, hideDeck: true },
+ { id: "c01", type: "clip", start: 4.5, duration: 9.9, end: 14.4, title: "", subtitle: "Built sub one", qrUrl: "https://q/1", hideDeck: false },
+ { id: "c02", type: "clip", start: 13.9, duration: 12, end: 25.9, title: "Old", subtitle: "Old sub", qrUrl: "https://q/2", hideDeck: false },
+ ],
+});
+
+test("no build schedule: an estimate from the manifest, with the cue metadata in its subtitles", () => {
+ const s = previewSchedule({ variantManifest: cut(), built: null, draft: new Map(), metas });
+ assert.equal(s.estimated, true);
+ assert.deepEqual(s, estimateSchedule(cut(), { metas }));
+ const c01 = s.segments.find((x) => x.id === "c01");
+ assert.match(c01.subtitle, /Stream one/);
+ assert.match(c01.subtitle, /Sep 3, 2024/);
+});
+
+test("no build schedule: the draft is applied before estimating, a row replacing the whole onscreen", () => {
+ const draft = normalizeDraft({ c01: { title: " Drafted " }, c02: { title: "Only a title" }, k1: null });
+ const s = previewSchedule({ variantManifest: cut(), built: null, draft, metas });
+ const by = Object.fromEntries(s.segments.map((x) => [x.id, x]));
+ assert.equal(by.c01.title, "Drafted");
+ assert.equal(by.c02.title, "Only a title");
+ // c02's saved subtitle override is gone with the row: auto again.
+ assert.match(by.c02.subtitle, /Stream two/);
+ assert.equal(by.k1.title, "Opening");
+});
+
+test("a matching build schedule keeps its timings and QRs; the text is the manifest's NOW", () => {
+ const draft = normalizeDraft({ c01: { title: "Drafted" } });
+ const s = previewSchedule({ variantManifest: cut(), built: built(), draft, metas });
+ assert.equal(s.estimated, false);
+ assert.equal(s.total, 25.9);
+ const by = Object.fromEntries(s.segments.map((x) => [x.id, x]));
+ assert.equal(by.c01.start, 4.5);
+ assert.equal(by.c01.duration, 9.9);
+ assert.equal(by.c01.qrUrl, "https://q/1");
+ assert.equal(by.c01.title, "Drafted");
+ // Saved since the build: the preview shows the save, not the build's text.
+ assert.equal(by.c02.title, "Saved");
+ assert.equal(by.c02.subtitle, "Saved sub");
+ assert.equal(by.k1.hideDeck, true);
+});
+
+test("a clip with no override and no cue file keeps the build's auto subtitle", () => {
+ const s = previewSchedule({ variantManifest: cut(), built: built(), draft: new Map(), metas: [null, null, null] });
+ const by = Object.fromEntries(s.segments.map((x) => [x.id, x]));
+ assert.equal(by.c01.subtitle, "Built sub one");
+ assert.equal(by.c02.subtitle, "Saved sub");
+});
+
+test("a build schedule that no longer lists the cut's entries in order is not used", () => {
+ const m = cut();
+ m.timeline.reverse();
+ assert.equal(scheduleMatches(built(), m.timeline), false);
+ const s = previewSchedule({ variantManifest: m, built: built(), draft: new Map(), metas: [] });
+ assert.equal(s.estimated, true);
+ assert.deepEqual(s.segments.map((x) => x.id), ["c02", "c01", "k1"]);
+
+ assert.equal(scheduleMatches({ ...built(), kind: "rail" }, cut().timeline), false);
+ assert.equal(scheduleMatches(built(), cut().timeline), true);
+});
+
+test("normalizeDraft: names the entry a bad value belongs to", () => {
+ assert.equal(normalizeDraft(undefined).size, 0);
+ assert.equal(normalizeDraft(null).size, 0);
+ assert.equal(normalizeDraft({ a: { title: " " } }).get("a"), null);
+ assert.throws(() => normalizeDraft({ c01: { title: "a\nb" } }), /c01: .*one line/);
+ assert.throws(() => normalizeDraft([1]), /must be an object/);
+});
+
+test("stillTimeOf: the middle of an entry's segment", () => {
+ assert.equal(stillTimeOf(built(), "c01"), 9.45);
+ assert.equal(stillTimeOf(built(), "nope"), null);
+});