Archilyzer · Source

archilyzer

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

commit 3dfdb6b792f9128437224c4694e9a57d51404f30
parent caa1739f4c30674a15474ab1f1763db1ebfec5b5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 21:20:55 -0400

Merge deck/s4-umtool-api (deck slice S4) — umtool writers (updateOnscreen, updateChrome, updateClip onscreen), the chrome/onscreen/preview/files/still/video routes, rangeResponse shared with the segment route, buildSteps chromeOnly; reviewed

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

Diffstat:
Mpackage.json | 2+-
Mumtool/app/api/report/build/route.ts | 21+++++++++++++++++----
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+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/api/report/segment/route.ts | 36++++--------------------------------
Aumtool/app/api/report/still/route.ts | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/video/route.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/api/report/window/route.ts | 3+++
Mumtool/lib/report/driver.mjs | 21++++++++++++++++-----
Aumtool/lib/report/driver.test.mjs | 26++++++++++++++++++++++++++
Mumtool/lib/report/manifest.mjs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/manifest.test.mjs | 296+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/onscreen.mjs | 265+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/onscreen.test.mjs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/serve.mjs | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Aumtool/lib/report/serve.test.mjs | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
18 files changed, 1631 insertions(+), 45 deletions(-)

diff --git a/package.json b/package.json @@ -22,7 +22,7 @@ "e2e": "node scripts/worktree.mjs run -- pnpm --filter editor run e2e", "wt": "node scripts/worktree.mjs", "e2e:sharded": "node scripts/run-sharded-e2e.mjs", - "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs", + "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs umtool/lib/report/*.test.mjs", "lint": "pnpm --filter export run lint", "ops": "node scripts/archilyzer-ops.mjs" }, diff --git a/umtool/app/api/report/build/route.ts b/umtool/app/api/report/build/route.ts @@ -76,7 +76,13 @@ export async function POST(request: Request) { // ---- the options the pipeline has and the driver used to hide ---------- const raw = (body.options ?? {}) as Record<string, unknown>; - const options: { variant?: string; xfade?: boolean; chaptersOnly?: boolean; preview?: { at: number; dur: number } | null } = {}; + const options: { + variant?: string; + xfade?: boolean; + chaptersOnly?: boolean; + chromeOnly?: boolean; + preview?: { at: number; dur: number } | null; + } = {}; if (raw.variant !== undefined && raw.variant !== "") { const v = String(raw.variant); if (!(VARIANTS as string[]).includes(v)) { @@ -86,6 +92,10 @@ export async function POST(request: Request) { } if (raw.xfade === false) options.xfade = false; if (raw.chaptersOnly) options.chaptersOnly = true; + // Re-render the on-screen deck over the segments on disk ("Re-render + // on-screen"). Like chaptersOnly it rewrites the deliverable in place because + // somebody asked it to, so the overwrite guard below does not apply. + if (raw.chromeOnly) options.chromeOnly = true; if (raw.preview && typeof raw.preview === "object") { const pv = raw.preview as Record<string, unknown>; const at = Number(pv.at); @@ -95,8 +105,11 @@ export async function POST(request: Request) { } options.preview = { at, dur }; } - if (options.chaptersOnly && options.preview) { - return Response.json({ error: "chaptersOnly and preview are different runs — pick one" }, { status: 400 }); + if ([options.chaptersOnly, options.chromeOnly, options.preview].filter(Boolean).length > 1) { + return Response.json( + { error: "chaptersOnly, chromeOnly and preview are different runs — pick one" }, + { status: 400 }, + ); } const project = await projectRef(projectId); @@ -137,7 +150,7 @@ export async function POST(request: Request) { // the same file in place by design, and a preview writes a different one, so // neither is guarded. const finalPath = variantPaths(path.join(project.dir, "out"), manifest.slug, options.variant ?? DEFAULT_VARIANT).final; - if (!only && !options.chaptersOnly && !options.preview) { + if (!only && !options.chaptersOnly && !options.chromeOnly && !options.preview) { const [fin, man] = await Promise.all([ stat(finalPath).catch(() => null), stat(path.join(project.dir, "video.manifest.json")).catch(() => null), 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/segment/route.ts b/umtool/app/api/report/segment/route.ts @@ -1,6 +1,4 @@ -import { createReadStream } from "node:fs"; -import { resolveClip, segmentFor } from "@/lib/report/serve.mjs"; -import { Readable } from "node:stream"; +import { rangeResponse, resolveClip, segmentFor } from "@/lib/report/serve.mjs"; export const dynamic = "force-dynamic"; @@ -21,7 +19,8 @@ export const dynamic = "force-dynamic"; // what makes "re-render, then watch it" show the new cut rather than the old. // // Range support is not optional: without a 206 the <video> element will not seek -// in a stream it did not fully download. +// in a stream it did not fully download. rangeResponse() is the one +// implementation, shared with /api/report/video. export async function GET(request: Request) { const url = new URL(request.url); @@ -47,32 +46,5 @@ export async function GET(request: Request) { "x-segment": seg.rel, }; - const range = request.headers.get("range"); - const m = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null; - if (m) { - const size = seg.size; - let start = m[1] ? Number(m[1]) : 0; - let end = m[2] ? Number(m[2]) : size - 1; - if (!m[1] && m[2]) { - // A suffix range: the LAST n bytes. - start = Math.max(0, size - Number(m[2])); - end = size - 1; - } - if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) { - return new Response(null, { status: 416, headers: { "content-range": `bytes */${size}` } }); - } - end = Math.min(end, size - 1); - return new Response(Readable.toWeb(createReadStream(seg.abs, { start, end })) as ReadableStream, { - status: 206, - headers: { - ...headers, - "content-range": `bytes ${start}-${end}/${size}`, - "content-length": String(end - start + 1), - }, - }); - } - - return new Response(Readable.toWeb(createReadStream(seg.abs)) as ReadableStream, { - headers: { ...headers, "content-length": String(seg.size) }, - }); + return rangeResponse(request, { abs: seg.abs, size: seg.size, headers }); } 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/app/api/report/window/route.ts b/umtool/app/api/report/window/route.ts @@ -50,6 +50,9 @@ export async function PUT(request: Request) { // Whether the walk has looked at this clip: "confirmed", or empty to clear // it. A non-empty `correction` is the other answer and needs no value. "verdict", + // What the deck says over this clip: { title?, subtitle? }, or null to + // clear it. Normalised and checked by the writer, like the date. + "onscreen", ]) { if (body[k] !== undefined) patch[k] = body[k]; } diff --git a/umtool/lib/report/driver.mjs b/umtool/lib/report/driver.mjs @@ -53,11 +53,15 @@ export const buildTimeoutMs = (clipCount, xfade) => * chaptersOnly `--chapters-only` -- retitle the chapters from the segments * already on disk; no fetch, no encode * preview `--preview <at> <dur>` -- the rail alone over a window + * chromeOnly `--chrome-only` -- re-render the on-screen deck over the + * segments already on disk and re-concat; no segment rebuilt * - * chaptersOnly and preview SKIP the preflight and the dry resolve: neither - * touches a source, and both are seconds of work under a five-minute cap. + * chaptersOnly, preview and chromeOnly SKIP the preflight and the dry resolve: + * none of them touches a source. The first two are seconds of work under a + * five-minute cap; a deck re-render is a render plus a concat of the whole + * cut, so it keeps the build's own timeout. * - * @typedef {{ variant?: string, xfade?: boolean, chaptersOnly?: boolean, preview?: { at: number, dur: number } | null }} BuildOptions + * @typedef {{ variant?: string, xfade?: boolean, chaptersOnly?: boolean, chromeOnly?: boolean, preview?: { at: number, dur: number } | null }} BuildOptions */ /** The step-1 preflight alone, reused by the check-sources job. */ @@ -84,7 +88,7 @@ export function buildSteps(project, { preset = "fast", only = null, skipFetch = const base = { cwd: PIPELINE_DIR, env }; const quick = !!(options.chaptersOnly || options.preview); - const steps = quick + const steps = quick || options.chromeOnly ? [] : [ { @@ -117,11 +121,18 @@ export function buildSteps(project, { preset = "fast", only = null, skipFetch = if (skipFetch) buildArgv.push("--skip-fetch"); if (p.only && only) buildArgv.push("--only", only); if (options.chaptersOnly) buildArgv.push("--chapters-only"); + if (options.chromeOnly) buildArgv.push("--chrome-only"); if (options.preview) buildArgv.push("--preview", String(options.preview.at), String(options.preview.dur)); steps.push({ ...base, - label: options.chaptersOnly ? "retitle the chapters (no encode)" : options.preview ? `rail preview at ${options.preview.at}s` : p.label, + label: options.chaptersOnly + ? "retitle the chapters (no encode)" + : options.chromeOnly + ? "re-render on-screen" + : options.preview + ? `rail preview at ${options.preview.at}s` + : p.label, argv: buildArgv, ndjson: true, timeoutMs: quick ? 5 * 60_000 : buildTimeoutMs(clipCount, p.xfade), diff --git a/umtool/lib/report/driver.test.mjs b/umtool/lib/report/driver.test.mjs @@ -0,0 +1,26 @@ +// buildSteps' `chromeOnly`: the deck re-rendered over the segments on disk. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildSteps } from "./driver.mjs"; + +const project = { id: "p", dir: "/r/p" }; + +test("chromeOnly: no preflight, --chrome-only on the build, labelled, then the verify", () => { + const steps = buildSteps(project, { preset: "final", options: { chromeOnly: true, variant: "full" } }); + assert.deepEqual(steps.map((s) => s.label), ["re-render on-screen", "verify the file that came out"]); + const argv = steps[0].argv; + assert.ok(argv.includes("--chrome-only")); + assert.deepEqual(argv.slice(argv.indexOf("--variant"), argv.indexOf("--variant") + 2), ["--variant", "full"]); + assert.ok(!argv.includes("--chapters-only")); + // A render plus a concat of the whole cut: the build's timeout, not the quick cap. + assert.ok(steps[0].timeoutMs > 5 * 60_000); +}); + +test("without chromeOnly nothing changes: the preflight leads and no --chrome-only", () => { + const steps = buildSteps(project, { preset: "final" }); + assert.equal(steps[0].label, "check every source is still fetchable"); + assert.ok(steps.every((s) => !s.argv.includes("--chrome-only"))); +}); diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -30,6 +30,7 @@ import { rolesGaps, } from "umtool-report-to-video/ledger-totals"; import { isCalendarDate } from "umtool-report-to-video/attribution"; +import { normalizeOnscreen, validateChrome } from "umtool-report-to-video/deck"; // Its own write queue, not lib/state.ts's. // @@ -317,6 +318,13 @@ export async function updateClip(dir, clipId, patch, { token = null } = {}) { throw new Error("an incorrect verdict needs its note: say what the report got wrong"); } + // ---- what the deck will say --------------------------------------------- + // + // The bench's On-screen fields, written in the same sitting as the header + // fields above. setOnscreen is updateOnscreen's rule, so a clip edited here + // and a row saved from the On-screen table cannot store different shapes. + if (patch.onscreen !== undefined) setOnscreen(entry, patch.onscreen); + const nextToken = await writeManifestAtomic(dir, manifest); return { entry, before, token: nextToken }; }); @@ -420,3 +428,127 @@ export async function updateClaim(dir, claimId, patch, { token = null } = {}) { return { entry, token: nextToken }; }); } + + +// --------------------------------------------------------------------------- +// The DECK: per-entry on-screen text, and the render.chrome block that turns +// the deck on. +// +// Both are checked by deck.mjs, imported rather than restated -- the build +// refuses a manifest with the same two functions, so a value this file accepts +// is one the build accepts, and the other way round. +// --------------------------------------------------------------------------- + +/** + * Store one entry's `onscreen`, normalised. The value REPLACES the entry's + * whole `onscreen` -- `{ title }` alone clears a subtitle override -- because + * the editors send a row, not a field. Nothing left (null, `{}`, blanks) + * DELETES the key, the way an empty attribution field does. + */ +function setOnscreen(entry, value) { + const v = normalizeOnscreen(value); + if (v) entry.onscreen = v; + else delete entry.onscreen; + return v; +} + +/** + * Patch the on-screen text of any number of timeline entries, in one write. + * + * Any entry type: a card's or a still's deck title is as much the author's as + * a clip's. The batch is ALL OR NOTHING -- an unknown id, or a value + * normalizeOnscreen refuses, fails the whole call before anything is written, + * because a table saved with one row silently dropped reads as saved. + * + * Ids are matched against the WHOLE timeline, every variant's entries + * included: an entry only the `full` cut shows still has a title. + * + * @param {string} dir + * @param {Record<string, { title?: string, subtitle?: string } | null>} onscreen + * @param {{ token?: string | null }} [opts] + * @returns {Promise<{ onscreen: Record<string, { title?: string, subtitle?: string } | null>, token: string | null }>} + */ +export async function updateOnscreen(dir, onscreen, { token = null } = {}) { + if (!onscreen || typeof onscreen !== "object" || Array.isArray(onscreen)) { + throw new Error("onscreen must be an object of entry id → { title, subtitle } or null"); + } + const ids = Object.keys(onscreen); + if (!ids.length) throw new Error("nothing to change"); + // Normalised BEFORE the lock: a bad value is the caller's error whatever the + // file says, and refusing it needs no read. + const next = {}; + for (const id of ids) { + try { + next[id] = normalizeOnscreen(onscreen[id]); + } catch (e) { + throw new Error(`${id}: ${e instanceof Error ? e.message : String(e)}`); + } + } + + return withManifestLock(async () => { + const current = await manifestToken(dir); + if (token !== null && current !== token) throw new StaleToken(token, current); + + const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8")); + const byId = new Map((manifest.timeline ?? []).map((e) => [e.id, e])); + const unknown = ids.filter((id) => !byId.has(id)); + if (unknown.length) { + throw new Error(`no timeline entry with id ${unknown.join(", ")} — nothing was written`); + } + // A timeline can repeat an id across variants (`variant: "sourced"` and + // `variant: "full"` twins). Every entry with the id gets the text: they + // are one moment in two cuts. + for (const e of manifest.timeline) { + if (e.id in next) setOnscreen(e, next[e.id]); + } + + const nextToken = await writeManifestAtomic(dir, manifest); + return { onscreen: next, token: nextToken }; + }); +} + +/** + * Set, replace or remove `render.chrome`. + * + * `null` REMOVES it, which turns the deck off and puts the cut back on the + * legacy chrome. Anything else is stored as given -- a manifest names only the + * settings it changes, so the defaults are not written out -- once + * validateChrome has nothing to say about it against the rest of the render + * block (a rail or a legacy `chromeEngine` beside the deck is refused there, + * and so is footage that does not fit above it). + * + * @param {string} dir + * @param {Record<string, unknown> | null} chrome + * @param {{ token?: string | null }} [opts] + * @returns {Promise<{ chrome: Record<string, unknown> | null, token: string | null }>} + */ +export async function updateChrome(dir, chrome, { token = null } = {}) { + if (chrome === undefined) throw new Error("chrome must be an object, or null to remove it"); + return withManifestLock(async () => { + const current = await manifestToken(dir); + if (token !== null && current !== token) throw new StaleToken(token, current); + + const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8")); + const { chrome: _old, ...renderWithoutChrome } = manifest.render ?? {}; + if (chrome === null) { + if (manifest.render) delete manifest.render.chrome; + } else { + const errors = validateChrome(chrome, renderWithoutChrome); + if (errors.length) throw new ChromeRefused(errors); + manifest.render = { ...(manifest.render ?? {}), chrome }; + } + + const nextToken = await writeManifestAtomic(dir, manifest); + return { chrome: manifest.render?.chrome ?? null, token: nextToken }; + }); +} + +/** validateChrome's sentences, thrown whole so a route can return each one. */ +export class ChromeRefused extends Error { + /** @param {string[]} errors */ + constructor(errors) { + super(`render.chrome: ${errors.join("; ")}`); + this.name = "ChromeRefused"; + this.errors = errors; + } +} diff --git a/umtool/lib/report/manifest.test.mjs b/umtool/lib/report/manifest.test.mjs @@ -0,0 +1,296 @@ +// The deck's writers: updateOnscreen, updateChrome, and updateClip's +// `onscreen`. Each one goes through the lock, the backup, the atomic write and +// the stale-token guard, and each refusal leaves the file byte-for-byte as it +// was. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + ChromeRefused, + MANIFEST_NAME, + StaleToken, + manifestToken, + updateChrome, + updateClip, + updateOnscreen, +} from "./manifest.mjs"; + +const base = () => ({ + slug: "t", + provenance: { siteOrigin: "https://example.test", channelSlug: "chan" }, + render: { width: 1920, height: 1080, fps: 30, transition: 0.5 }, + timeline: [ + { type: "card", id: "k1", heading: "Opening", sub: "a card" }, + { type: "clip", id: "c01", video: "v1", start: 10, end: 20 }, + { type: "image", id: "i1", file: "x.png", seconds: 4 }, + { type: "clip", id: "c02", video: "v2", start: 30, end: 41, onscreen: { title: "Kept" } }, + ], +}); + +async function project(manifest = base()) { + const dir = await mkdtemp(path.join(tmpdir(), "umtool-deck-")); + await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2) + "\n"); + return dir; +} +const readRaw = (dir) => readFile(path.join(dir, MANIFEST_NAME), "utf8"); +const read = async (dir) => JSON.parse(await readRaw(dir)); +const entry = (m, id) => m.timeline.find((e) => e.id === id); + +test("updateOnscreen: round trip on every entry type, trimmed, in the CLI's formatting", async () => { + const dir = await project(); + try { + const token = await manifestToken(dir); + const res = await updateOnscreen( + dir, + { + k1: { title: " Card title " }, + c01: { title: "County approves pre-application", subtitle: " Override · 2024 " }, + i1: { subtitle: "Still" }, + }, + { token }, + ); + assert.deepEqual(res.onscreen.k1, { title: "Card title" }); + assert.equal(res.token, await manifestToken(dir)); + + const raw = await readRaw(dir); + assert.ok(raw.endsWith("}\n"), "trailing newline kept"); + assert.ok(raw.startsWith('{\n "slug"'), "two-space indent kept"); + const m = JSON.parse(raw); + assert.deepEqual(entry(m, "k1").onscreen, { title: "Card title" }); + assert.deepEqual(entry(m, "c01").onscreen, { + title: "County approves pre-application", + subtitle: "Override · 2024", + }); + assert.deepEqual(entry(m, "i1").onscreen, { subtitle: "Still" }); + // Untouched rows stay as they were. + assert.deepEqual(entry(m, "c02").onscreen, { title: "Kept" }); + // The backup of the previous state sits beside it. + assert.ok(await stat(path.join(dir, `${MANIFEST_NAME}.bak`))); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateOnscreen: a row replaces the whole onscreen; empty and null delete the key", async () => { + const dir = await project(); + try { + await updateOnscreen(dir, { c01: { title: "T", subtitle: "S" } }); + await updateOnscreen(dir, { c01: { title: "T2" } }); + assert.deepEqual(entry(await read(dir), "c01").onscreen, { title: "T2" }); + + await updateOnscreen(dir, { c01: { title: " ", subtitle: "" }, c02: null }); + const m = await read(dir); + assert.equal("onscreen" in entry(m, "c01"), false); + assert.equal("onscreen" in entry(m, "c02"), false); + + await updateOnscreen(dir, { k1: { title: "x" } }); + await updateOnscreen(dir, { k1: {} }); + assert.equal("onscreen" in entry(await read(dir), "k1"), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateOnscreen: an unknown id refuses the WHOLE batch and writes nothing", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + await assert.rejects( + updateOnscreen(dir, { c01: { title: "would be written" }, nope: { title: "x" } }), + /no timeline entry with id nope/, + ); + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateOnscreen: a value the deck refuses fails the batch, naming the entry", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + await assert.rejects(updateOnscreen(dir, { c01: { title: "ok" }, k1: { title: "two\nlines" } }), /k1: .*one line/); + await assert.rejects(updateOnscreen(dir, { c01: { heading: "x" } }), /c01: onscreen\.heading is not/); + await assert.rejects(updateOnscreen(dir, { c01: { title: "x".repeat(201) } }), /at most 200/); + await assert.rejects(updateOnscreen(dir, {}), /nothing to change/); + await assert.rejects(updateOnscreen(dir, null), /must be an object/); + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateOnscreen: a stale token is refused and writes nothing", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + await assert.rejects(updateOnscreen(dir, { c01: { title: "x" } }, { token: "1" }), (e) => { + assert.ok(e instanceof StaleToken); + assert.equal(e.expected, "1"); + return true; + }); + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateOnscreen: every variant twin with the id gets the text", async () => { + const m = base(); + m.timeline.push({ type: "clip", id: "c01", variant: "full", video: "v1", start: 9, end: 22 }); + m.timeline[1].variant = "sourced"; + const dir = await project(m); + try { + await updateOnscreen(dir, { c01: { title: "Both" } }); + const out = (await read(dir)).timeline.filter((e) => e.id === "c01"); + assert.equal(out.length, 2); + for (const e of out) assert.deepEqual(e.onscreen, { title: "Both" }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateClip: onscreen is normalised, and empty or null deletes the key", async () => { + const dir = await project(); + try { + const res = await updateClip(dir, "c01", { onscreen: { title: " Bench title " } }); + assert.deepEqual(res.entry.onscreen, { title: "Bench title" }); + assert.deepEqual(entry(await read(dir), "c01").onscreen, { title: "Bench title" }); + + await updateClip(dir, "c01", { onscreen: { title: "", subtitle: " " } }); + assert.equal("onscreen" in entry(await read(dir), "c01"), false); + + await updateClip(dir, "c02", { onscreen: null }); + assert.equal("onscreen" in entry(await read(dir), "c02"), false); + + const before = await readRaw(dir); + await assert.rejects(updateClip(dir, "c01", { onscreen: { title: "a\nb" } }), /one line/); + await assert.rejects(updateClip(dir, "c01", { onscreen: "a string" }), /must be an object/); + // A refused value writes nothing. + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +const DECK = { engine: "hyperframes", layout: "deck", deck: { height: 180, title: { size: 60 } } }; + +test("updateChrome: round trip stores the block as given; null removes it", async () => { + const dir = await project(); + try { + const token = await manifestToken(dir); + const res = await updateChrome(dir, DECK, { token }); + assert.deepEqual(res.chrome, DECK); + assert.equal(res.token, await manifestToken(dir)); + let m = await read(dir); + assert.deepEqual(m.render.chrome, DECK); + // The rest of the render block is untouched and keeps its order. + assert.deepEqual(Object.keys(m.render), ["width", "height", "fps", "transition", "chrome"]); + + // Replacing keeps the key where it was. + await updateChrome(dir, { engine: "hyperframes", layout: "deck" }); + m = await read(dir); + assert.deepEqual(m.render.chrome, { engine: "hyperframes", layout: "deck" }); + + const off = await updateChrome(dir, null); + assert.equal(off.chrome, null); + m = await read(dir); + assert.equal("chrome" in m.render, false); + assert.equal(m.render.fps, 30); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateChrome: a manifest with no render block gets one", async () => { + const m = base(); + delete m.render; + const dir = await project(m); + try { + await updateChrome(dir, DECK); + assert.deepEqual((await read(dir)).render, { chrome: DECK }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateChrome: validateChrome's sentences come back whole, and nothing is written", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + const refused = async (chrome, ...want) => { + await assert.rejects(updateChrome(dir, chrome), (e) => { + assert.ok(e instanceof ChromeRefused, String(e)); + for (const w of want) assert.ok(e.errors.some((s) => w.test(s)), `${w} in ${JSON.stringify(e.errors)}`); + return true; + }); + assert.equal(await readRaw(dir), before); + }; + await refused( + { engine: "hyperframes", layout: "deck", deck: { height: 50, footageScle: 0.8 } }, + /deck\.height must be a whole number/, + /deck\.footageScle is not a deck setting/, + ); + await refused({ engine: "ffmpeg", layout: "deck" }, /engine must be "hyperframes"/); + await refused({ engine: "hyperframes", layout: "deck", deck: { qr: { size: 300 } } }, /qr\.size 300 does not fit/); + await refused("deck", /must be an object/); + await assert.rejects(updateChrome(dir, undefined), /an object, or null/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateChrome: refused beside a rail or the legacy chromeEngine — checked against the REST of render", async () => { + const m = base(); + m.render.rail = { kind: "x" }; + m.render.chromeEngine = "hyperframes"; + const dir = await project(m); + try { + await assert.rejects(updateChrome(dir, DECK), (e) => { + assert.ok(e instanceof ChromeRefused); + assert.ok(e.errors.some((s) => /render\.rail cannot both be set/.test(s))); + assert.ok(e.errors.some((s) => /remove chromeEngine/.test(s))); + return true; + }); + // Turning the deck OFF is never refused: it is how such a manifest is fixed. + await updateChrome(dir, null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateChrome: a stale token is refused and writes nothing", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + await assert.rejects(updateChrome(dir, DECK, { token: "1" }), StaleToken); + await assert.rejects(updateChrome(dir, null, { token: "1" }), StaleToken); + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("the writers queue: concurrent saves of different fields all land", async () => { + const dir = await project(); + try { + await Promise.all([ + updateOnscreen(dir, { k1: { title: "A" } }), + updateChrome(dir, DECK), + updateClip(dir, "c01", { onscreen: { title: "B" } }), + updateOnscreen(dir, { i1: { title: "C" } }), + ]); + const m = await read(dir); + assert.deepEqual(entry(m, "k1").onscreen, { title: "A" }); + assert.deepEqual(entry(m, "c01").onscreen, { title: "B" }); + assert.deepEqual(entry(m, "i1").onscreen, { title: "C" }); + assert.deepEqual(m.render.chrome, DECK); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); 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); +}); diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs @@ -6,10 +6,12 @@ // name that is simply not there fails, and a traversal fails twice: once on the // membership check and once on resolveInRoots. import path from "node:path"; -import { stat } from "node:fs/promises"; -import { REPORTS_ROOT, resolveInRoots } from "../paths.mjs"; +import { createReadStream } from "node:fs"; +import { realpath, stat } from "node:fs/promises"; +import { Readable } from "node:stream"; +import { REPORTS_ROOT, inside, resolveInRoots } from "../paths.mjs"; import { walkProjects } from "../projects/walk.mjs"; -import { DEFAULT_VARIANT, WIN_EPS } from "umtool-report-to-video/build-video"; +import { DEFAULT_VARIANT, VARIANTS, WIN_EPS, variantPaths } from "umtool-report-to-video/build-video"; import { rawCacheOf } from "./raw-cache.mjs"; import { channelsDirFor, @@ -30,6 +32,27 @@ export async function resolveClip(projectId, clipId) { return { project, manifest, clip }; } +/** + * The same membership rule for a request that names a PROJECT and a cut, and + * no clip: the deck's routes. `variant` is checked against the pipeline's own + * list; absent or empty it is the default cut. + * + * @param {string} projectId + * @param {string | null} [variant] + */ +export async function resolveReport(projectId, variant = null) { + const v = variant || DEFAULT_VARIANT; + if (!VARIANTS.includes(v)) { + return { error: `variant must be one of ${VARIANTS.join(", ")}`, status: 400 }; + } + const projects = await walkProjects(REPORTS_ROOT); + const project = projects.find((p) => p.id === projectId); + if (!project) return { error: "no such project", status: 404 }; + const manifest = await readManifest(project.dir); + if (!manifest) return { error: "no manifest", status: 404 }; + return { project, manifest, variant: v }; +} + /** The clips-raw cache, re-exported so `serve.mjs` stays the bench's one door. */ export { rawCacheOf } from "./raw-cache.mjs"; @@ -147,3 +170,145 @@ export async function resolveClaim(projectId, claimId) { if (!claim) return { error: "no such claim", status: 404 }; return { project, manifest, claim }; } + + +// --------------------------------------------------------------------------- +// Byte ranges. +// +// One implementation for every route here that hands an mp4 to a <video>: +// the built segment, the cut and its preview. Without a 206 the element will +// not seek in a stream it did not fully download. +// --------------------------------------------------------------------------- + +/** + * The response for one file the caller has ALREADY authorised and stat'ed. + * + * A `Range` this does not parse is ignored and the whole file is sent; one it + * parses but cannot satisfy is a 416 carrying only `content-range`. A suffix + * range (`bytes=-500`) is the LAST n bytes -- Chrome asks for one to find an + * mp4's moov atom when it is not at the front. + * + * @param {Request} request + * @param {{ abs: string, size: number, headers?: Record<string, string> }} file + * @returns {Response} + */ +export function rangeResponse(request, { abs, size, headers = {} }) { + const range = request.headers.get("range"); + const m = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null; + if (m) { + let start = m[1] ? Number(m[1]) : 0; + let end = m[2] ? Number(m[2]) : size - 1; + if (!m[1] && m[2]) { + // A suffix range: the LAST n bytes. + start = Math.max(0, size - Number(m[2])); + end = size - 1; + } + if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) { + return new Response(null, { status: 416, headers: { "content-range": `bytes */${size}` } }); + } + end = Math.min(end, size - 1); + return new Response(/** @type {ReadableStream} */ (Readable.toWeb(createReadStream(abs, { start, end }))), { + status: 206, + headers: { + ...headers, + "content-range": `bytes ${start}-${end}/${size}`, + "content-length": String(end - start + 1), + }, + }); + } + return new Response(/** @type {ReadableStream} */ (Readable.toWeb(createReadStream(abs))), { + headers: { ...headers, "content-length": String(size) }, + }); +} + +/** + * The deliverable of one cut, or the short window `--chrome-preview` writes. + * + * Built from the manifest's slug and the checked variant through the + * pipeline's own variantPaths(), never from anything the client typed: `final` + * is `out/<slug>.mp4` (`out/<slug>-full.mp4` for `full`), `preview` is + * `out/<variant>/<slug>.preview.mp4`. The mtime rides along for the same + * reason segmentFor's does -- a re-render writes the same path. + * + * @param {{ dir: string }} project + * @param {{ slug?: string }} manifest + * @param {string} variant + * @param {"final" | "preview"} kind + */ +export async function videoFor(project, manifest, variant, kind) { + const slug = manifest.slug ?? path.basename(project.dir); + const dirs = variantPaths(path.join(project.dir, "out"), slug, variant); + const file = kind === "preview" ? path.join(dirs.dir, `${slug}.preview.mp4`) : dirs.final; + const abs = resolveInRoots(file); + if (!abs) return null; + const st = await stat(abs).catch(() => null); + if (!st?.isFile()) return null; + return { + rel: path.relative(project.dir, abs).split(path.sep).join("/"), + abs, + size: st.size, + mtimeMs: Math.round(st.mtimeMs), + }; +} + +// --------------------------------------------------------------------------- +// The deck's preview composition. +// +// compose-chrome writes it under out/<variant>/chrome/deck-preview/, and the +// page loads it in an iframe -- so its index.html, and the assets it names by +// RELATIVE url, are served from one prefix by GET /api/report/chrome/files/. +// That route takes a path from the client, which nothing else here does, so +// the whole rule is in deckPreviewFile and it is tested. +// --------------------------------------------------------------------------- + +/** The preview project directory of one cut. Never a build's `chrome/deck/`. */ +export const deckPreviewDir = (projectDir, variant) => + path.join(projectDir, "out", variant, "chrome", "deck-preview"); + +/** + * The project id as ONE url segment. Ids are relative paths (`folder/name`), + * and the composition's relative asset urls only resolve under a prefix with + * no query string, so the id cannot ride as a parameter or as raw segments. + */ +export const encodeProjectSegment = (id) => Buffer.from(String(id), "utf8").toString("base64url"); +export const decodeProjectSegment = (seg) => { + if (!/^[A-Za-z0-9_-]+$/.test(String(seg ?? ""))) return null; + return Buffer.from(seg, "base64url").toString("utf8"); +}; + +/** The iframe src for a cut's preview composition. */ +export const deckPreviewSrc = (projectId, variant) => + `/api/report/chrome/files/${encodeProjectSegment(projectId)}/${variant}/index.html`; + +/** + * Resolve the url segments after `<project>/<variant>/` to a file INSIDE the + * preview directory, or null. + * + * Refused: an empty, `.` or `..` segment; one carrying a slash, a backslash or + * a NUL (a segment the router decoded from `%2F` is still one segment); an + * absolute path; and anything whose REAL path -- symlinks followed -- is not + * under the directory's real path. A symlink inside the directory pointing + * out of it is the case the realpath is for. A directory is not a file. + * + * @param {string} dir the preview directory (deckPreviewDir) + * @param {string[]} segments + * @returns {Promise<{ abs: string, size: number } | null>} + */ +export async function deckPreviewFile(dir, segments) { + if (!Array.isArray(segments) || !segments.length) return null; + for (const seg of segments) { + if (typeof seg !== "string" || !seg || seg === "." || seg === "..") return null; + if (/[\/\\\0]/.test(seg) || path.isAbsolute(seg)) return null; + } + const base = path.resolve(dir); + const abs = path.resolve(base, ...segments); + if (!inside(base, abs) || abs === base) return null; + const [realBase, realAbs] = await Promise.all([ + realpath(base).catch(() => null), + realpath(abs).catch(() => null), + ]); + if (!realBase || !realAbs || !inside(realBase, realAbs) || realAbs === realBase) return null; + const st = await stat(realAbs).catch(() => null); + if (!st?.isFile()) return null; + return { abs: realAbs, size: st.size }; +} diff --git a/umtool/lib/report/serve.test.mjs b/umtool/lib/report/serve.test.mjs @@ -0,0 +1,185 @@ +// What the report routes serve: rangeResponse (the segment and video routes' +// byte ranges) and deckPreviewFile (the one route that takes a path from the +// client, confined to the deck's preview directory). +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + decodeProjectSegment, + deckPreviewDir, + deckPreviewFile, + deckPreviewSrc, + encodeProjectSegment, + rangeResponse, +} from "./serve.mjs"; + +const req = (range) => new Request("http://x/", range ? { headers: { range } } : {}); +const bytes = async (res) => Buffer.from(await res.arrayBuffer()); + +test("rangeResponse: whole file, explicit, open-ended, suffix and clamped ranges", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "umtool-range-")); + try { + const data = Buffer.from(Array.from({ length: 1000 }, (_, i) => i % 251)); + const abs = path.join(dir, "a.mp4"); + await writeFile(abs, data); + const file = { abs, size: data.length, headers: { "content-type": "video/mp4", "x-k": "v" } }; + + let res = rangeResponse(req(null), file); + assert.equal(res.status, 200); + assert.equal(res.headers.get("content-length"), "1000"); + assert.equal(res.headers.get("content-type"), "video/mp4"); + assert.equal(res.headers.get("x-k"), "v"); + assert.deepEqual(await bytes(res), data); + + res = rangeResponse(req("bytes=0-99"), file); + assert.equal(res.status, 206); + assert.equal(res.headers.get("content-range"), "bytes 0-99/1000"); + assert.equal(res.headers.get("content-length"), "100"); + assert.equal(res.headers.get("x-k"), "v"); + assert.deepEqual(await bytes(res), data.subarray(0, 100)); + + res = rangeResponse(req("bytes=900-"), file); + assert.equal(res.status, 206); + assert.equal(res.headers.get("content-range"), "bytes 900-999/1000"); + assert.deepEqual(await bytes(res), data.subarray(900)); + + // A suffix range is the LAST n bytes, not an offset. + res = rangeResponse(req("bytes=-100"), file); + assert.equal(res.status, 206); + assert.equal(res.headers.get("content-range"), "bytes 900-999/1000"); + assert.deepEqual(await bytes(res), data.subarray(900)); + + res = rangeResponse(req("bytes=-5000"), file); + assert.equal(res.headers.get("content-range"), "bytes 0-999/1000"); + + // An end past the file is clamped. + res = rangeResponse(req("bytes=990-5000"), file); + assert.equal(res.headers.get("content-range"), "bytes 990-999/1000"); + assert.equal((await bytes(res)).length, 10); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("rangeResponse: unsatisfiable is a bare 416; unparseable is the whole file", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "umtool-range-")); + try { + const abs = path.join(dir, "a.mp4"); + await writeFile(abs, Buffer.alloc(1000, 1)); + const file = { abs, size: 1000, headers: { "content-type": "video/mp4" } }; + for (const r of ["bytes=1000-", "bytes=5-2", "bytes=2000-3000"]) { + const res = rangeResponse(req(r), file); + assert.equal(res.status, 416, r); + assert.equal(res.headers.get("content-range"), "bytes */1000"); + assert.equal(res.headers.get("content-type"), null, "the 416 carries content-range only"); + } + for (const r of ["items=0-1", "bytes=0-1,5-6", "nonsense"]) { + const res = rangeResponse(req(r), file); + assert.equal(res.status, 200, r); + assert.equal(res.headers.get("content-length"), "1000"); + await res.arrayBuffer(); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("project ids ride as one url segment and come back exactly", () => { + for (const id of ["ferret-rescue", "folder/name", "a b/ç"]) { + const seg = encodeProjectSegment(id); + assert.match(seg, /^[A-Za-z0-9_-]+$/); + assert.equal(decodeProjectSegment(seg), id); + } + for (const bad of ["", "..", "a/b", "a.b", null, undefined]) assert.equal(decodeProjectSegment(bad), null); + assert.equal( + deckPreviewSrc("folder/name", "sourced"), + `/api/report/chrome/files/${encodeProjectSegment("folder/name")}/sourced/index.html`, + ); + assert.equal(deckPreviewDir("/p", "full"), path.join("/p", "out", "full", "chrome", "deck-preview")); +}); + +test("deckPreviewFile: files inside the preview directory, and nothing else", async () => { + const root = await mkdtemp(path.join(tmpdir(), "umtool-deckfiles-")); + try { + const project = path.join(root, "proj"); + const dir = deckPreviewDir(project, "sourced"); + await mkdir(path.join(dir, "assets"), { recursive: true }); + await writeFile(path.join(dir, "index.html"), "<html></html>"); + await writeFile(path.join(dir, "assets", "gsap.min.js"), "x"); + // Beside the preview: a build's own project, the manifest, and a secret + // outside the project altogether. + await mkdir(path.join(project, "out", "sourced", "chrome", "deck"), { recursive: true }); + await writeFile(path.join(project, "out", "sourced", "chrome", "deck", "index.html"), "build"); + await writeFile(path.join(project, "video.manifest.json"), "{}"); + await writeFile(path.join(root, "secret.txt"), "secret"); + // Links inside the directory: one out, one to a directory out, one in. + await symlink(path.join(root, "secret.txt"), path.join(dir, "out-link.txt")); + await symlink(root, path.join(dir, "out-dir")); + await symlink(path.join(dir, "index.html"), path.join(dir, "assets", "in-link.html")); + + const ok = async (segs) => { + const f = await deckPreviewFile(dir, segs); + assert.ok(f, JSON.stringify(segs)); + return f; + }; + const refused = async (segs) => assert.equal(await deckPreviewFile(dir, segs), null, JSON.stringify(segs)); + + assert.equal((await ok(["index.html"])).size, "<html></html>".length); + await ok(["assets", "gsap.min.js"]); + // A link that stays inside is fine; it resolves to the real file. + assert.equal((await ok(["assets", "in-link.html"])).abs, path.join(await realDir(dir), "index.html")); + + await refused([]); + await refused([".."]); + await refused(["..", "deck", "index.html"]); + await refused(["assets", "..", "..", "deck", "index.html"]); + await refused(["..", "..", "..", "video.manifest.json"]); + await refused(["..", "..", "..", "..", "secret.txt"]); + await refused(["."]); + await refused(["", "index.html"]); + // A segment the router decoded from %2F or %5C is still one segment. + await refused(["assets/gsap.min.js"]); + await refused(["../../../../secret.txt"]); + await refused(["assets\\gsap.min.js"]); + await refused(["index.html\0.png"]); + // Absolute paths, however they arrive. + await refused([path.join(root, "secret.txt")]); + await refused(["/etc/passwd"]); + // Symlinks out of the directory. + await refused(["out-link.txt"]); + await refused(["out-dir", "secret.txt"]); + // A directory is not a file; a missing file is not one either. + await refused(["assets"]); + await refused(["nope.html"]); + await refused("index.html"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("deckPreviewFile: an out/ that is itself a symlink (media on another drive) still serves", async () => { + const root = await mkdtemp(path.join(tmpdir(), "umtool-deckfiles-")); + try { + const real = path.join(root, "elsewhere", "out"); + await mkdir(path.join(real, "sourced", "chrome", "deck-preview"), { recursive: true }); + await writeFile(path.join(real, "sourced", "chrome", "deck-preview", "index.html"), "x"); + const project = path.join(root, "proj"); + await mkdir(project); + await symlink(real, path.join(project, "out")); + const f = await deckPreviewFile(deckPreviewDir(project, "sourced"), ["index.html"]); + assert.ok(f); + assert.equal(await deckPreviewFile(deckPreviewDir(project, "sourced"), ["..", "..", "..", "..", "proj"]), null); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function realDir(p) { + const { realpath } = await import("node:fs/promises"); + return realpath(p); +}