commit d7d7bccbfb26af4f67a12f5395ee578b5f644821
parent 4f9352b9509aa01a6971d855e07812aed9899838
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 23:18:34 -0400
umtool: takes -- alternative renders of part of a cut, side by side, judged
/browse/<project>/takes lists <project>/takes/<id>/take.json grouped by
`group`, sorted by `order`: a card per take (label, kind badge, summary,
changes, preview player), Play all from start / Pause all per group, one
audio source at a time (listen toggle; play or unmute claims it), and
Like / Maybe / No plus a note per take, written to takes/verdicts.json as
{ "<id>": { verdict, note, at } } (tmp + rename, own write queue). A
take.json that fails validation is listed as skipped with the reason; a
dir with no take.json is not a take. The project page links to it when
takes/ exists.
Routes: GET /api/report/takes, GET /api/report/takes/preview (byte
ranges via rangeResponse, confined to the take's own dir by real path),
GET/POST /api/report/takes/verdict (take must be listed).
Unit tests in lib/report/takes.test.mjs (test:scripts), e2e takes.spec.ts
on a new takes-fixture project.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
13 files changed, 1234 insertions(+), 0 deletions(-)
diff --git a/umtool/app/api/report/takes/preview/route.ts b/umtool/app/api/report/takes/preview/route.ts
@@ -0,0 +1,37 @@
+import { projectRef } from "@/lib/projects";
+import { rangeResponse } from "@/lib/report/serve.mjs";
+import { isTakeId, listTakes, previewFile } from "@/lib/report/takes.mjs";
+
+export const dynamic = "force-dynamic";
+
+// One take's preview mp4, to a <video> element. ?project&take&v.
+//
+// Names, never paths: the take must be one the listing returns -- so a take
+// whose take.json was skipped is not served either -- and its file is the one
+// its take.json names, confined to its own directory by previewFile.
+//
+// `v` is the video route's contract: the mtime the listing reported, and only
+// a matching one may be cached, because a re-render writes the same path.
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const takeId = url.searchParams.get("take") ?? "";
+ if (!isTakeId(takeId)) return new Response("take must be a take id", { status: 400 });
+ const project = await projectRef(url.searchParams.get("project") ?? "");
+ if (!project) return new Response("no such project", { status: 404 });
+ const take = (await listTakes(project.dir)).takes.find((t) => t.id === takeId);
+ if (!take) return new Response("no such take", { status: 404 });
+ const file = await previewFile(project.dir, take);
+ if (!file) return new Response("this take has no preview yet", { status: 404 });
+
+ const fresh = url.searchParams.get("v") === String(file.mtimeMs);
+ return rangeResponse(request, {
+ abs: file.abs,
+ size: file.size,
+ headers: {
+ "content-type": "video/mp4",
+ "accept-ranges": "bytes",
+ "cache-control": fresh ? "private, max-age=3600, immutable" : "private, no-store",
+ "x-video-mtime": String(file.mtimeMs),
+ },
+ });
+}
diff --git a/umtool/app/api/report/takes/route.ts b/umtool/app/api/report/takes/route.ts
@@ -0,0 +1,15 @@
+import { projectRef } from "@/lib/projects";
+import { listTakes, readVerdicts } from "@/lib/report/takes.mjs";
+
+export const dynamic = "force-dynamic";
+
+// GET ?project=<id> every take, grouped, what was skipped and why, and the
+// verdicts so far. `exists: false` is a project with no
+// takes/ at all, which is the normal state, not an error.
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const project = await projectRef(url.searchParams.get("project") ?? "");
+ if (!project) return Response.json({ error: "no such project" }, { status: 404 });
+ const [takes, verdicts] = await Promise.all([listTakes(project.dir), readVerdicts(project.dir)]);
+ return Response.json({ project: project.id, ...takes, verdicts }, { headers: { "cache-control": "no-store" } });
+}
diff --git a/umtool/app/api/report/takes/verdict/route.ts b/umtool/app/api/report/takes/verdict/route.ts
@@ -0,0 +1,39 @@
+import { projectRef } from "@/lib/projects";
+import { isTakeId, listTakes, readVerdicts, setTakeVerdict } from "@/lib/report/takes.mjs";
+
+export const dynamic = "force-dynamic";
+
+// GET ?project=<id> <project>/takes/verdicts.json, as read
+// POST { project, take, verdict?, note? } set one take's verdict ("like" |
+// "maybe" | "no" | null) and/or note;
+// an absent field keeps its value
+//
+// The take must be one the listing returns. A verdict on a take that is not
+// there would be written into a file another agent reads as a decision.
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const project = await projectRef(url.searchParams.get("project") ?? "");
+ if (!project) return Response.json({ error: "no such project" }, { status: 404 });
+ return Response.json({ verdicts: await readVerdicts(project.dir) }, { headers: { "cache-control": "no-store" } });
+}
+
+export async function POST(request: Request) {
+ const body = (await request.json().catch(() => null)) as {
+ project?: string;
+ take?: string;
+ verdict?: string | null;
+ note?: string;
+ } | null;
+ if (!body || typeof body !== "object") return Response.json({ error: "bad json" }, { status: 400 });
+ if (!isTakeId(body.take)) return Response.json({ error: "take must be a take id" }, { status: 400 });
+ const project = await projectRef(String(body.project ?? ""));
+ if (!project) return Response.json({ error: "no such project" }, { status: 404 });
+ const { takes } = await listTakes(project.dir);
+ if (!takes.some((t) => t.id === body.take)) return Response.json({ error: "no such take" }, { status: 404 });
+ try {
+ const r = await setTakeVerdict(project.dir, body.take, { verdict: body.verdict, note: body.note });
+ return Response.json({ ok: true, ...r }, { headers: { "cache-control": "no-store" } });
+ } catch (e) {
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
+ }
+}
diff --git a/umtool/components/projects/ProjectView.tsx b/umtool/components/projects/ProjectView.tsx
@@ -6,6 +6,7 @@ import ReportProject from "./ReportProject";
import ClipBenchPage from "./ClipBenchPage";
import ClaimBenchPage from "./ClaimBenchPage";
import SweepProject from "./SweepProject";
+import TakesPage from "./TakesPage";
// ---------------------------------------------------------------------------
// The one place a kind id is matched against a component.
@@ -49,6 +50,9 @@ export default async function ProjectView({
if (rest.length === 2 && rest[0] === "claim") {
return <ClaimBenchPage project={project} claimId={rest[1]} />;
}
+ // `/browse/<project>/takes` -- alternative renders of one part of the
+ // cut, side by side, each judged like / maybe / no.
+ if (rest.length === 1 && rest[0] === "takes") return <TakesPage project={project} />;
return notFound();
}
case "sweep-report": {
diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx
@@ -17,6 +17,7 @@ import {
import { EXPORT_FORMATS, exportableVariants } from "@/lib/report/export.mjs";
import { diffManifests, formatChange } from "@/lib/report/manifest-diff.mjs";
import { listSnapshots, readSnapshot } from "@/lib/report/snapshots.mjs";
+import { listTakes } from "@/lib/report/takes.mjs";
import DeliverSection from "./DeliverSection";
import FetchUnfetchedButton from "./FetchUnfetchedButton";
import OnscreenSection from "./OnscreenSection";
@@ -127,6 +128,7 @@ export default async function ReportProject({
const noteFiles = names.filter((n) => /\.md$/i.test(n) && !/^readme\.md$/i.test(n) && n !== "sweep-report.md").sort();
const noteTexts = await Promise.all(noteFiles.map((n) => readFile(path.join(project.dir, n), "utf8").catch(() => "")));
const variants = await exportableVariants(project.dir);
+ const takes = await listTakes(project.dir);
// Provenance splits by SHAPE: a scalar or a URL stays in the table; a long
// string, or one with a newline, is prose the author wrote and reads as a
@@ -226,6 +228,17 @@ export default async function ReportProject({
</span>
</div>
)}
+ {/* Alternative renders of part of the cut, made elsewhere and judged
+ on their own page. Only when there is a takes/ to look at. */}
+ {takes.exists && (
+ <Link
+ href={`/browse/${project.id}/takes`}
+ data-takes-link={takes.takes.length}
+ className={buttonVariants({ size: "lg" })}
+ >
+ Takes — {takes.takes.length}
+ </Link>
+ )}
{build.built && (
<div className="text-right text-[11px] text-[var(--color-dim)]">
<div className="font-mono text-[var(--color-text)]">{build.slug}.mp4</div>
diff --git a/umtool/components/projects/TakesBench.tsx b/umtool/components/projects/TakesBench.tsx
@@ -0,0 +1,333 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { badgeVariants, type BadgeVariants } from "@/components/ui/badge";
+import { buttonVariants } from "@/components/ui/button";
+import { fmtAgo } from "@/lib/format";
+
+// The takes of one project, a group at a time, every preview on screen at once.
+//
+// ONE AUDIO SOURCE. Every preview is muted except the one being listened to:
+// the "listen" toggle picks it, and so does pressing play (or unmuting) on a
+// single preview -- the last thing you reached for is what you hear. "Play all
+// from start" keeps whichever is being listened to, so a group can be watched
+// in sync against one soundtrack, or in silence.
+//
+// Buttons stay disabled until mount, as VerdictChip's do: a click before
+// hydration sends nothing, and a verdict lost that way looks like one never
+// given.
+
+export type Take = {
+ id: string;
+ group: string;
+ order: number;
+ label: string;
+ kind: "reference" | "similar" | "different";
+ summary: string;
+ changes: string[];
+ preview: string;
+ seconds: number | null;
+ builtAt: string | null;
+ previewSize: number | null;
+ previewMtimeMs: number | null;
+};
+
+export type Verdict = "like" | "maybe" | "no";
+export type TakeVerdict = { verdict: Verdict | null; note: string; at: string };
+
+const KIND_TONE: Record<Take["kind"], BadgeVariants["variant"]> = {
+ reference: "on",
+ similar: "info",
+ different: "meter",
+};
+
+// Literal class strings, as VerdictChip's are: Tailwind finds classes by
+// reading the source, so a class assembled from a variable is never generated.
+const TONES = {
+ good: {
+ on: "border-[var(--color-good)] bg-[color-mix(in_srgb,var(--color-good)_18%,transparent)] text-[var(--color-good)]",
+ off: "border-[var(--color-line)] text-[var(--color-dim)] hover:border-[var(--color-good)] hover:text-[var(--color-good)]",
+ },
+ dirty: {
+ on: "border-[var(--color-dirty)] bg-[color-mix(in_srgb,var(--color-dirty)_18%,transparent)] text-[var(--color-dirty)]",
+ off: "border-[var(--color-line)] text-[var(--color-dim)] hover:border-[var(--color-dirty)] hover:text-[var(--color-dirty)]",
+ },
+ bad: {
+ on: "border-[var(--color-bad)] bg-[color-mix(in_srgb,var(--color-bad)_18%,transparent)] text-[var(--color-bad)]",
+ off: "border-[var(--color-line)] text-[var(--color-dim)] hover:border-[var(--color-bad)] hover:text-[var(--color-bad)]",
+ },
+ sel: {
+ on: "border-[var(--color-sel)] bg-[color-mix(in_srgb,var(--color-sel)_18%,transparent)] text-[var(--color-sel)]",
+ off: "border-[var(--color-line)] text-[var(--color-dim)] hover:border-[var(--color-sel)] hover:text-[var(--color-sel)]",
+ },
+} as const;
+type Tone = keyof typeof TONES;
+const tone = (t: Tone, on: boolean) => (on ? TONES[t].on : TONES[t].off);
+
+const VERDICTS: { id: Verdict; label: string; tone: Tone }[] = [
+ { id: "like", label: "Like", tone: "good" },
+ { id: "maybe", label: "Maybe", tone: "dirty" },
+ { id: "no", label: "No", tone: "bad" },
+];
+
+const previewSrc = (project: string, t: Take) =>
+ `/api/report/takes/preview?${new URLSearchParams({
+ project,
+ take: t.id,
+ ...(t.previewMtimeMs != null ? { v: String(t.previewMtimeMs) } : {}),
+ })}`;
+
+export default function TakesBench({
+ project,
+ groups,
+ initial,
+}: {
+ project: string;
+ groups: { group: string; takes: Take[] }[];
+ initial: Record<string, TakeVerdict>;
+}) {
+ const [verdicts, setVerdicts] = useState(initial);
+ const [listen, setListen] = useState<string | null>(null);
+ const [ready, setReady] = useState(false);
+ const videos = useRef(new Map<string, HTMLVideoElement>());
+ const listenRef = useRef<string | null>(null);
+ // True while "play all" is starting its videos, so their play events do not
+ // each claim the audio.
+ const starting = useRef(false);
+
+ useEffect(() => setReady(true), []);
+ useEffect(() => {
+ listenRef.current = listen;
+ for (const [id, v] of videos.current) v.muted = id !== listen;
+ }, [listen]);
+
+ const els = (g: { takes: Take[] }) =>
+ g.takes.map((t) => videos.current.get(t.id)).filter((v): v is HTMLVideoElement => !!v);
+
+ const playAll = async (g: { takes: Take[] }) => {
+ starting.current = true;
+ const list = els(g);
+ for (const v of list) {
+ v.pause();
+ v.currentTime = 0;
+ }
+ await Promise.allSettled(list.map((v) => v.play()));
+ starting.current = false;
+ };
+
+ const save = async (take: string, patch: { verdict?: Verdict | null; note?: string }) => {
+ const r = await fetch("/api/report/takes/verdict", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project, take, ...patch }),
+ cache: "no-store",
+ });
+ const j = (await r.json()) as { error?: string; verdicts?: Record<string, TakeVerdict> };
+ if (!r.ok || !j.verdicts) throw new Error(j.error ?? `HTTP ${r.status}`);
+ setVerdicts(j.verdicts);
+ };
+
+ return (
+ <div className="space-y-6">
+ {groups.map((g) => {
+ const tally = VERDICTS.map((v) => [v.id, g.takes.filter((t) => verdicts[t.id]?.verdict === v.id).length] as const)
+ .filter(([, n]) => n > 0)
+ .map(([id, n]) => `${n} ${id}`)
+ .join(" · ");
+ return (
+ <section key={g.group} data-group={g.group}>
+ <div className="mb-2 flex flex-wrap items-center gap-2">
+ <h2 className="micro">
+ {g.group} — {g.takes.length}
+ </h2>
+ {tally && <span className="num text-[11px] text-[var(--color-dim)]">{tally}</span>}
+ <span className="ml-auto flex gap-1.5">
+ <button
+ type="button"
+ data-action="play-all"
+ disabled={!ready}
+ className={buttonVariants({ variant: "primary", size: "sm" })}
+ onClick={() => void playAll(g)}
+ >
+ Play all from start
+ </button>
+ <button
+ type="button"
+ data-action="pause-all"
+ disabled={!ready}
+ className={buttonVariants({ size: "sm" })}
+ onClick={() => els(g).forEach((v) => v.pause())}
+ >
+ Pause all
+ </button>
+ </span>
+ </div>
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
+ {g.takes.map((t) => (
+ <TakeCard
+ key={t.id}
+ take={t}
+ src={previewSrc(project, t)}
+ verdict={verdicts[t.id] ?? null}
+ listening={listen === t.id}
+ ready={ready}
+ onListen={() => setListen((cur) => (cur === t.id ? null : t.id))}
+ videoRef={(el) => {
+ if (el) {
+ el.muted = t.id !== listenRef.current;
+ videos.current.set(t.id, el);
+ } else {
+ videos.current.delete(t.id);
+ }
+ }}
+ onPlay={() => {
+ if (!starting.current && listenRef.current !== t.id) setListen(t.id);
+ }}
+ onUnmute={() => {
+ if (listenRef.current !== t.id) setListen(t.id);
+ }}
+ save={(patch) => save(t.id, patch)}
+ />
+ ))}
+ </div>
+ </section>
+ );
+ })}
+ </div>
+ );
+}
+
+function TakeCard({
+ take: t,
+ src,
+ verdict,
+ listening,
+ ready,
+ onListen,
+ videoRef,
+ onPlay,
+ onUnmute,
+ save,
+}: {
+ take: Take;
+ src: string;
+ verdict: TakeVerdict | null;
+ listening: boolean;
+ ready: boolean;
+ onListen: () => void;
+ videoRef: (el: HTMLVideoElement | null) => void;
+ onPlay: () => void;
+ onUnmute: () => void;
+ save: (patch: { verdict?: Verdict | null; note?: string }) => Promise<void>;
+}) {
+ const [note, setNote] = useState(verdict?.note ?? "");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const saved = verdict?.note ?? "";
+ // Adopt a server value that moved underneath (another tab, a refresh).
+ useEffect(() => setNote(verdict?.note ?? ""), [verdict?.note]);
+
+ const run = async (patch: { verdict?: Verdict | null; note?: string }) => {
+ setBusy(true);
+ setError(null);
+ try {
+ await save(patch);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+ const commitNote = () => {
+ if (note.replace(/\s+$/, "") !== saved) void run({ note });
+ };
+ const current = verdict?.verdict ?? null;
+ const hue = VERDICTS.find((v) => v.id === current)?.tone;
+
+ return (
+ <article
+ data-take={t.id}
+ data-kind={t.kind}
+ data-verdict={current ?? ""}
+ className="flex flex-col overflow-hidden rounded border bg-[var(--color-panel)]"
+ style={{ borderColor: `var(--color-${hue ?? "line"})` }}
+ >
+ {t.previewSize != null ? (
+ <video
+ ref={videoRef}
+ src={src}
+ controls
+ preload="metadata"
+ playsInline
+ onPlay={onPlay}
+ onVolumeChange={(e) => {
+ if (!e.currentTarget.muted) onUnmute();
+ }}
+ className="aspect-video w-full bg-black"
+ />
+ ) : (
+ <div data-no-preview="" className="flex aspect-video w-full items-center justify-center bg-black text-[11px] text-[var(--color-dim)]">
+ no preview yet
+ </div>
+ )}
+ <div className="flex flex-1 flex-col gap-1.5 px-3 py-2">
+ <div className="flex flex-wrap items-center gap-2">
+ <h3 className="text-[13px] text-[var(--color-text)]">{t.label}</h3>
+ <span className={badgeVariants({ variant: KIND_TONE[t.kind], size: "sm" })}>{t.kind}</span>
+ <span className="num text-[11px] text-[var(--color-dim)]" suppressHydrationWarning>
+ {t.seconds != null && `${t.seconds.toFixed(1)} s`}
+ {t.builtAt && !Number.isNaN(Date.parse(t.builtAt)) && ` · ${fmtAgo(Date.parse(t.builtAt))}`}
+ </span>
+ <button
+ type="button"
+ data-action="listen"
+ aria-pressed={listening}
+ disabled={!ready || t.previewSize == null}
+ className={`ml-auto rounded border px-2 py-0.5 text-[11px] ${tone("sel", listening)}`}
+ onClick={onListen}
+ >
+ {listening ? "listening" : "listen"}
+ </button>
+ </div>
+ {t.summary && <p className="text-[12px] text-[var(--color-text)]">{t.summary}</p>}
+ {t.changes.length > 0 && (
+ <ul className="list-disc pl-4 text-[11px] text-[var(--color-dim)]">
+ {t.changes.map((c, i) => (
+ <li key={i}>{c}</li>
+ ))}
+ </ul>
+ )}
+ <div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
+ {VERDICTS.map((v) => (
+ <button
+ key={v.id}
+ type="button"
+ data-verdict-button={v.id}
+ aria-pressed={current === v.id}
+ disabled={!ready || busy}
+ className={`rounded border px-2.5 py-1 text-[12px] ${tone(v.tone, current === v.id)}`}
+ onClick={() => void run({ verdict: current === v.id ? null : v.id })}
+ >
+ {v.label}
+ </button>
+ ))}
+ <input
+ value={note}
+ onChange={(e) => setNote(e.target.value)}
+ onBlur={commitNote}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") commitNote();
+ }}
+ disabled={!ready}
+ placeholder="note"
+ aria-label={`note on ${t.label}`}
+ data-take-note=""
+ maxLength={2000}
+ className="min-w-0 flex-1 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-1 text-[12px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]"
+ />
+ </div>
+ {error && <span className="text-[11px] text-[var(--color-bad)]">{error}</span>}
+ </div>
+ </article>
+ );
+}
diff --git a/umtool/components/projects/TakesPage.tsx b/umtool/components/projects/TakesPage.tsx
@@ -0,0 +1,58 @@
+import Link from "next/link";
+import BrowseHeader from "@/components/BrowseHeader";
+import { listTakes, readVerdicts } from "@/lib/report/takes.mjs";
+import type { ProjectRef } from "@/lib/project-types";
+import TakesBench, { type Take, type TakeVerdict } from "./TakesBench";
+
+// `/browse/<project>/takes` -- alternative renders of one part of the cut,
+// watched side by side and judged like / maybe / no.
+//
+// The takes are made elsewhere (lib/report/takes.mjs says by whom and in what
+// shape); this page only reads them, and writes takes/verdicts.json.
+
+export default async function TakesPage({ project }: { project: ProjectRef }) {
+ const [listing, verdicts] = await Promise.all([listTakes(project.dir), readVerdicts(project.dir)]);
+ const takes = listing.takes as Take[];
+ const judged = takes.filter((t) => verdicts[t.id]?.verdict).length;
+
+ return (
+ <div className="flex h-full flex-col">
+ <BrowseHeader
+ crumbs={[
+ { href: "/browse", label: "projects" },
+ { href: `/browse/${project.id}`, label: project.id },
+ { label: "takes" },
+ ]}
+ note={takes.length ? `${takes.length} takes · ${judged} judged` : undefined}
+ />
+ <main className="deck-main flex-1 space-y-4 p-4">
+ {takes.length === 0 ? (
+ <p data-takes-empty="" className="text-[12px] text-[var(--color-dim)]">
+ No takes yet.{" "}
+ <Link href={`/browse/${project.id}`} className="text-[var(--color-sel)] hover:underline">
+ back to {project.id}
+ </Link>
+ </p>
+ ) : (
+ <TakesBench
+ project={project.id}
+ groups={listing.groups.map((g) => ({ group: g.group, takes: g.takes as Take[] }))}
+ initial={verdicts as Record<string, TakeVerdict>}
+ />
+ )}
+ {listing.skipped.length > 0 && (
+ <section data-takes-skipped="" className="text-[11px] text-[var(--color-dim)]">
+ <h2 className="micro mb-1">skipped — {listing.skipped.length}</h2>
+ <ul className="space-y-0.5">
+ {listing.skipped.map((s) => (
+ <li key={s.dir} data-skipped={s.dir}>
+ <code className="font-mono text-[var(--color-text)]">takes/{s.dir}</code> — {s.why}
+ </li>
+ ))}
+ </ul>
+ </section>
+ )}
+ </main>
+ </div>
+ );
+}
diff --git a/umtool/docs/e2e.md b/umtool/docs/e2e.md
@@ -37,6 +37,7 @@ rendered over a deliverable would be indistinguishable from a person doing it.
| `onscreen-build-fixture` | built with the deck, then re-rendered on-screen (`--chrome-only`) |
| `gone-fixture` | its source is gone — the preflight must block it |
| `no-origin-fixture` / `localhost-fixture` | the two defects that shipped |
+| `takes-fixture` | takes **write** `takes/verdicts.json` — two groups, a take with no preview, one skipped take.json, a work dir that is not a take |
| `bike-fixture` | the third kind |
| `find/` | shadowed by a tool page |
| `deep/nested/solo-fixture` | a pass-through chain, for the collapse |
diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md
@@ -692,6 +692,25 @@ containing a newline (`coverage`, `transcriptGapNote`, `qrNote`, `revisionNote`,
beside the manifest (`build-notes.md`, `ADJUDICATION-DIVERGENCE.md`) are listed
there too. No field is moved or rewritten.
+## Takes: alternative renders, judged side by side
+
+`/browse/<project>/takes`, linked from the project page when `takes/` exists.
+Something else renders the takes; umtool reads them and records the verdicts.
+
+```
+takes/<id>/take.json id (== dir, [a-z0-9-]), group, order, label,
+ kind reference|similar|different, preview;
+ optional summary, changes[], seconds, builtAt
+takes/<id>/preview.mp4 whatever `preview` names, inside the take dir
+takes/verdicts.json { "<id>": { verdict: like|maybe|no|null, note, at } }
+```
+
+A take.json that fails validation is listed under **skipped** with the reason;
+a directory with no take.json is not a take. A row whose verdict is null and
+note empty is removed. A `verdicts.json` that does not parse is never
+overwritten. The rules are `lib/report/takes.mjs`; the routes are
+`/api/report/takes` (list), `/takes/preview` (ranges) and `/takes/verdict`.
+
## Exports
`umtool export <project> --format toc-bbcode|toc-markdown|description|chapters`
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -1774,6 +1774,46 @@ writeFileSync(
"# deliverables-fixture — share batch `first`\n\n- **d03_2025-03-03_A-Fixture-Stream.mp4**\n",
);
+// -- THE TAKES FIXTURE --------------------------------------------------------
+//
+// Alternative renders of one part of a cut, as another agent leaves them in
+// takes/<id>/. Its own project because the spec WRITES takes/verdicts.json.
+//
+// finale ref (reference, order 1), slow (similar, 2), hard-cut (different,
+// 3, its preview not rendered yet -- listed, with no player)
+// opening intro-a (similar, 5)
+// bad-kind a take.json whose kind is not one of the three -> skipped, with
+// the reason on the page
+// current/ a work directory with no take.json -> not a take, not listed
+//
+// report-fixture has no takes/ at all, which is the empty state.
+const TAKES = writeProject(
+ "takes-fixture",
+ manifest("takes-fixture", "The Takes Fixture", { siteOrigin: "https://archive.example" }, [
+ { type: "clip", id: "c01", video: "vid1", start: 0, end: 3, cite: 0, section: 0, lock: true, quote: "q" },
+ ]),
+);
+const take = (id, doc, { hz = null } = {}) => {
+ const dir = path.join(TAKES, "takes", id);
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(path.join(dir, "take.json"), JSON.stringify({ id, preview: "preview.mp4", ...doc }, null, 2));
+ if (hz) {
+ ff([
+ "-f", "lavfi", "-i", "testsrc=size=320x180:rate=15:duration=2",
+ "-f", "lavfi", "-i", `sine=frequency=${hz}:duration=2`,
+ "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest",
+ "-movflags", "+faststart",
+ path.join(dir, "preview.mp4"),
+ ]);
+ }
+};
+take("ref", { group: "finale", order: 1, label: "As built", kind: "reference", summary: "The current ending.", changes: [], seconds: 2 }, { hz: 330 });
+take("slow", { group: "finale", order: 2, label: "Slow burn", kind: "similar", summary: "Same words; slower beat.", changes: ["beat 1.35 s (was 1.05)", "dip 1.4 s (was 0.6)"], seconds: 2, builtAt: "2026-10-06T23:10:00Z" }, { hz: 440 });
+take("hard-cut", { group: "finale", order: 3, label: "Hard cut", kind: "different", summary: "No dip at all.", changes: ["no fade"] });
+take("intro-a", { group: "opening", order: 5, label: "Cold open", kind: "similar", summary: "Starts on the quote.", seconds: 2 }, { hz: 550 });
+take("bad-kind", { group: "finale", order: 4, label: "Bad", kind: "maybe" });
+mkdirSync(path.join(TAKES, "takes", "current", "out"), { recursive: true });
+
console.log(`fixture at ${dest}`);
if (planned) console.log(` planned clip (used in a build): ${planned}`);
console.log(` videos/: alpha (4 cuts, 3 variants), beta (2 cuts), deck (1 cut, 2 variants)`);
@@ -1804,4 +1844,5 @@ console.log(` onscreen-fixture (writable, deck on, unbuilt), onscreen
console.log(` onscreen-posts-fixture (writable, deck on, three posts, unbuilt)`);
console.log(` onscreen-feed-fixture (writable, posts feed, built by the spec)`);
console.log(` deliver-stop-fixture (writable: six confirmed clips to cut, for Stop and resume)`);
+console.log(` takes-fixture (writable: takes/ with 4 takes over 2 groups, 1 skipped)`);
console.log(` ${taken} candidate files copied, 2 mix tracks synthesised`);
diff --git a/umtool/e2e/takes.spec.ts b/umtool/e2e/takes.spec.ts
@@ -0,0 +1,143 @@
+import { test, expect } from "@playwright/test";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// ---------------------------------------------------------------------------
+// Takes: alternative renders of part of a cut, side by side, judged.
+//
+// takes-fixture WRITES takes/verdicts.json. Two groups, a take with no
+// preview yet, one skipped take.json, a work dir that is
+// not a take (make-fixture.mjs says which is which)
+// report-fixture no takes/ at all -- the empty state
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const PROJECT_DIR = path.join(HERE, "..", ".e2e-song", "reports", "takes-fixture");
+const VERDICTS = path.join(PROJECT_DIR, "takes", "verdicts.json");
+const PAGE = "/browse/reports/takes-fixture/takes";
+
+test("a project with no takes/ says so, and its page has no takes link", async ({ page }) => {
+ const res = await page.goto("/browse/reports/report-fixture/takes");
+ expect(res?.status()).toBe(200);
+ await expect(page.locator("[data-takes-empty]")).toContainText("No takes yet");
+
+ await page.goto("/browse/reports/report-fixture");
+ await expect(page.locator("[data-takes-link]")).toHaveCount(0);
+});
+
+test("takes are grouped and ordered; the skipped one says why; a work dir is not a take", async ({ page }) => {
+ await page.goto("/browse/reports/takes-fixture");
+ const link = page.locator("[data-takes-link]");
+ await expect(link).toHaveAttribute("data-takes-link", "4");
+ await link.click();
+ await expect(page).toHaveURL(new RegExp(`${PAGE}$`));
+
+ const groups = page.locator("[data-group]");
+ await expect(groups).toHaveCount(2);
+ await expect(groups.nth(0)).toHaveAttribute("data-group", "finale");
+ await expect(groups.nth(1)).toHaveAttribute("data-group", "opening");
+
+ const finale = page.locator("[data-group='finale'] [data-take]");
+ expect(await finale.evaluateAll((els) => els.map((e) => e.getAttribute("data-take")))).toEqual([
+ "ref",
+ "slow",
+ "hard-cut",
+ ]);
+ await expect(page.locator("[data-take='ref']")).toHaveAttribute("data-kind", "reference");
+ await expect(page.locator("[data-take='slow']")).toContainText("beat 1.35 s (was 1.05)");
+ await expect(page.locator("[data-take='hard-cut'] [data-no-preview]")).toBeVisible();
+ await expect(page.locator("[data-take='hard-cut'] video")).toHaveCount(0);
+
+ await expect(page.locator("[data-skipped='bad-kind']")).toContainText("kind must be one of");
+ await expect(page.locator("[data-skipped='current']")).toHaveCount(0);
+ await expect(page.locator("[data-take='current']")).toHaveCount(0);
+});
+
+test("a preview is served with byte ranges, and only for a listed take", async ({ request }) => {
+ const q = (take: string) => `/api/report/takes/preview?project=reports/takes-fixture&take=${take}`;
+ const whole = await request.get(q("ref"));
+ expect(whole.status()).toBe(200);
+ expect(whole.headers()["content-type"]).toBe("video/mp4");
+ const size = Number(whole.headers()["content-length"]);
+ expect(size).toBeGreaterThan(100);
+
+ const part = await request.get(q("ref"), { headers: { range: "bytes=0-99" } });
+ expect(part.status()).toBe(206);
+ expect(part.headers()["content-range"]).toBe(`bytes 0-99/${size}`);
+ expect((await part.body()).length).toBe(100);
+
+ expect((await request.get(q("bad-kind"))).status()).toBe(404);
+ expect((await request.get(q("current"))).status()).toBe(404);
+ expect((await request.get(q("hard-cut"))).status()).toBe(404);
+ expect((await request.get(q("..%2Fref"))).status()).toBe(400);
+});
+
+test("verdicts and notes land in takes/verdicts.json in the agreed shape", async ({ page, request }) => {
+ await page.goto(PAGE);
+ const slow = page.locator("[data-take='slow']");
+ const like = slow.locator("[data-verdict-button='like']");
+ await expect(like).toBeEnabled();
+ await like.click();
+ await expect(slow).toHaveAttribute("data-verdict", "like");
+
+ const note = slow.locator("[data-take-note]");
+ await expect(async () => {
+ await note.fill("the wait is right");
+ await expect(note).toHaveValue("the wait is right");
+ }).toPass();
+ await note.press("Enter");
+ await expect
+ .poll(() => {
+ try {
+ return JSON.parse(readFileSync(VERDICTS, "utf8")).slow?.note;
+ } catch {
+ return null;
+ }
+ })
+ .toBe("the wait is right");
+
+ const onDisk = JSON.parse(readFileSync(VERDICTS, "utf8"));
+ expect(Object.keys(onDisk.slow).sort()).toEqual(["at", "note", "verdict"]);
+ expect(onDisk.slow.verdict).toBe("like");
+
+ // Survives a reload; pressing the lit verdict again clears it but keeps the note.
+ await page.reload();
+ await expect(slow).toHaveAttribute("data-verdict", "like");
+ await expect(note).toHaveValue("the wait is right");
+ await slow.locator("[data-verdict-button='like']").click();
+ await expect(slow).toHaveAttribute("data-verdict", "");
+ await expect.poll(() => JSON.parse(readFileSync(VERDICTS, "utf8")).slow?.verdict).toBeNull();
+
+ // A verdict on a take that is not listed is refused, not written.
+ const bad = await request.post("/api/report/takes/verdict", {
+ data: { project: "reports/takes-fixture", take: "bad-kind", verdict: "like" },
+ });
+ expect(bad.status()).toBe(404);
+ expect(JSON.parse(readFileSync(VERDICTS, "utf8"))["bad-kind"]).toBeUndefined();
+});
+
+test("listen picks the one audio source; play all starts the group from zero", async ({ page }) => {
+ await page.goto(PAGE);
+ const muted = (id: string) => page.locator(`[data-take='${id}'] video`).evaluate((v: HTMLVideoElement) => v.muted);
+
+ await page.locator("[data-take='slow'] [data-action='listen']").click();
+ await expect(page.locator("[data-take='slow'] [data-action='listen']")).toHaveAttribute("aria-pressed", "true");
+ await expect.poll(() => muted("slow")).toBe(false);
+ expect(await muted("ref")).toBe(true);
+ expect(await muted("intro-a")).toBe(true);
+
+ await page.locator("[data-take='ref'] [data-action='listen']").click();
+ await expect.poll(() => muted("ref")).toBe(false);
+ expect(await muted("slow")).toBe(true);
+
+ await page.locator("[data-group='finale'] [data-action='play-all']").click();
+ const playing = (id: string) =>
+ page.locator(`[data-take='${id}'] video`).evaluate((v: HTMLVideoElement) => !v.paused && v.currentTime > 0);
+ await expect.poll(() => playing("ref")).toBe(true);
+ await expect.poll(() => playing("slow")).toBe(true);
+ // The other group was not started, and play all did not move the audio.
+ expect(await playing("intro-a")).toBe(false);
+ expect(await muted("ref")).toBe(false);
+ expect(await muted("slow")).toBe(true);
+});
diff --git a/umtool/lib/report/takes.mjs b/umtool/lib/report/takes.mjs
@@ -0,0 +1,292 @@
+// Takes: alternative renders of one part of a report video, side by side.
+//
+// Something ELSE renders them -- an agent trying five endings -- into
+//
+// <project>/takes/<takeId>/take.json
+// <project>/takes/<takeId>/preview.mp4 (whatever take.json names)
+// <project>/takes/<takeId>/video.manifest.json (what it was built from)
+//
+// and this reads them back, serves their previews and records which ones the
+// operator liked in <project>/takes/verdicts.json, which that agent reads.
+// So take.json is a contract written by somebody else: every field is checked,
+// and a take that fails is SKIPPED WITH A REASON rather than dropped or half
+// shown. A directory with no take.json is not a take (a work dir, a build in
+// progress) and is not listed at all.
+//
+// Plain ESM for the same reason manifest.mjs is: `node --test` runs it with no
+// TypeScript, and the rules about which file a request may open live here,
+// tested, rather than in a route.
+import { readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { inside } from "../paths.mjs";
+
+export const TAKES_DIR = "takes";
+export const TAKE_FILE = "take.json";
+export const VERDICTS_FILE = "verdicts.json";
+
+/** A take id is its directory's name, and one url-safe segment. */
+export const TAKE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
+/**
+ * @param {unknown} v
+ * @returns {v is string}
+ */
+export const isTakeId = (v) => typeof v === "string" && TAKE_ID.test(v);
+
+export const TAKE_KINDS = ["reference", "similar", "different"];
+export const TAKE_VERDICTS = ["like", "maybe", "no"];
+
+/** A note is a sentence or two about one take, not an essay. */
+export const TAKE_NOTE_LIMIT = 2000;
+
+export const takesDirOf = (projectDir) => path.join(projectDir, TAKES_DIR);
+export const verdictsFileOf = (projectDir) => path.join(takesDirOf(projectDir), VERDICTS_FILE);
+
+const str = (v) => (typeof v === "string" ? v.trim() : "");
+
+/**
+ * One take.json, checked. `{ take }` or `{ error }` -- never a partial take.
+ *
+ * Required: id (== the directory), group, order, label, kind, preview.
+ * Optional: summary (""), changes ([]), seconds (null), builtAt (null). An
+ * optional field of the wrong type is an error too: a `changes` that is a
+ * string would otherwise render as one bullet per character.
+ *
+ * `preview` is RELATIVE to the take's directory and may not climb out of it;
+ * that is checked again against the real path when the file is served.
+ *
+ * @param {string} dirName
+ * @param {unknown} raw
+ */
+export function parseTake(dirName, raw) {
+ if (!isTakeId(dirName)) return { error: `directory name is not a take id (${TAKE_ID})` };
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: "take.json is not an object" };
+ const j = /** @type {Record<string, unknown>} */ (raw);
+ if (j.id !== dirName) return { error: `id ${JSON.stringify(j.id ?? null)} is not the directory name` };
+ const group = str(j.group);
+ if (!group) return { error: "group is missing" };
+ if (typeof j.order !== "number" || !Number.isFinite(j.order)) return { error: "order is not a number" };
+ const label = str(j.label);
+ if (!label) return { error: "label is missing" };
+ if (!TAKE_KINDS.includes(/** @type {string} */ (j.kind))) {
+ return { error: `kind must be one of ${TAKE_KINDS.join(", ")}` };
+ }
+ const preview = str(j.preview);
+ if (!preview) return { error: "preview is missing" };
+ if (path.isAbsolute(preview) || /[\\\0]/.test(preview) || preview.split("/").some((s) => s === ".." || s === "")) {
+ return { error: "preview must be a relative path inside the take" };
+ }
+ if (j.summary !== undefined && typeof j.summary !== "string") return { error: "summary is not a string" };
+ if (j.changes !== undefined && !(Array.isArray(j.changes) && j.changes.every((c) => typeof c === "string"))) {
+ return { error: "changes is not a list of strings" };
+ }
+ if (j.seconds !== undefined && j.seconds !== null && (typeof j.seconds !== "number" || !Number.isFinite(j.seconds))) {
+ return { error: "seconds is not a number" };
+ }
+ if (j.builtAt !== undefined && j.builtAt !== null && typeof j.builtAt !== "string") {
+ return { error: "builtAt is not a string" };
+ }
+ return {
+ take: {
+ id: dirName,
+ group,
+ order: j.order,
+ label,
+ kind: /** @type {"reference" | "similar" | "different"} */ (j.kind),
+ summary: str(j.summary),
+ changes: /** @type {string[]} */ (j.changes ?? []).map((c) => c.trim()).filter(Boolean),
+ preview,
+ seconds: typeof j.seconds === "number" ? j.seconds : null,
+ builtAt: typeof j.builtAt === "string" ? j.builtAt : null,
+ },
+ };
+}
+
+/**
+ * Every take of a project, grouped, plus what was skipped and why.
+ *
+ * Groups are in order of their lowest `order`, then by name; takes within a
+ * group by `order`, then id. Each take carries its preview's size and mtime
+ * (null when the file is not there yet -- an agent may still be rendering it),
+ * and the mtime is what the page passes as `v`, because a re-render writes the
+ * same path.
+ *
+ * @param {string} projectDir
+ */
+export async function listTakes(projectDir) {
+ const root = takesDirOf(projectDir);
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => null);
+ if (!entries) return { exists: false, takes: [], groups: [], skipped: [] };
+
+ const takes = [];
+ const skipped = [];
+ for (const e of entries) {
+ if (e.name.startsWith(".")) continue;
+ const dir = path.join(root, e.name);
+ const isDir = e.isDirectory() || (e.isSymbolicLink() && (await stat(dir).then((s) => s.isDirectory(), () => false)));
+ if (!isDir) continue;
+ let text;
+ try {
+ text = await readFile(path.join(dir, TAKE_FILE), "utf8");
+ } catch {
+ continue; // not a take
+ }
+ let raw;
+ try {
+ raw = JSON.parse(text);
+ } catch (err) {
+ skipped.push({ dir: e.name, why: `take.json does not parse: ${err instanceof Error ? err.message : err}` });
+ continue;
+ }
+ const r = parseTake(e.name, raw);
+ if ("error" in r) {
+ skipped.push({ dir: e.name, why: r.error });
+ continue;
+ }
+ const file = await previewFile(projectDir, r.take);
+ takes.push({ ...r.take, previewSize: file?.size ?? null, previewMtimeMs: file?.mtimeMs ?? null });
+ }
+
+ takes.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
+ const byGroup = new Map();
+ for (const t of takes) {
+ if (!byGroup.has(t.group)) byGroup.set(t.group, []);
+ byGroup.get(t.group).push(t);
+ }
+ const groups = [...byGroup.entries()]
+ .map(([group, list]) => ({ group, takes: list }))
+ .sort((a, b) => a.takes[0].order - b.takes[0].order || a.group.localeCompare(b.group));
+ skipped.sort((a, b) => a.dir.localeCompare(b.dir));
+ return { exists: true, takes, groups, skipped };
+}
+
+/**
+ * A take's preview, if it is a file whose REAL path is inside that take's own
+ * directory. A symlink out of it, or a take directory that is itself a link
+ * out of `takes/`, is refused, as deckPreviewFile refuses one.
+ *
+ * @param {string} projectDir
+ * @param {{ id: string, preview: string }} take an already-parsed take
+ * @returns {Promise<{ abs: string, size: number, mtimeMs: number } | null>}
+ */
+export async function previewFile(projectDir, take) {
+ if (!isTakeId(take?.id)) return null;
+ const root = takesDirOf(projectDir);
+ const dir = path.join(root, take.id);
+ const abs = path.resolve(dir, take.preview);
+ if (!inside(dir, abs) || abs === dir) return null;
+ const [realRoot, realDir, realAbs] = await Promise.all(
+ [root, dir, abs].map((p) => realpath(p).catch(() => null)),
+ );
+ if (!realRoot || !realDir || !realAbs) return null;
+ if (!inside(realRoot, realDir) || realDir === realRoot) return null;
+ if (!inside(realDir, realAbs) || realAbs === realDir) return null;
+ const st = await stat(realAbs).catch(() => null);
+ if (!st?.isFile()) return null;
+ return { abs: realAbs, size: st.size, mtimeMs: Math.round(st.mtimeMs) };
+}
+
+// ---------------------------------------------------------------------------
+// Verdicts.
+//
+// { "<takeId>": { "verdict": "like" | "maybe" | "no" | null, "note": string, "at": ISO } }
+//
+// Read back by the agent that made the takes, so the shape is fixed and every
+// row carries all three keys. A row whose verdict is null and note empty is
+// REMOVED rather than stored -- it says nothing.
+//
+// Its own write queue, as manifest.mjs has its own: lib/state.ts is TypeScript
+// (and serialises the song state files, an unrelated set). Within a process the
+// queue serialises read-modify-write; across processes the tmp + rename keeps
+// a reader from ever seeing half a file.
+// ---------------------------------------------------------------------------
+
+/** @type {Promise<unknown>} */
+let queue = Promise.resolve();
+/**
+ * @template T
+ * @param {() => Promise<T>} fn
+ * @returns {Promise<T>}
+ */
+function withTakesLock(fn) {
+ const run = queue.then(fn, fn);
+ queue = run.then(
+ () => undefined,
+ () => undefined,
+ );
+ return run;
+}
+
+/** Rows that are not the contract's shape are dropped on read, never repaired on disk. */
+function cleanVerdicts(raw) {
+ const out = {};
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
+ for (const [id, row] of Object.entries(raw)) {
+ if (!isTakeId(id) || !row || typeof row !== "object") continue;
+ const verdict = TAKE_VERDICTS.includes(row.verdict) ? row.verdict : null;
+ const note = typeof row.note === "string" ? row.note : "";
+ const at = typeof row.at === "string" ? row.at : "";
+ out[id] = { verdict, note, at };
+ }
+ return out;
+}
+
+/**
+ * @param {string} projectDir
+ * @returns {Promise<Record<string, { verdict: "like" | "maybe" | "no" | null, note: string, at: string }>>}
+ */
+export async function readVerdicts(projectDir) {
+ try {
+ return cleanVerdicts(JSON.parse(await readFile(verdictsFileOf(projectDir), "utf8")));
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * Set one take's verdict and/or note. A field left undefined keeps its value;
+ * `verdict: null` clears it. Returns the row as stored (null when removed) and
+ * the whole file.
+ *
+ * A verdicts.json that exists and does not parse is an ERROR, not an empty
+ * map: writing over it would erase every judgement in it.
+ *
+ * @param {string} projectDir
+ * @param {string} takeId
+ * @param {{ verdict?: string | null, note?: string }} patch
+ */
+export async function setTakeVerdict(projectDir, takeId, patch) {
+ if (!isTakeId(takeId)) throw new Error("not a take id");
+ if (patch.verdict !== undefined && patch.verdict !== null && !TAKE_VERDICTS.includes(patch.verdict)) {
+ throw new Error(`verdict must be one of ${TAKE_VERDICTS.join(", ")} or null`);
+ }
+ if (patch.note !== undefined && typeof patch.note !== "string") throw new Error("note must be a string");
+ const file = verdictsFileOf(projectDir);
+ return withTakesLock(async () => {
+ let text = null;
+ try {
+ text = await readFile(file, "utf8");
+ } catch (err) {
+ if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT") throw err;
+ }
+ let map = {};
+ if (text !== null) {
+ try {
+ map = cleanVerdicts(JSON.parse(text));
+ } catch {
+ throw new Error(`${VERDICTS_FILE} does not parse; not overwriting it`);
+ }
+ }
+ const prev = map[takeId] ?? { verdict: null, note: "", at: "" };
+ const row = {
+ verdict: patch.verdict === undefined ? prev.verdict : patch.verdict,
+ note: patch.note === undefined ? prev.note : patch.note.slice(0, TAKE_NOTE_LIMIT).replace(/\s+$/, ""),
+ at: new Date().toISOString(),
+ };
+ if (row.verdict === null && !row.note) delete map[takeId];
+ else map[takeId] = row;
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
+ await writeFile(tmp, JSON.stringify(map, null, 2) + "\n", "utf8");
+ await rename(tmp, file);
+ return { entry: map[takeId] ?? null, verdicts: map };
+ });
+}
diff --git a/umtool/lib/report/takes.test.mjs b/umtool/lib/report/takes.test.mjs
@@ -0,0 +1,239 @@
+// Takes: what take.json may say, which preview a request may open, and the
+// verdicts file another agent reads back.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import {
+ isTakeId,
+ listTakes,
+ parseTake,
+ previewFile,
+ readVerdicts,
+ setTakeVerdict,
+ verdictsFileOf,
+} from "./takes.mjs";
+
+const good = (over = {}) => ({
+ id: "slow-burn",
+ group: "finale",
+ order: 3,
+ label: "Slow burn",
+ kind: "similar",
+ summary: "Same words; slower beat",
+ changes: ["beat 1.35 s (was 1.05)"],
+ preview: "preview.mp4",
+ seconds: 38.2,
+ builtAt: "2026-10-06T23:10:00Z",
+ ...over,
+});
+
+async function project() {
+ const dir = await mkdtemp(path.join(tmpdir(), "takes-"));
+ return dir;
+}
+
+async function writeTake(projectDir, id, doc, { preview = true } = {}) {
+ const dir = path.join(projectDir, "takes", id);
+ await mkdir(dir, { recursive: true });
+ await writeFile(path.join(dir, "take.json"), typeof doc === "string" ? doc : JSON.stringify(doc));
+ if (preview) await writeFile(path.join(dir, "preview.mp4"), "not really an mp4");
+ return dir;
+}
+
+test("take ids are one lowercase url segment", () => {
+ for (const ok of ["a", "slow-burn", "take-01", "0", "a".repeat(64)]) assert.equal(isTakeId(ok), true, ok);
+ for (const bad of ["", "-a", "Slow", "a_b", "a.b", "a/b", "..", "a".repeat(65), null, 3]) {
+ assert.equal(isTakeId(bad), false, String(bad));
+ }
+});
+
+test("parseTake accepts the contract and fills the optional fields", () => {
+ const r = parseTake("slow-burn", good());
+ assert.ok("take" in r);
+ assert.deepEqual(r.take, {
+ id: "slow-burn",
+ group: "finale",
+ order: 3,
+ label: "Slow burn",
+ kind: "similar",
+ summary: "Same words; slower beat",
+ changes: ["beat 1.35 s (was 1.05)"],
+ preview: "preview.mp4",
+ seconds: 38.2,
+ builtAt: "2026-10-06T23:10:00Z",
+ });
+ const min = parseTake("x", { id: "x", group: "g", order: 0, label: "X", kind: "reference", preview: "p.mp4" });
+ assert.ok("take" in min);
+ assert.equal(min.take.summary, "");
+ assert.deepEqual(min.take.changes, []);
+ assert.equal(min.take.seconds, null);
+ assert.equal(min.take.builtAt, null);
+});
+
+test("parseTake refuses a take it cannot show truthfully", () => {
+ const cases = [
+ ["slow-burn", [], "not an object"],
+ ["slow-burn", good({ id: "other" }), "not the directory"],
+ ["Bad_Dir", good({ id: "Bad_Dir" }), "directory name"],
+ ["slow-burn", good({ group: " " }), "group"],
+ ["slow-burn", good({ order: "3" }), "order"],
+ ["slow-burn", good({ order: Number.NaN }), "order"],
+ ["slow-burn", good({ label: undefined }), "label"],
+ ["slow-burn", good({ kind: "other" }), "kind"],
+ ["slow-burn", good({ preview: "" }), "preview"],
+ ["slow-burn", good({ preview: "/etc/passwd" }), "relative"],
+ ["slow-burn", good({ preview: "../other/preview.mp4" }), "relative"],
+ ["slow-burn", good({ preview: "a//b.mp4" }), "relative"],
+ ["slow-burn", good({ changes: "beat 1.35" }), "changes"],
+ ["slow-burn", good({ changes: [1] }), "changes"],
+ ["slow-burn", good({ summary: 4 }), "summary"],
+ ["slow-burn", good({ seconds: "38" }), "seconds"],
+ ["slow-burn", good({ builtAt: 1 }), "builtAt"],
+ ];
+ for (const [dir, doc, want] of cases) {
+ const r = parseTake(dir, doc);
+ assert.ok("error" in r, `${JSON.stringify(doc)} should fail`);
+ assert.match(r.error, new RegExp(want), r.error);
+ }
+});
+
+test("listTakes: no takes/ is a state of its own, not an error", async () => {
+ const dir = await project();
+ try {
+ assert.deepEqual(await listTakes(dir), { exists: false, takes: [], groups: [], skipped: [] });
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("listTakes groups, sorts, skips with a reason, and ignores what is not a take", async () => {
+ const dir = await project();
+ try {
+ await writeTake(dir, "slow-burn", good());
+ await writeTake(dir, "ref", good({ id: "ref", label: "Reference", kind: "reference", order: 1 }));
+ await writeTake(dir, "hard-cut", good({ id: "hard-cut", label: "Hard cut", kind: "different", order: 2 }), { preview: false });
+ await writeTake(dir, "intro-a", good({ id: "intro-a", group: "opening", order: 5 }));
+ await writeTake(dir, "broken", "{ not json");
+ await writeTake(dir, "wrong-kind", good({ id: "wrong-kind", kind: "maybe" }));
+ // A work directory and loose files: not takes, not listed, not "skipped".
+ await mkdir(path.join(dir, "takes", "current", "out"), { recursive: true });
+ await writeFile(path.join(dir, "takes", "make-takes.py"), "");
+ await writeFile(path.join(dir, "takes", "verdicts.json"), "{}");
+
+ const r = await listTakes(dir);
+ assert.equal(r.exists, true);
+ assert.deepEqual(r.groups.map((g) => [g.group, g.takes.map((t) => t.id)]), [
+ ["finale", ["ref", "hard-cut", "slow-burn"]],
+ ["opening", ["intro-a"]],
+ ]);
+ assert.deepEqual(r.skipped.map((s) => s.dir), ["broken", "wrong-kind"]);
+ assert.match(r.skipped[0].why, /does not parse/);
+ assert.match(r.skipped[1].why, /kind/);
+ const hard = r.takes.find((t) => t.id === "hard-cut");
+ assert.equal(hard.previewSize, null, "a preview not rendered yet is listed, without a size");
+ const ref = r.takes.find((t) => t.id === "ref");
+ assert.equal(ref.previewSize, "not really an mp4".length);
+ assert.equal(typeof ref.previewMtimeMs, "number");
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("previewFile never leaves the take's own directory", async () => {
+ const dir = await project();
+ const outside = await mkdtemp(path.join(tmpdir(), "takes-outside-"));
+ try {
+ const takeDir = await writeTake(dir, "ok", good({ id: "ok" }));
+ await writeFile(path.join(outside, "secret.mp4"), "secret");
+ assert.ok(await previewFile(dir, { id: "ok", preview: "preview.mp4" }));
+
+ // Lexical escapes, even if parseTake were bypassed.
+ assert.equal(await previewFile(dir, { id: "ok", preview: "../../video.manifest.json" }), null);
+ assert.equal(await previewFile(dir, { id: "../ok", preview: "preview.mp4" }), null);
+ assert.equal(await previewFile(dir, { id: "ok", preview: "." }), null);
+
+ // A symlinked preview pointing out of the take.
+ await symlink(path.join(outside, "secret.mp4"), path.join(takeDir, "leak.mp4"));
+ assert.equal(await previewFile(dir, { id: "ok", preview: "leak.mp4" }), null);
+
+ // A take directory that is itself a link out of takes/.
+ await mkdir(path.join(outside, "linked"));
+ await writeFile(path.join(outside, "linked", "preview.mp4"), "x");
+ await symlink(path.join(outside, "linked"), path.join(dir, "takes", "linked"));
+ assert.equal(await previewFile(dir, { id: "linked", preview: "preview.mp4" }), null);
+
+ // A directory is not a file; a missing file is null.
+ await mkdir(path.join(takeDir, "sub"));
+ assert.equal(await previewFile(dir, { id: "ok", preview: "sub" }), null);
+ assert.equal(await previewFile(dir, { id: "ok", preview: "nope.mp4" }), null);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ await rm(outside, { recursive: true, force: true });
+ }
+});
+
+test("setTakeVerdict writes the contract's shape, merges, and removes an empty row", async () => {
+ const dir = await project();
+ try {
+ await mkdir(path.join(dir, "takes"), { recursive: true });
+ assert.deepEqual(await readVerdicts(dir), {});
+
+ const a = await setTakeVerdict(dir, "slow-burn", { verdict: "like" });
+ assert.equal(a.entry.verdict, "like");
+ assert.equal(a.entry.note, "");
+ assert.ok(!Number.isNaN(Date.parse(a.entry.at)));
+
+ // A note alone keeps the verdict; trailing whitespace is not kept.
+ await setTakeVerdict(dir, "slow-burn", { note: "the wait is right \n" });
+ await setTakeVerdict(dir, "ref", { verdict: "no", note: "too fast" });
+ const onDisk = JSON.parse(await readFile(verdictsFileOf(dir), "utf8"));
+ assert.deepEqual(Object.keys(onDisk).sort(), ["ref", "slow-burn"]);
+ assert.deepEqual(
+ { verdict: onDisk["slow-burn"].verdict, note: onDisk["slow-burn"].note },
+ { verdict: "like", note: "the wait is right" },
+ );
+ assert.deepEqual(Object.keys(onDisk["ref"]).sort(), ["at", "note", "verdict"]);
+
+ // Clearing the verdict keeps a row with a note; clearing both removes it.
+ await setTakeVerdict(dir, "ref", { verdict: null });
+ assert.deepEqual((await readVerdicts(dir)).ref.verdict, null);
+ const gone = await setTakeVerdict(dir, "ref", { note: "" });
+ assert.equal(gone.entry, null);
+ assert.deepEqual(Object.keys(await readVerdicts(dir)), ["slow-burn"]);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("setTakeVerdict refuses bad input and never overwrites a file it cannot read", async () => {
+ const dir = await project();
+ try {
+ await mkdir(path.join(dir, "takes"), { recursive: true });
+ await assert.rejects(setTakeVerdict(dir, "../x", { verdict: "like" }), /take id/);
+ await assert.rejects(setTakeVerdict(dir, "a", { verdict: "keep" }), /verdict must be/);
+ await assert.rejects(setTakeVerdict(dir, "a", { note: 4 }), /note must be/);
+
+ await writeFile(verdictsFileOf(dir), "{ half a file");
+ await assert.rejects(setTakeVerdict(dir, "a", { verdict: "like" }), /does not parse/);
+ assert.equal(await readFile(verdictsFileOf(dir), "utf8"), "{ half a file");
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("concurrent verdicts on different takes are all kept", async () => {
+ const dir = await project();
+ try {
+ await mkdir(path.join(dir, "takes"), { recursive: true });
+ const ids = Array.from({ length: 12 }, (_, i) => `t${i}`);
+ await Promise.all(ids.map((id, i) => setTakeVerdict(dir, id, { verdict: ["like", "maybe", "no"][i % 3] })));
+ assert.deepEqual(Object.keys(await readVerdicts(dir)).sort(), [...ids].sort());
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});