Archilyzer · Source

archilyzer

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

commit 3740bd67dce7bf32699ef0565cbc26181c7f18a6
parent 3ca9da34bfc7f485ecce18f7008a42b7468746b6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 21:17:25 -0400

deck S4: the on-screen routes — chrome, onscreen, preview, files, still, video

- GET/PUT /api/report/chrome — the render.chrome block (with defaults filled
  and any validation errors on read); PUT writes through updateChrome, 400
  with `errors` on a refusal, 409 on a stale token.
- GET/PUT /api/report/onscreen — the table's rows (saved onscreen plus the
  auto title and subtitle as placeholders); PUT writes through
  updateOnscreen, all or nothing.
- POST /api/report/chrome/preview — composes out/<variant>/chrome/
  deck-preview/ (no render) and returns { src, geometry, layout, schedule }.
- GET /api/report/chrome/files/<project>/<variant>/<file…> — serves that
  directory through deckPreviewFile.
- GET /api/report/still — a PNG of the deck at a clip's midpoint or a time.
- GET /api/report/video — the cut or its --chrome-preview window, ranged.

lib/report/onscreen.mjs picks the preview's schedule: the build's
schedule.json when it is the deck's and still lists the cut's entries in
order (its timings and QRs kept, its text re-derived from the manifest as it
is now with the draft applied), else estimateSchedule with cue-file metadata.
The compose and still calls are serialised per preview directory and code
against compose-chrome's deck contract; a preview written anywhere but
deckPreviewDir is an error.

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

Diffstat:
Aumtool/app/api/report/chrome/files/[...path]/route.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/chrome/preview/route.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/chrome/route.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/onscreen/route.ts | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/still/route.ts | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/video/route.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/onscreen.mjs | 265+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/onscreen.test.mjs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 783 insertions(+), 0 deletions(-)

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