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:
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);
+}