Archilyzer · Source

archilyzer

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

commit 5dc81a12bcc8c7360acdd7f5ed438152896eb804
parent b50e90429d315c8094392fd4790a1da17d7f1609
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 22:17:34 -0400

Merge deck/s5-umtool-ui (deck slice S5) — the On-screen section on the report page (switch, settings, title/subtitle table with auto placeholders and a maxChars counter, live composition preview with scrubber, true still, re-render on-screen, the built video) and on-screen fields with a live deck preview on the clip bench; reviewed by screenshots

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

Diffstat:
Mumtool/components/projects/ClipBench.tsx | 278++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/components/projects/ClipBenchPage.tsx | 10++++++++++
Aumtool/components/projects/OnscreenSection.tsx | 1305+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/ReportProject.tsx | 12++++++++++++
4 files changed, 1603 insertions(+), 2 deletions(-)

diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -10,6 +10,16 @@ import { buttonVariants } from "@/components/ui/button"; // disagrees with the header by a character is worth less than no preview: you // would only find out twenty minutes into a build. import { attributionLine } from "umtool-report-to-video/attribution"; +import { + DeckFrame, + NeutralFrame, + composePreview, + midOf, + onscreenValue, + type DeckPreviewDoc, + type DeckTexts, + type Onscreen, +} from "./OnscreenSection"; // --------------------------------------------------------------------------- // Editing a clip's window against the audio and the words. @@ -92,8 +102,15 @@ type Clip = { lockCut: boolean; /** "confirmed" / "incorrect", or null for "nobody has looked at this yet". */ verdict: "confirmed" | "incorrect" | null; + /** What the on-screen panel says over this clip. Absent means the auto text. */ + onscreen: Onscreen | null; }; +/** The on-screen fields as the inputs hold them. */ +type OsDraft = { title: string; subtitle: string }; +const osDraftOf = (o: Onscreen | null): OsDraft => ({ title: o?.title ?? "", subtitle: o?.subtitle ?? "" }); +const sameOs = (a: OsDraft, b: OsDraft) => a.title.trim() === b.title.trim() && a.subtitle.trim() === b.subtitle.trim(); + /** * Has anybody looked at this clip, and did they agree with the description? * @@ -164,6 +181,7 @@ const fromEntry = (prev: Clip, e: Record<string, unknown>): Clip => ({ cutEnd: e.cutEnd == null ? null : Number(e.cutEnd), lockCut: !!e.lockCut, verdict: e.verdict === "confirmed" || e.verdict === "incorrect" ? e.verdict : null, + onscreen: (e.onscreen as Onscreen | undefined) ?? null, }); export type ClipBenchData = { @@ -219,6 +237,8 @@ export type ClipBenchData = { mixHref: string | null; /** Every other clip in the cut from this same recording, by start. */ siblings: Sibling[]; + /** Is the cut built with the on-screen panel (`render.chrome`)? */ + deckOn: boolean; }; const hms = (t: number) => { @@ -313,6 +333,20 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const [playback, setPlayback] = useState<Playback>(PLAYBACK_DEFAULT); const [segment, setSegment] = useState(data.segment); const [segmentMtime, setSegmentMtime] = useState(data.segmentMtime); + // ---- the on-screen panel ------------------------------------------------ + // Its two fields, the text the panel says with them empty (`osAuto`, from + // the On-screen route), and a composed preview of the whole cut's panel, of + // which this bench shows this clip's segment. + const [os, setOs] = useState<OsDraft>(() => osDraftOf(data.clip.onscreen)); + const [osAuto, setOsAuto] = useState<OsDraft | null>(null); + const [osMax, setOsMax] = useState(48); + const [deckPreview, setDeckPreview] = useState<DeckPreviewDoc | null>(null); + const [deckError, setDeckError] = useState<string | null>(null); + // The rendered strip is folded; its overlay only exists while it is open. + const [renderedOpen, setRenderedOpen] = useState(false); + const [segT, setSegT] = useState<number | null>(null); + const [segPaused, setSegPaused] = useState(true); + const [segDur, setSegDur] = useState(0); const router = useRouter(); const video = useRef<HTMLVideoElement | null>(null); @@ -392,6 +426,64 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // ---- playback preferences ------------------------------------------------ useEffect(() => setPlayback(readPlayback()), []); + // ---- the on-screen preview ---------------------------------------------- + // + // Composed once per clip: the cut's whole panel, from which the strip and + // the overlay show this clip's segment. Typing never recomposes -- the + // fields are patched in by message, before anything is saved. + useEffect(() => { + if (!data.deckOn) return; + let live = true; + void (async () => { + const [pv, table] = await Promise.all([ + composePreview(data.project, null), + fetch(`/api/report/onscreen?project=${encodeURIComponent(data.project)}`, { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null) as Promise<{ + rows?: { id: string; auto: OsDraft }[]; + maxChars?: number; + } | null>, + ]); + if (!live) return; + if ("error" in pv) setDeckError(pv.error); + else setDeckPreview(pv); + const row = table?.rows?.find((r) => r.id === clip.id); + if (row) setOsAuto(row.auto); + if (table?.maxChars) setOsMax(table.maxChars); + })(); + return () => { + live = false; + }; + }, [data.deckOn, data.project, clip.id]); + + // The rendered segment drives the overlay's clock: its own time, plus where + // its segment starts in the cut. + useEffect(() => { + const el = segVideo.current; + if (!el || !renderedOpen) return; + const tick = () => setSegT(el.currentTime); + const state = () => setSegPaused(el.paused); + const meta = () => { + setSegDur(el.duration || 0); + // A quarter in, not zero: at the segment's first frame the PREVIOUS + // clip's title is still handing over, which reads as the wrong title. + if (el.currentTime === 0 && el.duration) el.currentTime = el.duration / 4; + }; + el.addEventListener("timeupdate", tick); + el.addEventListener("seeked", tick); + el.addEventListener("play", state); + el.addEventListener("pause", state); + el.addEventListener("loadedmetadata", meta); + if (el.readyState >= 1) meta(); + return () => { + el.removeEventListener("timeupdate", tick); + el.removeEventListener("seeked", tick); + el.removeEventListener("play", state); + el.removeEventListener("pause", state); + el.removeEventListener("loadedmetadata", meta); + }; + }, [segment, segmentMtime, renderedOpen, deckPreview]); + /** * Change a preference AND remember it. Never an effect on `playback`. * @@ -437,7 +529,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const again = () => apply(el); el.addEventListener("loadedmetadata", again); return () => el.removeEventListener("loadedmetadata", again); - }, [playback, segment, cached]); + }, [playback, segment, cached, renderedOpen, deckPreview]); // ---- audition ----------------------------------------------------------- const play = useCallback( @@ -570,6 +662,17 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { for (const [k] of ATTRIB) if (patch[k] !== undefined) out[k] = attribValue(next, k); return out; }); + // The on-screen pair saves as one value. Each field goes back to what was + // stored only if it still holds what was sent -- typing on into the + // other field while this was in flight must not be undone by it. + if (patch.onscreen !== undefined) { + const sent = osDraftOf(patch.onscreen as Onscreen | null); + const stored = osDraftOf(next.onscreen); + setOs((d) => ({ + title: d.title.trim() === sent.title ? stored.title : d.title, + subtitle: d.subtitle.trim() === sent.subtitle ? stored.subtitle : d.subtitle, + })); + } token.current = String(j.token ?? ""); setNote("saved"); // The widener's opinion changes when the window does. @@ -629,6 +732,12 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { [draft, clip, save, needNote], ); + /** Persist the on-screen pair, on blur or Enter, if either field changed. */ + const commitOs = useCallback(() => { + if (sameOs(os, osDraftOf(clip.onscreen))) return; + void save({ onscreen: onscreenValue(os) }); + }, [os, clip.onscreen, save]); + // ---- the window patch, one rule, two callers ------------------------------ // // `save window` writes it on demand; a confirmation carries it when the edges @@ -1133,6 +1242,28 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const verdict = verdictOf(clip); const reviewed = data.reviewedOthers + (verdict === "unreviewed" ? 0 : 1); + // ---- the on-screen panel, as this clip's segment of it -------------------- + const deckSeg = deckPreview?.schedule.segments.find((sg) => sg.id === clip.id) ?? null; + const osShown: OsDraft = { + title: os.title.trim() || osAuto?.title || "", + subtitle: os.subtitle.trim() || osAuto?.subtitle || "", + }; + // Nothing is patched in until the auto text is known: an empty field would + // otherwise blank the line the composition was built with. + const osTexts: DeckTexts = osAuto ? { [clip.id]: osShown } : {}; + const osChanged = !sameOs(os, osDraftOf(clip.onscreen)); + const osOver = osShown.title.length > osMax; + // The strip follows the player when the player is inside what the cut + // plays, mapped onto the cut's clock; anywhere else it holds mid-segment. + const playFrom = clip.cutStart ?? clip.start; + const stripT = !deckSeg + ? 0 + : playhead != null && playhead >= playFrom - 0.05 && playhead <= playFrom + deckSeg.duration + ? deckSeg.start + Math.max(0, playhead - playFrom) + : midOf(deckSeg); + const overlayT = deckSeg ? deckSeg.start + Math.min(segT ?? deckSeg.duration / 4, deckSeg.duration) : 0; + const showOverlay = data.deckOn && !!deckPreview && renderedOpen; + return ( // ONE SCREEN, at a desk. // @@ -1756,6 +1887,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { bench taller than a laptop. */} <details data-rendered-strip="" + onToggle={(e) => setRenderedOpen((e.currentTarget as HTMLDetailsElement).open)} className="shrink-0 rounded border border-[var(--color-line)] px-2 py-1 text-[12px] open:max-h-[55vh] open:overflow-y-auto" > <summary className="cursor-pointer text-[11px] text-[var(--color-dim)]"> @@ -1768,7 +1900,59 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { </summary> <div className="mt-2 grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]"> <div className="space-y-2"> - {segSrc ? ( + {segSrc && showOverlay ? ( + // The segment with the on-screen panel laid over it, where the + // build overlays it. The panel covers the bottom of the frame + // -- where native controls would be -- so the player's + // controls are a row of its own underneath. + <div className="space-y-1"> + <DeckFrame preview={deckPreview!} t={overlayT} texts={osTexts} testid="bench-deck-preview"> + <video + ref={segVideo} + data-testid="segment-video" + src={segSrc} + className="absolute inset-0 h-full w-full cursor-pointer object-contain" + preload="metadata" + onClick={(e) => { + const el = e.currentTarget; + if (el.paused) void el.play().catch(() => {}); + else el.pause(); + }} + /> + </DeckFrame> + <div className="flex items-center gap-2"> + <button + type="button" + data-testid="segment-play" + className={buttonVariants({ size: "sm" })} + onClick={() => { + const el = segVideo.current; + if (!el) return; + if (el.paused) void el.play().catch(() => {}); + else el.pause(); + }} + > + {segPaused ? "play" : "pause"} + </button> + <input + type="range" + data-testid="segment-scrub" + min={0} + max={segDur || 0} + step={0.01} + value={Math.min(segT ?? 0, segDur || 0)} + onChange={(e) => { + const el = segVideo.current; + if (el) el.currentTime = Number(e.target.value); + }} + className="min-w-0 flex-1" + /> + <span className="num font-mono text-[11px] text-[var(--color-dim)]"> + {(segT ?? 0).toFixed(1)} / {segDur.toFixed(1)}s + </span> + </div> + </div> + ) : segSrc ? ( <video ref={segVideo} data-testid="segment-video" @@ -1777,6 +1961,17 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { preload="metadata" controls /> + ) : showOverlay ? ( + <div data-testid="no-segment"> + <DeckFrame + preview={deckPreview!} + t={deckSeg ? midOf(deckSeg) : 0} + texts={osTexts} + testid="bench-deck-preview" + > + <NeutralFrame geometry={deckPreview!.geometry} label="nothing rendered for this clip yet" /> + </DeckFrame> + </div> ) : ( <div data-testid="no-segment" @@ -2046,6 +2241,85 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { })} </div> + {/* ---- on-screen: what the panel under the footage says ---- + Beside the header's fields because it is the same sitting: the + words you hear are the words a title should summarise. Both + save together, on blur or Enter; an empty one is the grey text. */} + <div + data-testid="bench-onscreen" + data-deck-on={data.deckOn ? "1" : "0"} + className="space-y-1 rounded border border-[var(--color-line)] px-2 py-1.5" + > + <div className="flex flex-wrap items-baseline gap-2"> + <span className="micro">on-screen</span> + {osChanged && <span className="text-[11px] text-[var(--color-dirty)]">unsaved</span>} + {!data.deckOn && ( + <span className="text-[11px] text-[var(--color-dim)]"> + off for this report — kept, and drawn once it is on (project page → On-screen) + </span> + )} + </div> + {( + [ + ["title", "The big line over this clip. Empty: no title, and the source line takes its place."], + ["subtitle", "The line under it. Empty: the source and date, from the archive."], + ] as const + ).map(([k, why]) => ( + <label key={k} className="flex items-center gap-2" title={why}> + <span className="w-14 shrink-0 font-mono text-[11px] text-[var(--color-text)]">{k}</span> + <input + type="text" + data-testid={`bench-onscreen-${k}`} + name={`onscreen-${k}`} + value={os[k]} + maxLength={200} + placeholder={ + osAuto?.[k] || + (k === "title" ? "(no title — the source line takes its place)" : "(the source and date)") + } + onChange={(e) => setOs((d) => ({ ...d, [k]: e.target.value }))} + onBlur={commitOs} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitOs(); + } + }} + className={`min-w-0 flex-1 rounded border bg-[var(--color-panel-2)] px-2 py-1 text-[12px] placeholder:italic placeholder:text-[var(--color-dim)] ${ + k === "title" && osOver ? "border-[var(--color-dirty)]" : "border-[var(--color-line)]" + }`} + /> + {k === "title" && ( + <span + data-testid="bench-onscreen-count" + data-over={osOver ? "1" : "0"} + className={`num w-12 shrink-0 text-right font-mono text-[11px] ${osOver ? "text-[var(--color-dirty)]" : "text-[var(--color-dim)]"}`} + > + {osShown.title.length}/{osMax} + </span> + )} + {k === "subtitle" && <span className="w-12 shrink-0" />} + </label> + ))} + {data.deckOn && + (deckPreview ? ( + <DeckFrame + preview={deckPreview} + t={stripT} + texts={osTexts} + mode="strip" + testid="bench-deck-strip" + /> + ) : ( + <p className="text-[11px] text-[var(--color-dim)]" data-testid="bench-deck-pending"> + {deckError ? `no preview: ${deckError}` : "composing the panel…"} + </p> + ))} + {deckPreview && !deckSeg && ( + <p className="text-[11px] text-[var(--color-dirty)]">this clip is not in the default cut</p> + )} + </div> + <details className="rounded border border-[var(--color-line)] px-2 py-1 text-[11px] text-[var(--color-dim)]"> <summary className="cursor-pointer">what these fields are for</summary> <div className="mt-1 space-y-1 leading-snug"> diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx @@ -12,6 +12,7 @@ import { } from "@/lib/projects/report.mjs"; import { projectCache, segmentFor, windowsFor } from "@/lib/report/serve.mjs"; import { FETCH_MAX_PAD } from "@/lib/report/driver.mjs"; +import { deckOn } from "umtool-report-to-video/deck"; import type { ProjectRef } from "@/lib/project-types"; // The server half of the bench: resolve the clip, read what it needs, and hand @@ -89,7 +90,16 @@ export default async function ClipBenchPage({ lockCut: !!entry.lockCut, verdict: entry.verdict === "confirmed" || entry.verdict === "incorrect" ? entry.verdict : null, + // From the manifest's own entry: what the on-screen panel says over this + // clip, when somebody has written it. + onscreen: + ((manifest.timeline ?? []) as { id: string; onscreen?: { title?: string; subtitle?: string } }[]).find( + (e) => e.id === clipId, + )?.onscreen ?? null, }, + // Whether the cut is built with the on-screen panel. Decides whether the + // bench composes a preview of it; the fields are there either way. + deckOn: deckOn(manifest.render ?? {}), view, windows: windows.map((w: { name: string; from: number; to: number }) => ({ name: w.name, diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx @@ -0,0 +1,1305 @@ +"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); + // The switch as pressed, while its write is in flight: the box follows the + // hand at once and falls back to the manifest's answer if the write fails. + const [switching, setSwitching] = useState<boolean | null>(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; + } + setSwitching(on); + const ok = await putChrome(on ? { engine: "hyperframes", layout: "deck", deck: {} } : null); + if (!ok) { + setSwitching(null); + return; + } + const c = await loadChrome(); + setSwitching(null); + 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; + // The validator's sentences, one per problem. Beside the settings when the + // panel is on (that is the form they are about); at the top when it is off, + // where a refused switch is. + const errorList = 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> + ); + + const 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> + ); + + 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={switching ?? on} + disabled={!!busy || switching !== null} + 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&rsquo;s QR. Titles can be written below either way; they are only drawn + when this is on. + </p> + )} + + {!on && errorList} + + {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" })} + // Over the segments a build left, so there must have been one. + disabled={job?.state === "running" || finalV === "none"} + onClick={() => void rerender()} + title={ + typeof finalV === "number" + ? "Render the panel over the segments already built and re-concat. No segment is rebuilt." + : "Build the cut first: a re-render works over the segments a build left" + } + > + 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&rsquo;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> + {errorList} + <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 ================= + Open when the panel is on. Folded when it is off: a report that + 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 + ) : ( + <details data-testid="onscreen-words-folded"> + <summary className="cursor-pointer text-[11px] text-[var(--color-dim)]"> + titles and subtitles — {rows.length} entries + {rows.some((r) => r.onscreen) ? ` · ${rows.filter((r) => r.onscreen).length} written` : ""} + </summary> + <div className="mt-1.5">{words}</div> + </details> + )} + </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