import { composeDeckPreview, composeFeedPreview, composePostsPreviews, composeStampPreview, normalizeClaimsDraft, normalizeDraft, normalizePostsDraft, scheduleForPreview, segmentBoxes, } from "@/lib/report/onscreen.mjs"; import { deckPreviewSrc, feedPreviewSrc, postsPreviewSrc, resolveReport, stampPreviewSrc } from "@/lib/report/serve.mjs"; import { deckGeometry, deckLayout, deckOn, feedGeometry, postsGeometry, stampGeometry, validateChrome, } from "umtool-report-to-video/deck"; import { tallyOf } from "umtool-report-to-video/factcheck"; export const dynamic = "force-dynamic"; // Compose the deck's PREVIEW for one cut, and say where to load it. // // No render. compose-chrome writes out//chrome/deck-preview/ -- never // the build's chrome/deck/ or its frame cache -- and the page loads that in an // iframe from `src`, at `geometry.deck`'s rect over the footage, seeking it with // postMessage. Live typing is patched in by message too (`deck:text`); this // route is for when the text or the timing changes enough to recompose. // // The schedule is the build's own when one exists and still matches the cut, // else an estimate from the manifest (`schedule.estimated`), and the request's // `draft` -- unsaved rows, id → { title?, subtitle? } | null, the shape PUT // /api/report/onscreen takes -- is applied on top. // // The schedule carries the posts' holds (a carrying clip's `hold`, part of its // length) and the footage's `moves` when `posts.shift` is on; the page eases // its backdrop along them, and the posts region sits at `postsGeometry`, at // the frame's edge when the footage makes room. // // The posts region is composed beside it, one project per window // (postWindows: a clip that carries posts, from its first post's appearance to // the end of their leave), each loaded in its own iframe at // `posts.geometry` over the footage while the scrubber is inside the window. // `postsDraft` -- unsaved overrides, the shape PUT /api/report/posts takes -- // is applied first. A window that does not compose says why in its row; the // deck's preview is returned either way. // // The posts FEED (`posts.layout: "feed"`, when the schedule says `layout: // "feed"`) is one more composition for the whole cut, `feed.src`, loaded at // `feed.geometry` (the column) for the whole scrub; the footage of a feed cut // sits in `feed.footage`, and `feed.boxes` says which box each built segment // was framed into, so a backdrop built for the deck is carried into the // feed's box. A feed has no windows. // // The fact-check STAMPS (a draft row's or an entry's `claim`, the schedule's // `factcheck.stamps`) are one more composition for the whole cut, // `stamp.src`, loaded at `stamp.geometry` over the footage for the whole // scrub; the deck's own page draws the tally, and `layout` makes room for it. // // The client sends a project id, a variant and the drafts. Never a path. export async function POST(request: Request) { let body: Record; try { body = await request.json(); } catch { return Response.json({ error: "expected JSON" }, { status: 400 }); } const r = await resolveReport(String(body.project ?? ""), body.variant ? String(body.variant) : null); if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); let draft; let claimsDraft; try { draft = normalizeDraft(body.draft); claimsDraft = normalizeClaimsDraft(body.draft); } catch (e) { return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 }); } let postsDraft; try { postsDraft = normalizePostsDraft(body.postsDraft); } catch (e) { return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 }); } const { variantManifest, schedule } = await scheduleForPreview(r.project, r.manifest, r.variant, draft, postsDraft, { claimsDraft }); const render = (variantManifest.render ?? {}) as Record; if (!deckOn(render)) { return Response.json( { error: "the deck is off for this manifest — save render.chrome first", deckOn: false }, { status: 409 }, ); } const { chrome, ...rest } = render; const errors = validateChrome(chrome, rest); if (errors.length) { return Response.json({ error: `render.chrome: ${errors.join("; ")}`, errors }, { status: 400 }); } try { await composeDeckPreview(r.project, r.variant, schedule); } catch (e) { return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 }); } // `posts: false` -- the clip bench's strip, which has no footage to lay them on. const windows = body.posts === false ? [] : await composePostsPreviews(r.project, r.variant, schedule); const stamp = Date.now(); const isFeed = body.posts !== false && (schedule as { layout?: string }).layout === "feed"; let feed: Record | null = null; if (isFeed) { const g = feedGeometry(render); let error: string | null = null; try { await composeFeedPreview(r.project, r.variant, schedule); } catch (e) { error = e instanceof Error ? e.message : String(e); } feed = { geometry: g.column, footage: g.footage, boxes: await segmentBoxes(r.project, r.variant, (variantManifest.timeline ?? []) as { id: string }[], render), ...(error ? { error } : { src: `${feedPreviewSrc(r.project.id, r.variant)}?v=${stamp}` }), }; } // The fact-check stamps, when the cut stamps a claim -- over the footage, // so not on the clip bench's strip either. const stamps = (schedule as { factcheck?: { stamps?: unknown[] } }).factcheck?.stamps ?? []; let stampDoc: Record | null = null; if (body.posts !== false && stamps.length) { let error: string | null = null; try { await composeStampPreview(r.project, r.variant, schedule); } catch (e) { error = e instanceof Error ? e.message : String(e); } stampDoc = { geometry: stampGeometry(render, { feed: (schedule as { layout?: string }).layout === "feed" }), ...(error ? { error } : { src: `${stampPreviewSrc(r.project.id, r.variant)}?v=${stamp}` }), }; } return Response.json( { // `v` so the iframe reloads a recomposed preview; the files themselves // are served no-store, so its relative asset urls need none. src: `${deckPreviewSrc(r.project.id, r.variant)}?v=${stamp}`, variant: r.variant, geometry: deckGeometry(render), // The ground a footage moved aside for the posts (schedule.moves) leaves showing. background: typeof (render.palette as { bg?: unknown } | undefined)?.bg === "string" ? (render.palette as { bg: string }).bg : null, layout: deckLayout(render, { tally: tallyOf(schedule, render) }), schedule, posts: { geometry: postsGeometry(render), windows: windows.map((w) => ({ segment: w.segment, from: w.from, to: w.to, ...(w.ok ? { src: `${postsPreviewSrc(r.project.id, r.variant, w.segment)}?v=${stamp}` } : { error: w.error }), })), }, ...(feed ? { feed } : {}), ...(stampDoc ? { stamp: stampDoc } : {}), }, { headers: { "cache-control": "no-store" } }, ); }