"use client"; import GeneratedBanner from "@/components/notes/GeneratedBanner"; import { TimedVideo } from "@/components/notes/TimedNotes"; import StructureEditors from "./StructureEditors"; import { MANIFEST_CHANGED } from "./timelineApi"; import { announceNotesChanged, wroteNotes } from "@/components/notes/NotesProvider"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { badgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { backdropTransform, footageAt } from "@/lib/report/footage-move.mjs"; import { VERDICTS } from "umtool-report-to-video/factcheck"; // --------------------------------------------------------------------------- // 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//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; /** Seconds this segment is held on its last frame for its posts (part of `duration`); absent when none. */ hold?: number; /** The fact-check claim this entry carries; absent when none. */ claim?: Claim; }; /** One claim's verdict stamp, in the cut's clock (factcheck.mjs stampSchedule). */ export type Stamp = { claim: string; verdict: string; segment: string; at: number; landed: number; out: [number, number] }; /** The footage moving aside for a clip's posts (`posts.shift`), as the schedule says. */ export type FootageMove = { segment: string; at: number; segmentAt: number; seconds: number; from: Rect; to: Rect }; export type DeckSchedule = { estimated?: boolean; /** "feed" when the posts are a column for the whole cut (`posts.layout: "feed"`, posts to draw). */ layout?: "feed"; fps: number; transition: number; total: number; multiChannel: boolean; segments: DeckSegment[]; moves?: FootageMove[]; /** The fact-check stamps, when an entry carries a claim. */ factcheck?: { stamps: Stamp[] }; }; /** A placed post: the popup's `appear` and `out`, or the feed's tick-in, `in`. */ export type PostSlot = { id: string; segment: string; slot: number; of: number; appear?: number; out?: [number, number]; in?: number }; /** The posts feed's preview: the column, the footage box beside it, each built segment's framing box, the composition. */ export type FeedPreview = { geometry: Rect; footage: Rect; boxes: Record; src?: string; error?: string }; /** One posts window's preview composition: `src` when it composed, `error` when it did not. */ export type PostsWindow = { segment: string; from: number; to: number; src?: string; error?: string }; export type DeckPreviewDoc = { src: string; variant: string; geometry: DeckGeometry; /** The palette's background: the ground a moved footage leaves showing. */ background?: string | null; schedule: DeckSchedule & { posts?: PostSlot[] }; /** The posts region: where it sits in the frame, and one composition per window. */ posts?: { geometry: Rect; windows: PostsWindow[] }; /** The posts feed, when the cut is one: a single composition for the whole cut. */ feed?: FeedPreview; /** The fact-check stamps, when the cut stamps a claim: one composition for the whole cut, over the footage. */ stamp?: { geometry: Rect; src?: string; error?: string }; }; /** An unsaved change to a post, as PUT /api/report/posts takes it. */ export type PostPatch = { attachTo?: string | null; hide?: boolean }; /** What each segment's panel should say right now, by entry id. */ export type DeckTexts = Record; export type Onscreen = { title?: string; subtitle?: string }; /** An entry's fact-check claim (factcheck.mjs): its id in the report and its verdict. */ export type Claim = { id: string; verdict: string }; /** A row as PUT /api/report/onscreen takes it: the text, and the claim beside it (null removes it). */ export type OnscreenRow = Onscreen & { claim?: Claim | null }; /** 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. * `postsDraft` is the Posts table's unsaved overrides; `posts: false` skips * composing the posts windows (the bench's strip has no footage to put them on). */ export async function composePreview( project: string, variant: string | null, draft: Record = {}, { postsDraft = {}, posts = true }: { postsDraft?: Record; posts?: boolean } = {}, ): Promise { const r = await fetch("/api/report/chrome/preview", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project, variant: variant || undefined, draft, postsDraft, ...(posts ? {} : { posts: false }) }), }); const j = (await r.json().catch(() => ({}))) as Record; 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(null); const frame = useRef(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({}); 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) => { 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 (
{mode === "frame" && children}