Archilyzer · Source

archilyzer

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

commit 6b8128338916d55e7b503e2c98f9bf181043cd11
parent 4f9352b9509aa01a6971d855e07812aed9899838
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 23:19:06 -0400

Merge feat/umtool-takes (umtool: alternative renders of part of a cut, side by side, judged)

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

Diffstat:
Aumtool/app/api/report/takes/preview/route.ts | 37+++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/takes/route.ts | 15+++++++++++++++
Aumtool/app/api/report/takes/verdict/route.ts | 39+++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/ProjectView.tsx | 4++++
Mumtool/components/projects/ReportProject.tsx | 13+++++++++++++
Aumtool/components/projects/TakesBench.tsx | 333+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/projects/TakesPage.tsx | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/docs/e2e.md | 1+
Mumtool/docs/report-video.md | 19+++++++++++++++++++
Mumtool/e2e/fixtures/make-fixture.mjs | 41+++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/takes.spec.ts | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/takes.mjs | 292+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/takes.test.mjs | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 }); + } +});