commit 9132b9be1ffa1daf837ea5295caddf8e2b48a411
parent fd28ef5edcaba12497e19b3c66ff22ec1b23fc72
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 21:59:45 -0400
deck S5: the On-screen section on the report page
A client section between the build chain and Deliver, labelled On-screen
(the song kind already owns "deck"): an enable switch writing render.chrome
(deck: {} on, null off), a settings form over every render.chrome.deck key
that writes only what differs from the defaults and shows the validator's
sentences from a 400, the title/subtitle table with auto text as placeholders,
a maxChars counter that warns without refusing and one save with the shared
manifest token (409 keeps the drafts and offers a reload), a 16:9 live
preview of the composition iframe at the deck rect over the built segment
(or a neutral frame) seeked by a scrubber and patched by deck:text as rows
are typed, a true still, a chromeOnly re-render through the build job runner,
and the final video with its mtime as the cache-buster.
The iframe is same-origin and unsandboxed: an opaque origin would break the
composition's font loads, and allow-scripts with allow-same-origin is no
boundary. Messages both ways are checked against this origin and frame.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 1279 insertions(+), 0 deletions(-)
diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx
@@ -0,0 +1,1267 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { badgeVariants } from "@/components/ui/badge";
+import { buttonVariants } from "@/components/ui/button";
+
+// ---------------------------------------------------------------------------
+// ON-SCREEN: the persistent panel under the footage of a report cut.
+//
+// Called "on-screen" everywhere a person reads it. The manifest and the
+// pipeline call it the deck (`render.chrome.layout: "deck"`), but in this app
+// "deck" is already the song kind's name, and one word meaning two things two
+// pages apart is how somebody edits the wrong one.
+//
+// THE PREVIEW IS THE COMPOSITION ITSELF, not a mock of it. The pipeline's own
+// HTML (chrome-deck.mjs) is composed into out/<variant>/chrome/deck-preview/
+// and loaded in an iframe at the panel's rect over a 16:9 frame; the scrubber
+// seeks its timeline and every keystroke in the table is patched into it by
+// postMessage, so a title is seen in the real face and the real fit before it
+// is saved. Recomposing (a server round trip) is only for what postMessage
+// cannot carry: settings, and a schedule that moved.
+//
+// It is close to the render and not the same thing -- the page's browser is
+// not the render's -- which is what "true still" is for: the render engine's
+// own screenshot of one moment, of what is SAVED.
+//
+// THE IFRAME IS SAME-ORIGIN AND NOT SANDBOXED. Its fonts and assets are
+// fetched by relative url from /api/report/chrome/files, and a sandbox without
+// `allow-same-origin` makes the document an opaque origin, every font load a
+// CORS request that route does not answer, and the preview draws in the
+// fallback face -- the one thing it must not get wrong. A sandbox WITH
+// `allow-scripts allow-same-origin` is no boundary at all (the document may
+// lift it, and the browser warns as much on every load), so it is left off
+// rather than worn as decoration. What makes the frame safe to load is what
+// it is: the pipeline's own composition, every manifest string escaped by
+// chrome-deck.mjs, served no-store from the project's own preview directory.
+// Messages both ways are checked against this origin and this frame.
+// ---------------------------------------------------------------------------
+
+// ---- the composition's contract, as this page reads it --------------------
+
+export type Rect = { x: number; y: number; width: number; height: number };
+export type DeckGeometry = { W: number; H: number; footage: Rect; deck: Rect };
+export type DeckSegment = {
+ id: string;
+ type: string;
+ start: number;
+ duration: number;
+ end: number;
+ title: string;
+ subtitle: string;
+ qrUrl: string | null;
+ hideDeck: boolean;
+};
+export type DeckSchedule = {
+ estimated?: boolean;
+ fps: number;
+ transition: number;
+ total: number;
+ multiChannel: boolean;
+ segments: DeckSegment[];
+};
+export type DeckPreviewDoc = { src: string; variant: string; geometry: DeckGeometry; schedule: DeckSchedule };
+/** 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 };
+
+/** The middle of a segment: where a still of it, and a jump to it, lands. */
+export const midOf = (s: DeckSegment) => Math.round((s.start + s.duration / 2) * 1000) / 1000;
+
+/** The segment on screen at `t`. Past the end, the last one. */
+export const segmentAt = (schedule: DeckSchedule, t: number): DeckSegment | null => {
+ const segs = schedule.segments;
+ for (let i = segs.length - 1; i >= 0; i--) if (t >= segs[i].start) return segs[i];
+ return segs[0] ?? null;
+};
+
+/** A row's draft as the writers take it: blanks dropped, nothing left → null. */
+export const onscreenValue = (d: { title: string; subtitle: string }): Onscreen | null => {
+ const o: Onscreen = {};
+ if (d.title.trim()) o.title = d.title.trim();
+ if (d.subtitle.trim()) o.subtitle = d.subtitle.trim();
+ return o.title || o.subtitle ? o : null;
+};
+
+/** Ask for a fresh preview composition. One door, used by both callers. */
+export async function composePreview(
+ project: string,
+ variant: string | null,
+ draft: Record<string, Onscreen | null> = {},
+): 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 }),
+ });
+ const j = (await r.json().catch(() => ({}))) as Record<string, unknown>;
+ if (!r.ok) return { error: String(j.error ?? r.status) };
+ return j as unknown as DeckPreviewDoc;
+}
+
+const clock = (t: number) => {
+ const s = Math.max(0, t);
+ const m = Math.floor(s / 60);
+ return `${m}:${(s - m * 60).toFixed(1).padStart(4, "0")}`;
+};
+
+// ---------------------------------------------------------------------------
+// DeckFrame: the composition in an iframe, at its rect, seeked and patched.
+//
+// `frame` is a 16:9 picture (children are the backdrop: a still of the
+// footage, or a neutral frame) with the panel at geometry.deck; `strip` is the
+// panel alone. Either way the iframe is laid out at the composition's own
+// pixel size and scaled by a transform, so the text fit inside it is measured
+// at 1920 wide -- the width the render measures at.
+// ---------------------------------------------------------------------------
+export function DeckFrame({
+ preview,
+ t,
+ texts,
+ mode = "frame",
+ testid,
+ children,
+ className = "",
+}: {
+ preview: DeckPreviewDoc;
+ t: number;
+ texts: DeckTexts;
+ mode?: "frame" | "strip";
+ testid?: string;
+ children?: React.ReactNode;
+ className?: string;
+}) {
+ const box = useRef<HTMLDivElement | null>(null);
+ const frame = useRef<HTMLIFrameElement | null>(null);
+ const [width, setWidth] = useState(0);
+ // The `deck:ready` the composition posts once its fonts are in and its
+ // titles are fitted. Nothing is sent before it: a message to a document
+ // still loading is dropped without a word.
+ const [ready, setReady] = useState<{ src: string; total: number; ids: string[] } | null>(null);
+ // What this document has already been told, so a keystroke sends one row.
+ const sent = useRef<DeckTexts>({});
+ const g = preview.geometry;
+ const src = `${preview.src}${preview.src.includes("?") ? "&" : "?"}preview=1`;
+
+ useEffect(() => {
+ const el = box.current;
+ if (!el) return;
+ const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const onMsg = (e: MessageEvent) => {
+ // Only this frame's document, and only from this origin.
+ if (e.source !== frame.current?.contentWindow || e.origin !== window.location.origin) return;
+ const m = (e.data ?? {}) as { type?: string; total?: number; ids?: string[] };
+ if (m.type !== "deck:ready") return;
+ sent.current = {};
+ setReady({ src, total: Number(m.total) || 0, ids: Array.isArray(m.ids) ? m.ids : [] });
+ };
+ window.addEventListener("message", onMsg);
+ return () => window.removeEventListener("message", onMsg);
+ }, [src]);
+
+ const live = ready?.src === src;
+ const post = useCallback((msg: Record<string, unknown>) => {
+ frame.current?.contentWindow?.postMessage(msg, window.location.origin);
+ }, []);
+
+ // Text first, then the seek, in one effect: a patched title is re-fitted
+ // inside the composition, and the seek lands on the patched nodes.
+ useEffect(() => {
+ if (!live) return;
+ for (const [id, v] of Object.entries(texts)) {
+ const prev = sent.current[id];
+ if (prev && prev.title === v.title && prev.subtitle === v.subtitle) continue;
+ post({ type: "deck:text", id, title: v.title, subtitle: v.subtitle });
+ sent.current[id] = v;
+ }
+ }, [live, texts, post]);
+ useEffect(() => {
+ if (live) post({ type: "deck:seek", t });
+ }, [live, t, post]);
+
+ const scale = width > 0 ? width / (mode === "frame" ? g.W : g.deck.width) : 0;
+ return (
+ <div
+ ref={box}
+ data-testid={testid}
+ data-deck-ready={live ? "1" : "0"}
+ className={`relative w-full overflow-hidden rounded border border-[var(--color-line)] bg-black ${className}`}
+ style={{ aspectRatio: mode === "frame" ? `${g.W} / ${g.H}` : `${g.deck.width} / ${g.deck.height}` }}
+ >
+ {mode === "frame" && children}
+ <iframe
+ ref={frame}
+ key={src}
+ src={src}
+ title="on-screen preview"
+ data-testid={testid ? `${testid}-iframe` : undefined}
+ tabIndex={-1}
+ aria-hidden
+ style={{
+ position: "absolute",
+ left: mode === "frame" ? g.deck.x * scale : 0,
+ top: mode === "frame" ? g.deck.y * scale : 0,
+ width: g.deck.width,
+ height: g.deck.height,
+ transform: `scale(${scale})`,
+ transformOrigin: "0 0",
+ border: 0,
+ background: "transparent",
+ // A document with no color-scheme of its own is "normal"; matching it
+ // here is what keeps the browser from painting an opaque backdrop
+ // behind a transparent frame on a dark page.
+ colorScheme: "normal",
+ pointerEvents: "none",
+ visibility: scale > 0 ? "visible" : "hidden",
+ }}
+ />
+ </div>
+ );
+}
+
+/** Where the footage goes, drawn as a box: the backdrop when there is no picture. */
+export function NeutralFrame({ geometry: g, label = "footage" }: { geometry: DeckGeometry; label?: string }) {
+ const pct = (n: number, of: number) => `${(n / of) * 100}%`;
+ return (
+ <div className="absolute inset-0 bg-[#0d0f14]" data-testid="onscreen-neutral-frame">
+ <div
+ className="absolute flex items-center justify-center border border-dashed border-[var(--color-line)] bg-[var(--color-panel)]"
+ style={{
+ left: pct(g.footage.x, g.W),
+ top: pct(g.footage.y, g.H),
+ width: pct(g.footage.width, g.W),
+ height: pct(g.footage.height, g.H),
+ }}
+ >
+ <span className="micro">{label}</span>
+ </div>
+ </div>
+ );
+}
+
+// ---------------------------------------------------------------------------
+// The settings form: every key of render.chrome.deck, flattened.
+// ---------------------------------------------------------------------------
+
+type DeckSettings = {
+ height: number;
+ footageScale: number;
+ background: string;
+ pip: { spacing: string; size: number; activeSize: number };
+ title: { size: number; maxChars: number };
+ subtitle: { parts: "auto" | string[]; dateFormat: string };
+ qr: { show: boolean; size: number };
+ overCards: string;
+ motion: { out: number; in: number; pip: number };
+};
+
+type Field =
+ | { key: string; label: string; kind: "int" | "num"; step?: number; hint: string }
+ | { key: string; label: string; kind: "select"; options: string[]; hint: string }
+ | { key: string; label: string; kind: "bool"; hint: string }
+ | { key: string; label: string; kind: "parts"; hint: string };
+
+/** Grouped as the form shows them. The hints are deck.mjs's ranges. */
+const GROUPS: { name: string; fields: Field[] }[] = [
+ {
+ name: "panel",
+ fields: [
+ { key: "height", label: "height", kind: "int", hint: "px, 120–400" },
+ { key: "footageScale", label: "footage scale", kind: "num", step: 0.01, hint: "0.5–1; the footage must fit above the panel" },
+ { key: "background", label: "background", kind: "select", options: ["panel", "flush"], hint: "panel is lifted; flush sits on the frame" },
+ { key: "overCards", label: "over cards", kind: "select", options: ["hide", "show"], hint: "hide slides the panel away over a card" },
+ ],
+ },
+ {
+ name: "title",
+ fields: [
+ { key: "title.size", label: "size", kind: "num", hint: "px, 24–96; a long title shrinks to 60 % of it" },
+ { key: "title.maxChars", label: "max chars", kind: "int", hint: "10–120; the table's counter, not a refusal" },
+ ],
+ },
+ {
+ name: "subtitle",
+ fields: [
+ { key: "subtitle.parts", label: "parts", kind: "parts", hint: "auto, or a list of channel, title, date, clock" },
+ { key: "subtitle.dateFormat", label: "date", kind: "select", options: ["long", "iso"], hint: "long is Aug 14, 2026" },
+ ],
+ },
+ {
+ name: "timeline",
+ fields: [
+ { key: "pip.spacing", label: "spacing", kind: "select", options: ["even", "time"], hint: "even, or by each clip's length" },
+ { key: "pip.size", label: "pip", kind: "num", hint: "px, 2–24" },
+ { key: "pip.activeSize", label: "active", kind: "num", hint: "px, 2–40, at least the pip" },
+ ],
+ },
+ {
+ name: "qr",
+ fields: [
+ { key: "qr.show", label: "show", kind: "bool", hint: "off drops every clip's QR" },
+ { key: "qr.size", label: "size", kind: "num", hint: "px, 80–380 and at most height − 20" },
+ ],
+ },
+ {
+ name: "motion",
+ fields: [
+ { key: "motion.out", label: "out", kind: "num", step: 0.05, hint: "s, 0–2: the old title wiping out" },
+ { key: "motion.in", label: "in", kind: "num", step: 0.05, hint: "s, 0–2: the new title expanding" },
+ { key: "motion.pip", label: "pip", kind: "num", step: 0.05, hint: "s, 0–3: the marker's travel" },
+ ],
+ },
+];
+const FIELDS = GROUPS.flatMap((g) => g.fields);
+
+type Form = Record<string, string | boolean>;
+
+const getPath = (o: unknown, key: string): unknown =>
+ key.split(".").reduce<unknown>((v, k) => (v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined), o);
+
+const formOf = (deck: DeckSettings): Form =>
+ Object.fromEntries(
+ FIELDS.map((f) => {
+ const v = getPath(deck, f.key);
+ if (f.kind === "bool") return [f.key, !!v];
+ if (f.kind === "parts") return [f.key, Array.isArray(v) ? v.join(", ") : String(v ?? "auto")];
+ return [f.key, v == null ? "" : String(v)];
+ }),
+ );
+
+/**
+ * The form, back into a `render.chrome.deck` block: ONLY what differs from
+ * the defaults, so a manifest says what somebody chose and a default that
+ * moves later still reaches it. Anything that does not parse is sent as typed
+ * -- the validator's sentence is a better answer than a silent clamp here.
+ */
+const deckOf = (form: Form, defaults: DeckSettings): Record<string, unknown> => {
+ const out: Record<string, unknown> = {};
+ for (const f of FIELDS) {
+ const raw = form[f.key];
+ let v: unknown;
+ if (f.kind === "bool") v = !!raw;
+ else if (f.kind === "parts") {
+ const s = String(raw).trim();
+ v = !s || s === "auto" ? "auto" : s.split(/[\s,·]+/).filter(Boolean);
+ } else if (f.kind === "select") v = String(raw);
+ else {
+ const s = String(raw).trim();
+ if (!s) continue; // blank = the default
+ v = Number.isFinite(Number(s)) ? Number(s) : s;
+ }
+ if (JSON.stringify(v) === JSON.stringify(getPath(defaults, f.key))) continue;
+ const [a, b] = f.key.split(".");
+ if (b) out[a] = { ...((out[a] as Record<string, unknown>) ?? {}), [b]: v };
+ else out[a] = v;
+ }
+ return out;
+};
+
+// ---------------------------------------------------------------------------
+
+type ChromeDoc = {
+ chrome: Record<string, unknown> | null;
+ deckOn: boolean;
+ deck: DeckSettings | null;
+ defaults: DeckSettings;
+ errors: string[];
+ token: string | null;
+};
+type Row = {
+ id: string;
+ type: string;
+ onscreen: Onscreen | null;
+ auto: { title: string; subtitle: string };
+};
+type Draft = { title: string; subtitle: string };
+type JobView = {
+ id: string;
+ state: "running" | "done" | "failed";
+ stepIndex: number;
+ steps: { label: string }[];
+ error: string | null;
+ log: string[];
+ next: number;
+};
+
+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 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";
+
+const field =
+ "rounded border bg-[var(--color-panel-2)] px-1.5 py-0.5 text-[12px] text-[var(--color-text)] placeholder:italic placeholder:text-[var(--color-dim)]";
+const input = `${field} border-[var(--color-line)]`;
+
+export default function OnscreenSection({
+ project,
+ entries,
+ built,
+}: {
+ project: string;
+ /**
+ * Is the default cut's deliverable on disk? The server already knows, and
+ * asking the video route about a file that is not there is a 404 in the
+ * console of every page view of an unbuilt report.
+ */
+ built: boolean;
+ /** Which entries have a built segment: the backdrop the preview can show. */
+ entries: { id: string; kind: string; segment: boolean }[];
+}) {
+ const [doc, setDoc] = useState<ChromeDoc | null>(null);
+ const [loadError, setLoadError] = useState<string | null>(null);
+ const [variants, setVariants] = useState<string[]>([]);
+ const [variant, setVariant] = useState("");
+ const [form, setForm] = useState<Form>({});
+ const [formDirty, setFormDirty] = useState(false);
+ const [errors, setErrors] = useState<string[]>([]);
+ const [rows, setRows] = useState<Row[]>([]);
+ const [maxChars, setMaxChars] = useState(48);
+ const [drafts, setDrafts] = useState<Record<string, Draft>>({});
+ const [note, setNote] = useState<string | null>(null);
+ const [stale, setStale] = useState(false);
+ const [busy, setBusy] = useState<string | null>(null);
+ const [preview, setPreview] = useState<DeckPreviewDoc | null>(null);
+ const [previewError, setPreviewError] = useState<string | null>(null);
+ const [composing, setComposing] = useState(false);
+ const [t, setT] = useState(0);
+ const [still, setStill] = useState<{ url: string; at: number; estimated: boolean } | null>(null);
+ const [stillError, setStillError] = useState<string | null>(null);
+ const [preset, setPreset] = useState<"final" | "fast" | null>(null);
+ const [job, setJob] = useState<JobView | null>(null);
+ const [jobError, setJobError] = useState<string | null>(null);
+ const [finalV, setFinalV] = useState<number | null | "none">(null);
+ // One token for the manifest, shared by both writers here: a settings save
+ // moves it, and the table's next save must carry the moved one. A ref, as
+ // in the clip bench, because two saves can be in flight before a render.
+ const token = useRef<string | null>(null);
+ const saving = useRef<Promise<unknown>>(Promise.resolve());
+ const since = useRef(0);
+ const backdrop = useRef<HTMLVideoElement | null>(null);
+
+ const variantQ = variant ? `&variant=${encodeURIComponent(variant)}` : "";
+ const q = `project=${encodeURIComponent(project)}${variantQ}`;
+ const segmentOf = useMemo(() => new Set(entries.filter((e) => e.kind === "clip" && e.segment).map((e) => e.id)), [entries]);
+
+ // ---- reading ------------------------------------------------------------
+ const loadChrome = useCallback(async () => {
+ const r = await fetch(`/api/report/chrome?project=${encodeURIComponent(project)}`, { cache: "no-store" });
+ const j = (await r.json()) as ChromeDoc & { error?: string };
+ if (!r.ok) {
+ setLoadError(String(j.error ?? r.status));
+ return null;
+ }
+ setDoc(j);
+ token.current = j.token;
+ setForm(formOf(j.deck ?? j.defaults));
+ setFormDirty(false);
+ setErrors(j.errors ?? []);
+ return j;
+ }, [project]);
+
+ /**
+ * The table's rows. `keep` keeps every unsaved edit on top of what is now
+ * saved -- the reload after a 409 must not throw away what was typed.
+ */
+ 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 };
+ if (!r.ok) {
+ setLoadError(String(j.error ?? r.status));
+ return;
+ }
+ token.current = j.token;
+ setRows(j.rows);
+ setMaxChars(j.maxChars);
+ setDrafts((prev) => {
+ const next: Record<string, Draft> = {};
+ for (const row of j.rows) {
+ const saved = draftOf(row.onscreen);
+ const old = prev[row.id];
+ next[row.id] = keep && old ? old : saved;
+ }
+ return next;
+ });
+ },
+ [q],
+ );
+
+ const recompose = useCallback(
+ async (draft: Record<string, Onscreen | null> = {}) => {
+ setComposing(true);
+ setPreviewError(null);
+ const res = await composePreview(project, variant || null, draft);
+ setComposing(false);
+ if ("error" in res) {
+ setPreviewError(res.error);
+ return;
+ }
+ setPreview(res);
+ // The schedule says how this cut was built: a crossfaded build is
+ // re-rendered as one, a hard-cut build as one.
+ setPreset((p) => p ?? (res.schedule.estimated || res.schedule.transition > 0 ? "final" : "fast"));
+ setT((cur) => Math.min(cur, res.schedule.total));
+ },
+ [project, variant],
+ );
+
+ const loadFinal = useCallback(async () => {
+ const r = await fetch(`/api/report/video?${q}&kind=final`, { method: "HEAD", cache: "no-store" }).catch(() => null);
+ const m = r?.ok ? r.headers.get("x-video-mtime") : null;
+ setFinalV(m ? Number(m) : "none");
+ }, [q]);
+
+ useEffect(() => {
+ void fetch("/api/report/build", { cache: "no-store" })
+ .then((r) => r.json())
+ .then((j) => setVariants((j.variants as string[]) ?? []))
+ .catch(() => {});
+ }, []);
+
+ useEffect(() => {
+ let live = true;
+ void (async () => {
+ const c = await loadChrome();
+ if (!live || !c) return;
+ await loadRows(false);
+ if (built || variant) void loadFinal();
+ else setFinalV("none");
+ if (live && c.deckOn && !(c.errors ?? []).length) await recompose();
+ })();
+ return () => {
+ live = false;
+ };
+ // `built` and `variant` are read once per load; loadFinal already follows the variant.
+ }, [loadChrome, loadRows, loadFinal, recompose]);
+
+ // ---- writing ------------------------------------------------------------
+ const queued = useCallback(<T,>(fn: () => Promise<T>): Promise<T> => {
+ const run = saving.current.catch(() => null).then(fn);
+ saving.current = run;
+ return run;
+ }, []);
+
+ /** PUT render.chrome. `null` turns the panel off. */
+ const putChrome = useCallback(
+ (chrome: Record<string, unknown> | null) =>
+ queued(async () => {
+ setBusy("saving…");
+ setNote(null);
+ const r = await fetch("/api/report/chrome", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project, chrome, 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 {
+ setErrors((j.errors as string[]) ?? []);
+ setNote(`could not save: ${String(j.error ?? r.status)}`);
+ }
+ return false;
+ }
+ token.current = String(j.token ?? "");
+ setErrors([]);
+ setStale(false);
+ return true;
+ }),
+ [project, queued],
+ );
+
+ const toggle = useCallback(
+ async (on: boolean) => {
+ if (!doc) return;
+ if (!on) {
+ const custom = doc.chrome?.deck && Object.keys(doc.chrome.deck as object).length > 0;
+ if (
+ custom &&
+ !window.confirm(
+ "Turning the on-screen panel off removes render.chrome from the manifest, settings included. The titles and subtitles in the table stay. Turn it off?",
+ )
+ )
+ return;
+ }
+ const ok = await putChrome(on ? { engine: "hyperframes", layout: "deck", deck: {} } : null);
+ if (!ok) return;
+ const c = await loadChrome();
+ await loadRows(true);
+ if (c?.deckOn) await recompose();
+ else setPreview(null);
+ setNote(on ? "on — the next build draws the panel" : "off — the next build draws the old chrome");
+ },
+ [doc, putChrome, loadChrome, loadRows, recompose],
+ );
+
+ const dirtyIds = rows.filter((r) => drafts[r.id] && !sameDraft(drafts[r.id], draftOf(r.onscreen))).map((r) => r.id);
+ const draftMap = useCallback(
+ () => Object.fromEntries(dirtyIds.map((id) => [id, onscreenValue(drafts[id])])) as Record<string, Onscreen | null>,
+ [dirtyIds, drafts],
+ );
+
+ const saveSettings = useCallback(async () => {
+ if (!doc) return;
+ const chrome = {
+ engine: (doc.chrome?.engine as string) ?? "hyperframes",
+ layout: (doc.chrome?.layout as string) ?? "deck",
+ deck: deckOf(form, doc.defaults),
+ };
+ const ok = await putChrome(chrome);
+ if (!ok) return;
+ await loadChrome();
+ // The auto subtitles follow subtitle.parts and dateFormat; the counter
+ // follows title.maxChars. Unsaved table edits ride through.
+ await loadRows(true);
+ await recompose(draftMap());
+ setNote("settings saved — the preview is recomposed with them");
+ }, [doc, form, putChrome, loadChrome, loadRows, recompose, draftMap]);
+
+ const saveRows = useCallback(
+ () =>
+ queued(async () => {
+ if (!dirtyIds.length) return;
+ setBusy("saving…");
+ setNote(null);
+ const r = await fetch("/api/report/onscreen", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project, onscreen: draftMap(), token: token.current }),
+ });
+ const j = (await r.json()) as Record<string, unknown>;
+ setBusy(null);
+ if (!r.ok) {
+ // The drafts stay exactly as typed. A refused batch is a typo or a
+ // race, and neither is fixed by retyping nineteen rows.
+ 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 saved = (j.onscreen ?? {}) as Record<string, Onscreen | null>;
+ setRows((prev) => prev.map((row) => (row.id in saved ? { ...row, onscreen: saved[row.id] } : row)));
+ // Re-sync from what the writer stored, which is trimmed.
+ setDrafts((prev) => {
+ const next = { ...prev };
+ for (const [id, v] of Object.entries(saved)) next[id] = draftOf(v);
+ return next;
+ });
+ setNote(`saved ${Object.keys(saved).length} row${Object.keys(saved).length === 1 ? "" : "s"}`);
+ }),
+ [queued, dirtyIds, project, draftMap],
+ );
+
+ const reloadSaved = useCallback(async () => {
+ await loadChrome();
+ await loadRows(true);
+ setStale(false);
+ setNote("reloaded the saved values — your unsaved edits are still here");
+ }, [loadChrome, loadRows]);
+
+ // ---- the true still -----------------------------------------------------
+ const trueStill = useCallback(async () => {
+ setStillError(null);
+ setBusy("rendering a still…");
+ const r = await fetch(`/api/report/still?${q}&at=${t.toFixed(3)}`, { cache: "no-store" });
+ setBusy(null);
+ if (!r.ok) {
+ setStillError(await r.text());
+ return;
+ }
+ const blob = await r.blob();
+ setStill((prev) => {
+ if (prev) URL.revokeObjectURL(prev.url);
+ return {
+ url: URL.createObjectURL(blob),
+ at: Number(r.headers.get("x-still-at") ?? t),
+ estimated: r.headers.get("x-schedule-estimated") === "true",
+ };
+ });
+ }, [q, t]);
+
+ // ---- re-render on-screen: the build chain's own job runner ---------------
+ const rerender = useCallback(async () => {
+ setJobError(null);
+ const options: Record<string, unknown> = { chromeOnly: true };
+ if (variant) options.variant = variant;
+ const r = await fetch("/api/report/build", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project, preset: preset ?? "final", only: null, options }),
+ });
+ const j = (await r.json()) as Record<string, unknown>;
+ if (!r.ok) {
+ setJobError(String(j.error ?? r.status));
+ return;
+ }
+ since.current = 0;
+ setJob(j.job as JobView);
+ }, [project, preset, variant]);
+
+ useEffect(() => {
+ if (!job || job.state !== "running") return;
+ const tick = setInterval(async () => {
+ const r = await fetch(`/api/report/build?job=${job.id}&since=${since.current}`, { cache: "no-store" });
+ if (!r.ok) return;
+ const j = (await r.json()) as JobView;
+ since.current = j.next;
+ setJob((prev) => (prev ? { ...j, log: [...prev.log, ...j.log] } : j));
+ if (j.state !== "running") void loadFinal();
+ }, 800);
+ return () => clearInterval(tick);
+ }, [job, loadFinal]);
+
+ // ---- the live picture ----------------------------------------------------
+ const texts = useMemo<DeckTexts>(() => {
+ const out: DeckTexts = {};
+ for (const row of rows) {
+ const d = drafts[row.id] ?? draftOf(row.onscreen);
+ out[row.id] = { title: d.title.trim() || row.auto.title, subtitle: d.subtitle.trim() || row.auto.subtitle };
+ }
+ return out;
+ }, [rows, drafts]);
+
+ const schedule = preview?.schedule ?? null;
+ const current = schedule ? segmentAt(schedule, t) : null;
+ const backdropId = current && segmentOf.has(current.id) ? current.id : null;
+
+ // The backdrop follows the scrubber: the built segment of whichever clip is
+ // on screen, at the same offset into it.
+ useEffect(() => {
+ const el = backdrop.current;
+ if (!el || !current) return;
+ const at = Math.max(0, t - current.start);
+ const seek = () => {
+ el.currentTime = Math.min(at, Math.max(0, (el.duration || at) - 0.05));
+ };
+ if (el.readyState >= 1) seek();
+ else el.addEventListener("loadedmetadata", seek, { once: true });
+ return () => el.removeEventListener("loadedmetadata", seek);
+ }, [t, current, backdropId]);
+
+ const jump = (id: string) => {
+ const s = schedule?.segments.find((x) => x.id === id);
+ if (s) setT(midOf(s));
+ };
+
+ // ---- render ---------------------------------------------------------------
+ if (loadError) {
+ return (
+ <section data-testid="onscreen-section" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3">
+ <h2 className="micro">on-screen</h2>
+ <p className="text-[12px] text-[var(--color-bad)]">{loadError}</p>
+ </section>
+ );
+ }
+ if (!doc) {
+ return (
+ <section data-testid="onscreen-section" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3">
+ <h2 className="micro">on-screen</h2>
+ <p className="text-[11px] text-[var(--color-dim)]">reading the manifest…</p>
+ </section>
+ );
+ }
+
+ const on = doc.deckOn;
+ const over = (s: string) => s.trim().length > maxChars;
+
+ return (
+ <section
+ data-testid="onscreen-section"
+ data-onscreen={on ? "on" : "off"}
+ className="space-y-3 rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3"
+ >
+ {/* ---- the switch ---- */}
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <h2 className="micro">on-screen</h2>
+ <label className="flex items-center gap-1.5 text-[12px] text-[var(--color-text)]">
+ <input
+ type="checkbox"
+ data-testid="onscreen-toggle"
+ checked={on}
+ disabled={!!busy}
+ onChange={(e) => void toggle(e.target.checked)}
+ />
+ a panel under the footage: timeline, title, source, QR
+ </label>
+ {on && variants.length > 1 && (
+ <label className="flex items-center gap-1 text-[11px] text-[var(--color-dim)]">
+ cut
+ <select
+ data-testid="onscreen-variant"
+ value={variant}
+ onChange={(e) => {
+ setVariant(e.target.value);
+ setPreview(null);
+ }}
+ className={`${input} font-mono text-[11px]`}
+ >
+ <option value="">default</option>
+ {variants.map((v) => (
+ <option key={v} value={v}>
+ {v}
+ </option>
+ ))}
+ </select>
+ </label>
+ )}
+ {busy && <span className="micro">{busy}</span>}
+ {note && (
+ <span data-testid="onscreen-note" className={`text-[11px] ${stale ? "text-[var(--color-dirty)]" : "text-[var(--color-dim)]"}`}>
+ {note}
+ </span>
+ )}
+ {stale && (
+ <button type="button" data-testid="onscreen-reload" className={buttonVariants({ size: "sm" })} onClick={() => void reloadSaved()}>
+ reload saved values
+ </button>
+ )}
+ </div>
+
+ {!on && (
+ <p className="text-[11px] leading-snug text-[var(--color-dim)]">
+ Off: the cut is built with the citation header, the corner QR and the section footer. On, the
+ footage is scaled up above one panel that carries a pip per clip, a title per clip, the source
+ and date, and the clip’s QR. Titles can be written below either way; they are only drawn
+ when this is on.
+ </p>
+ )}
+
+ {errors.length > 0 && (
+ <ul data-testid="onscreen-errors" className="space-y-0.5 rounded border border-[var(--color-bad)] px-2 py-1 text-[11px] text-[var(--color-bad)]">
+ {errors.map((e) => (
+ <li key={e}>{e}</li>
+ ))}
+ </ul>
+ )}
+
+ {on && (
+ <div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(20rem,26rem)]">
+ {/* ================= the picture ================= */}
+ <div className="min-w-0 space-y-2">
+ {preview ? (
+ <DeckFrame preview={preview} t={t} texts={texts} testid="onscreen-preview">
+ {backdropId ? (
+ <video
+ ref={backdrop}
+ key={backdropId}
+ data-testid="onscreen-backdrop"
+ src={`/api/report/segment?project=${encodeURIComponent(project)}&clip=${encodeURIComponent(backdropId)}`}
+ muted
+ playsInline
+ preload="auto"
+ className="absolute inset-0 h-full w-full object-contain"
+ />
+ ) : (
+ <NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} />
+ )}
+ </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)]">
+ {composing ? "composing the preview…" : previewError ? "" : "no preview yet"}
+ </div>
+ )}
+ {previewError && (
+ <p data-testid="onscreen-preview-error" className="text-[11px] text-[var(--color-bad)]">
+ {previewError}
+ </p>
+ )}
+
+ {schedule && (
+ <div className="space-y-1">
+ {/* One block per segment, as long as it plays: where you are
+ in the cut, and a click to the middle of any of it. */}
+ <div className="flex h-4 w-full overflow-hidden rounded" data-testid="onscreen-segments">
+ {schedule.segments.map((s) => (
+ <button
+ key={s.id}
+ type="button"
+ title={`${s.id}${s.hideDeck ? " · panel hidden" : ""}`}
+ data-seg-jump={s.id}
+ onClick={() => setT(midOf(s))}
+ className={`h-full border-r border-[var(--color-ink)] ${
+ current?.id === s.id
+ ? "bg-[var(--color-sel)]"
+ : s.hideDeck
+ ? "bg-[var(--color-panel-2)]"
+ : dirtyIds.includes(s.id)
+ ? "bg-[var(--color-dirty)] opacity-70"
+ : "bg-[var(--color-line)]"
+ }`}
+ style={{ width: `${(s.duration / schedule.total) * 100}%` }}
+ />
+ ))}
+ </div>
+ <div className="flex items-center gap-2">
+ <input
+ type="range"
+ data-testid="onscreen-scrubber"
+ min={0}
+ max={schedule.total}
+ step={0.01}
+ value={t}
+ onChange={(e) => setT(Number(e.target.value))}
+ className="min-w-0 flex-1"
+ />
+ <span className="num w-28 text-right font-mono text-[11px] text-[var(--color-meter)]" data-testid="onscreen-time">
+ {clock(t)} / {clock(schedule.total)}
+ </span>
+ </div>
+ <div className="flex flex-wrap items-center gap-2 text-[11px] text-[var(--color-dim)]">
+ <span className="font-mono text-[var(--color-sel)]" data-testid="onscreen-current">
+ {current?.id ?? "—"}
+ </span>
+ {schedule.estimated ? (
+ <span className={badgeVariants({ variant: "open", size: "sm" })} data-testid="onscreen-estimated">
+ estimated timing
+ </span>
+ ) : (
+ <span className={badgeVariants({ variant: "meter", size: "sm" })}>built timing</span>
+ )}
+ <span>
+ {schedule.estimated
+ ? "nothing built yet, so the clock comes from the manifest; a build probes the real lengths"
+ : "the clock of the last build"}
+ </span>
+ </div>
+ </div>
+ )}
+
+ <div className="flex flex-wrap items-center gap-2">
+ <button
+ type="button"
+ 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"
+ >
+ {composing ? "recomposing…" : "recompose"}
+ </button>
+ <button
+ type="button"
+ data-testid="onscreen-true-still"
+ className={buttonVariants({ size: "sm" })}
+ disabled={!!busy || !schedule}
+ onClick={() => void trueStill()}
+ title="The render engine's own picture of this moment, of what is saved"
+ >
+ true still at {clock(t)}
+ </button>
+ <span className="mx-1 h-4 w-px bg-[var(--color-line)]" />
+ <select
+ value={preset ?? "final"}
+ onChange={(e) => setPreset(e.target.value as "final" | "fast")}
+ data-testid="onscreen-rerender-preset"
+ title="Concat the segments as the last build did: crossfaded (final) or hard cuts (fast)"
+ className={`${input} text-[11px]`}
+ >
+ <option value="final">as final (crossfades)</option>
+ <option value="fast">as fast (hard cuts)</option>
+ </select>
+ <button
+ type="button"
+ data-testid="onscreen-rerender"
+ className={buttonVariants({ variant: "primary", size: "sm" })}
+ disabled={job?.state === "running"}
+ onClick={() => void rerender()}
+ title="Render the panel over the segments already built and re-concat. No segment is rebuilt."
+ >
+ re-render on-screen
+ </button>
+ </div>
+ {dirtyIds.length > 0 && (
+ <p className="text-[11px] text-[var(--color-dirty)]">
+ the preview shows {dirtyIds.length} unsaved row{dirtyIds.length === 1 ? "" : "s"}; a true still and a
+ re-render use what is saved
+ </p>
+ )}
+
+ {stillError && (
+ <p data-testid="onscreen-still-error" className="text-[11px] text-[var(--color-bad)]">
+ {stillError}
+ </p>
+ )}
+ {still && (
+ <figure className="space-y-0.5">
+ <img
+ src={still.url}
+ alt={`the panel at ${clock(still.at)}, as the render draws it`}
+ data-testid="onscreen-still"
+ data-still-at={still.at}
+ className="w-full rounded border border-[var(--color-line)] bg-[repeating-conic-gradient(#1e2732_0_25%,#161c24_0_50%)] bg-[length:16px_16px]"
+ />
+ <figcaption className="num text-[11px] text-[var(--color-dim)]">
+ true still at {clock(still.at)}
+ {still.estimated ? " · estimated timing" : ""} — what is saved, drawn by the render’s browser
+ </figcaption>
+ </figure>
+ )}
+
+ {jobError && (
+ <p data-testid="onscreen-job-error" className="text-[11px] text-[var(--color-bad)]">
+ {jobError}
+ </p>
+ )}
+ {job && (
+ <div className="space-y-1" data-testid="onscreen-job" data-job-state={job.state}>
+ <div className="flex flex-wrap items-center gap-2 text-[11px]">
+ <span className={badgeVariants({ variant: job.state === "failed" ? "blocking" : job.state === "done" ? "meter" : "on", size: "sm" })}>
+ {job.state}
+ </span>
+ <span className="text-[var(--color-dim)]">{job.steps[job.stepIndex]?.label ?? "re-render on-screen"}</span>
+ {job.error && <span className="text-[var(--color-bad)]">{job.error}</span>}
+ {job.state === "running" && (
+ <button
+ type="button"
+ data-testid="onscreen-rerender-cancel"
+ className={buttonVariants({ variant: "destructive", size: "sm" })}
+ onClick={() => void fetch(`/api/report/build?cancel=${job.id}`, { method: "POST" })}
+ >
+ cancel
+ </button>
+ )}
+ </div>
+ <pre className="max-h-40 overflow-auto rounded border border-[var(--color-line)] bg-[var(--color-ink)] p-2 font-mono text-[11px] text-[var(--color-dim)]">
+ {job.log.slice(-60).join("\n")}
+ </pre>
+ </div>
+ )}
+ </div>
+
+ {/* ================= settings, then the built video ================= */}
+ <div className="min-w-0 space-y-3">
+ <div data-testid="onscreen-settings" className="space-y-1.5">
+ <div className="flex items-center gap-2">
+ <span className="micro">settings</span>
+ {formDirty && <span className="text-[11px] text-[var(--color-dirty)]">unsaved</span>}
+ <button
+ type="button"
+ data-testid="onscreen-settings-save"
+ className={`${buttonVariants({ variant: "primary", size: "sm" })} ml-auto`}
+ disabled={!!busy || !formDirty}
+ onClick={() => void saveSettings()}
+ >
+ save settings
+ </button>
+ <button
+ type="button"
+ data-testid="onscreen-settings-defaults"
+ className={buttonVariants({ size: "sm" })}
+ disabled={!!busy}
+ onClick={() => {
+ setForm(formOf(doc.defaults));
+ setFormDirty(true);
+ }}
+ title="Fill the form with the defaults; nothing is written until you save"
+ >
+ defaults
+ </button>
+ </div>
+ <div className="grid grid-cols-[max-content_minmax(0,1fr)] items-center gap-x-2 gap-y-1 text-[11px]">
+ {GROUPS.map((group) => (
+ <div key={group.name} className="contents">
+ <span className="micro pt-1">{group.name}</span>
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-1">
+ {group.fields.map((f) => {
+ const v = form[f.key];
+ const set = (nv: string | boolean) => {
+ setForm((prev) => ({ ...prev, [f.key]: nv }));
+ setFormDirty(true);
+ };
+ return (
+ <label key={f.key} className="flex items-center gap-1 text-[var(--color-dim)]" title={f.hint}>
+ {f.label}
+ {f.kind === "bool" ? (
+ <input
+ type="checkbox"
+ data-testid={`onscreen-setting-${f.key}`}
+ checked={!!v}
+ onChange={(e) => set(e.target.checked)}
+ />
+ ) : f.kind === "select" ? (
+ <select
+ data-testid={`onscreen-setting-${f.key}`}
+ value={String(v ?? "")}
+ onChange={(e) => set(e.target.value)}
+ className={`${input} text-[11px]`}
+ >
+ {f.options.map((o) => (
+ <option key={o} value={o}>
+ {o}
+ </option>
+ ))}
+ </select>
+ ) : (
+ <input
+ type="text"
+ inputMode={f.kind === "parts" ? "text" : "decimal"}
+ data-testid={`onscreen-setting-${f.key}`}
+ value={String(v ?? "")}
+ onChange={(e) => set(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") void saveSettings();
+ }}
+ className={`${input} num font-mono text-[11px] ${f.kind === "parts" ? "w-36" : "w-14"}`}
+ />
+ )}
+ </label>
+ );
+ })}
+ </div>
+ </div>
+ ))}
+ </div>
+ <p className="text-[11px] leading-snug text-[var(--color-dim)]">
+ Only what differs from the defaults is written. A refused value is named above in the
+ words the build would use; nothing is written until all of it is accepted.
+ </p>
+ </div>
+
+ <div className="space-y-1">
+ <span className="micro">the built video</span>
+ {typeof finalV === "number" ? (
+ <video
+ data-testid="onscreen-final-video"
+ src={`/api/report/video?${q}&kind=final&v=${finalV}`}
+ controls
+ preload="metadata"
+ className="aspect-video w-full rounded border border-[var(--color-line)] bg-black"
+ />
+ ) : (
+ <p data-testid="onscreen-no-final" className="text-[11px] text-[var(--color-dim)]">
+ {finalV === null ? "looking…" : "this cut has not been built yet"}
+ </p>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* ================= the words ================= */}
+ <div className="space-y-1.5">
+ <div className="flex flex-wrap items-center gap-2">
+ <span className="micro">titles and subtitles — {rows.length} entries</span>
+ <span className="text-[11px] text-[var(--color-dim)]">
+ empty uses the grey text; the counter warns past {maxChars}, it does not refuse
+ </span>
+ <button
+ type="button"
+ data-testid="onscreen-save"
+ className={`${buttonVariants({ variant: "primary", size: "sm" })} ml-auto`}
+ disabled={!!busy || dirtyIds.length === 0}
+ onClick={() => void saveRows()}
+ >
+ {dirtyIds.length ? `save ${dirtyIds.length} change${dirtyIds.length === 1 ? "" : "s"}` : "saved"}
+ </button>
+ {dirtyIds.length > 0 && (
+ <button
+ type="button"
+ data-testid="onscreen-discard"
+ className={buttonVariants({ size: "sm" })}
+ disabled={!!busy}
+ onClick={() => setDrafts(Object.fromEntries(rows.map((r) => [r.id, draftOf(r.onscreen)])))}
+ >
+ discard
+ </button>
+ )}
+ </div>
+ <div className="overflow-x-auto">
+ <table data-testid="onscreen-table" className="w-full border-collapse text-[12px]">
+ <thead>
+ <tr className="micro text-left">
+ <th className="w-16 px-1.5 py-0.5">entry</th>
+ <th className="px-1.5 py-0.5">title</th>
+ <th className="w-14 px-1.5 py-0.5 text-right">chars</th>
+ <th className="px-1.5 py-0.5">subtitle</th>
+ </tr>
+ </thead>
+ <tbody>
+ {rows.map((row) => {
+ const d = drafts[row.id] ?? draftOf(row.onscreen);
+ const dirty = !sameDraft(d, draftOf(row.onscreen));
+ const shown = d.title.trim() || row.auto.title;
+ const isCurrent = current?.id === row.id;
+ const set = (k: keyof Draft, v: string) =>
+ setDrafts((prev) => ({ ...prev, [row.id]: { ...(prev[row.id] ?? draftOf(row.onscreen)), [k]: v } }));
+ return (
+ <tr
+ key={row.id}
+ data-testid="onscreen-row"
+ data-entry={row.id}
+ data-dirty={dirty ? "1" : "0"}
+ data-current={isCurrent ? "1" : "0"}
+ className={`border-t border-[var(--color-line)] align-middle ${isCurrent ? "bg-[var(--color-panel-2)]" : ""}`}
+ >
+ <td className="px-1.5 py-1">
+ <button
+ type="button"
+ onClick={() => jump(row.id)}
+ className={`font-mono text-[11px] hover:underline ${isCurrent ? "text-[var(--color-sel)]" : "text-[var(--color-text)]"}`}
+ title="show this entry in the preview"
+ >
+ {row.id}
+ </button>
+ <span className="micro ml-1">{row.type === "clip" ? "" : row.type}</span>
+ {dirty && <span className="ml-1 text-[var(--color-dirty)]" title="unsaved">●</span>}
+ </td>
+ <td className="px-1.5 py-1">
+ <input
+ type="text"
+ data-testid="onscreen-title"
+ name={`title-${row.id}`}
+ value={d.title}
+ maxLength={200}
+ placeholder={row.auto.title || "(no title — the source line takes its place)"}
+ onFocus={() => jump(row.id)}
+ onChange={(e) => set("title", e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void saveRows();
+ }}
+ className={`${field} w-full ${over(shown) ? "border-[var(--color-dirty)]" : "border-[var(--color-line)]"}`}
+ />
+ </td>
+ <td
+ className={`num px-1.5 py-1 text-right font-mono text-[11px] ${over(shown) ? "text-[var(--color-dirty)]" : "text-[var(--color-dim)]"}`}
+ data-testid="onscreen-count"
+ data-over={over(shown) ? "1" : "0"}
+ >
+ {shown.trim().length}/{maxChars}
+ </td>
+ <td className="px-1.5 py-1">
+ <input
+ type="text"
+ data-testid="onscreen-subtitle"
+ name={`subtitle-${row.id}`}
+ value={d.subtitle}
+ maxLength={200}
+ placeholder={row.auto.subtitle || "(no archive record here — the build reads the fetched file)"}
+ onFocus={() => jump(row.id)}
+ onChange={(e) => set("subtitle", e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void saveRows();
+ }}
+ className={`${input} w-full`}
+ />
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </section>
+ );
+}
diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx
@@ -19,6 +19,7 @@ import { diffManifests, formatChange } from "@/lib/report/manifest-diff.mjs";
import { listSnapshots, readSnapshot } from "@/lib/report/snapshots.mjs";
import DeliverSection from "./DeliverSection";
import FetchUnfetchedButton from "./FetchUnfetchedButton";
+import OnscreenSection from "./OnscreenSection";
import ReportBuildChain from "./ReportBuildChain";
import SnapshotButton from "./SnapshotButton";
import TagCitedButton from "./TagCitedButton";
@@ -409,6 +410,17 @@ export default async function ReportProject({
entries={entries.map((e) => ({ id: e.id, kind: e.kind }))}
/>
+ {/* --- what is drawn over it --------------------------------------- */}
+ {/* The on-screen panel: its settings, its words and a live preview of
+ both. After the build chain, because "re-render on-screen" is a run
+ of that chain over segments it already built; before Deliver,
+ because the panel is part of the video that gets delivered. */}
+ <OnscreenSection
+ project={project.id}
+ entries={entries.map((e) => ({ id: e.id, kind: e.kind, segment: !!e.segment }))}
+ built={!!build.built}
+ />
+
{/* --- delivering it --------------------------------------------- */}
{/* After the build chain, because that is the order the work happens
in: the video is one deliverable and the written report with its