commit e12f3f621dd99fa546611a68679f99cc3e98e9a8
parent ab18d2defbdc0aa39e18dd0fe510ee0e9c922305
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 25 Aug 2026 09:11:03 -0400
umtool: a dashboard at /, a project from a video, and a long-form pass over sources, revisions and exports
/ is a page now, not a doorway into the song piles: what is running (one
activity list over both job registries, /api/jobs), what is waiting, where
every kind stands, which sources nobody has checked, what is built, and
whether this machine can build (umtool doctor; the panel shows a cache or
"not checked" and never probes). The piles fold into one nav entry, song ▸,
at their old URLs; the last page is offered as a chip rather than navigated
to.
A report video can start from a video ref, not only a report:
`umtool new --from <channel>/<id> [--seed chapters]`, one scaffold writer
shared with POST /api/projects/new, whose menu renders the fields each kind
declares. The driver exposes --variant, --no-xfade, --chapters-only and
--preview, and the overwrite guard goes through variantPaths(). The editor
links into it with UMTOOL_URL.
The long-form pass: sourcesOf() gives one row per source with cue coverage
(cues end 880 s · c03 needs 897 s), availability age and the citeUrl
override; clip-cue-gap is a decision; snapshots live in revisions/ with
legacy .bak files listed and diffed by entry id; README and long provenance
render as notes; check-sources re-checks across projects through the job
runner; export derives the toc-bbcode / markdown / description / chapters
from the build's own offsets -- gout's matches its hand-made file row for
row.
The job registries moved onto globalThis: Next compiles each route entry
into its own module graph in dev, and the dashboard page read an empty
Map while /api/jobs held the finished build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
68 files changed, 3935 insertions(+), 581 deletions(-)
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -93,6 +93,9 @@ type Props = {
prevHref?: string;
nextHref?: string;
position?: { index: number; total: number };
+ // The umtool front door, when UMTOOL_URL is set on the editor. One link:
+ // "open in umtool" scaffolds a report video from this exact video.
+ umtoolUrl?: string | null;
};
// One list, shared with the detection predicates in lib/mediaFiles.ts — these
@@ -160,6 +163,7 @@ export function VideoPanel({
nextHref,
position,
vttProvenance = {},
+ umtoolUrl = null,
}: Props) {
const audioFiles = files.filter((f) => isAudioFile(f.name));
const transcodeSources = files.filter((f) => isTranscodeSource(f.name));
@@ -212,6 +216,18 @@ export function VideoPanel({
videoId={videoId}
/>
)}
+ {umtoolUrl && (
+ <div className="text-sm">
+ <a
+ href={`${umtoolUrl}/?from=${encodeURIComponent(`${slug}/${videoId}`)}`}
+ className="underline"
+ data-umtool-link
+ >
+ Open in umtool
+ </a>
+ <span className="text-muted-foreground"> — start a report video from this recording</span>
+ </div>
+ )}
<PipelineStatusStrip
downloaded={!noAudio}
transcoded={transcodeSources.length === 0}
@@ -466,6 +482,9 @@ function VideoNavStrip({
prevHref?: string;
nextHref?: string;
position?: { index: number; total: number };
+ // The umtool front door, when UMTOOL_URL is set on the editor. One link:
+ // "open in umtool" scaffolds a report video from this exact video.
+ umtoolUrl?: string | null;
videoId: string;
}) {
return (
diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx
@@ -255,6 +255,9 @@ export default async function VideoDetailPage({
slug={slug}
videoId={id}
files={dirData.files}
+ // Read HERE, in the server component, never in the client one: an env
+ // var does not exist in the browser. Unset means no link at all.
+ umtoolUrl={process.env.UMTOOL_URL?.replace(/\/+$/, "") || null}
primaryVtt={resolvePrimaryVtt(dirData.files.map((f) => f.name))}
vttProvenance={vttProvenance}
handling={config.handling}
diff --git a/umtool/app/api/browse/init/route.ts b/umtool/app/api/browse/init/route.ts
@@ -1,79 +0,0 @@
-import { mkdir, stat, writeFile } from "node:fs/promises";
-import path from "node:path";
-import { BROWSE_ROOT, isSegment, songDir } from "@/lib/browse";
-import { writeSpec } from "@/lib/spec";
-
-export const dynamic = "force-dynamic";
-
-// Start a new song.
-//
-// The tree has been made by hand five times, and each time something was left
-// out -- pokemon is the only one with a clips.csv, two have no vertical cut, and
-// three have plan/ directories whose contents nobody can now attribute. This
-// makes the shape once: the directories the scan expects, and a spec sheet with
-// the song's name in it, so the very first thing the UI shows is a sheet to
-// fill in rather than an empty page.
-//
-// It does NOT create empty cut files. A cut that does not exist must read as a
-// hole in the set -- that is the whole reason the cut list is fixed rather than
-// derived -- and a zero-byte wide.mp4 would read as a built one.
-
-const README = (id: string, title: string) => `# ${title}
-
-_New song, nothing built yet._
-
-Fill in \`spec.json\` with what this song is made of — the background video, any
-one-off sounds, the drum map, the vocal hooks, the arrangement. What is declared
-there is what the browse UI will offer to do.
-
-| file | |
-|---|---|
-| \`wide.mp4\` | not built |
-| \`wide-short.mp4\` | not built |
-| \`vertical.mp4\` | not built |
-| \`vertical-short.mp4\` | not built |
-`;
-
-export async function POST(request: Request) {
- let body: { song?: string; title?: string };
- try {
- body = await request.json();
- } catch {
- return Response.json({ error: "bad json" }, { status: 400 });
- }
-
- const id = (body.song ?? "").trim().toLowerCase();
- if (!isSegment(id)) {
- return Response.json(
- { error: "a song name is letters, digits, dots, dashes and underscores — no slashes" },
- { status: 400 },
- );
- }
-
- const dir = songDir(id);
- // Containment check as well as the name check. songDir() joins under
- // BROWSE_ROOT, and isSegment() already rules out traversal, but a mkdir is
- // the first thing here that CREATES something and it gets both gates.
- if (dir !== path.join(BROWSE_ROOT, id)) {
- return Response.json({ error: "refusing to create outside the tree" }, { status: 400 });
- }
- if (
- await stat(dir).then(
- () => true,
- () => false,
- )
- ) {
- return Response.json({ error: `${id} already exists` }, { status: 409 });
- }
-
- const title = (body.title ?? "").trim() || id;
- await mkdir(path.join(dir, "plan"), { recursive: true });
- await mkdir(path.join(dir, "variants"), { recursive: true });
- await writeFile(path.join(dir, "README.md"), README(id, title));
- const spec = await writeSpec(id, { version: 1, song: id, title });
-
- return Response.json(
- { ok: true, song: id, spec, href: `/browse/${id}` },
- { headers: { "cache-control": "no-store" } },
- );
-}
diff --git a/umtool/app/api/doctor/route.ts b/umtool/app/api/doctor/route.ts
@@ -0,0 +1,19 @@
+import { probeNow, toolsCache } from "@/lib/doctor";
+
+export const dynamic = "force-dynamic";
+
+// GET is the cache, and only the cache: `{ checked: false }` when nothing has
+// been probed in the last ten minutes. POST probes. The split is the rule that
+// the index and the pages never shell out -- a probe is an action somebody took.
+export async function GET() {
+ const c = toolsCache();
+ return Response.json(
+ c ? { checked: true, ...c } : { checked: false },
+ { headers: { "cache-control": "no-store" } },
+ );
+}
+
+export async function POST() {
+ const r = await probeNow();
+ return Response.json({ checked: true, ...r }, { headers: { "cache-control": "no-store" } });
+}
diff --git a/umtool/app/api/jobs/route.ts b/umtool/app/api/jobs/route.ts
@@ -0,0 +1,15 @@
+import { listActivity, runningActivity } from "@/lib/activity";
+
+export const dynamic = "force-dynamic";
+
+// The dashboard's one poll. Both registries, one shape, no-store. The per-route
+// GETs (/api/report/build?job=, /api/mix/render?job=) are untouched: they carry
+// the log and the events, which this deliberately does not.
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const n = Math.max(1, Math.min(50, Number(url.searchParams.get("n") ?? 8) || 8));
+ return Response.json(
+ { running: runningActivity(), jobs: listActivity(n) },
+ { headers: { "cache-control": "no-store" } },
+ );
+}
diff --git a/umtool/app/api/projects/new/route.ts b/umtool/app/api/projects/new/route.ts
@@ -0,0 +1,22 @@
+import { ScaffoldError, scaffoldProject } from "@/lib/projects/scaffold";
+
+export const dynamic = "force-dynamic";
+
+// One "new project" door, whatever the kind. The body names the kind and what
+// that kind's scaffold asks for; the dispatch lives in lib/projects/scaffold.ts
+// so this file never branches on a kind id. Supersedes /api/browse/init.
+export async function POST(request: Request) {
+ let body: Record<string, unknown>;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "bad json" }, { status: 400 });
+ }
+ try {
+ const r = await scaffoldProject(body);
+ return Response.json({ ok: true, ...r }, { headers: { "cache-control": "no-store" } });
+ } catch (e) {
+ const status = e instanceof ScaffoldError ? e.status : 500;
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status });
+ }
+}
diff --git a/umtool/app/api/report/build/route.ts b/umtool/app/api/report/build/route.ts
@@ -4,6 +4,8 @@ import { cancelJob, getJob, jobView, recentJobs, runningJob, startJob } from "@/
import { PRESETS, buildSteps } from "@/lib/report/driver.mjs";
import { clipsOf, readManifest } from "@/lib/projects/report.mjs";
import { projectRef } from "@/lib/projects";
+import { recordBuildSnapshot } from "@/lib/report/snapshots.mjs";
+import { DEFAULT_VARIANT, VARIANTS, variantPaths } from "report-to-video/build-video";
export const dynamic = "force-dynamic";
@@ -33,6 +35,7 @@ export async function GET(request: Request) {
running: running ? jobView(running) : null,
jobs: recentJobs(5).map((j) => jobView(j, j.log.length, j.events.length)),
presets: Object.entries(PRESETS).map(([k, v]) => ({ id: k, label: v.label })),
+ variants: VARIANTS,
},
{ headers },
);
@@ -71,6 +74,31 @@ export async function POST(request: Request) {
);
}
+ // ---- the options the pipeline has and the driver used to hide ----------
+ const raw = (body.options ?? {}) as Record<string, unknown>;
+ const options: { variant?: string; xfade?: boolean; chaptersOnly?: boolean; preview?: { at: number; dur: number } | null } = {};
+ if (raw.variant !== undefined && raw.variant !== "") {
+ const v = String(raw.variant);
+ if (!(VARIANTS as string[]).includes(v)) {
+ return Response.json({ error: `variant must be one of ${VARIANTS.join(", ")}` }, { status: 400 });
+ }
+ options.variant = v;
+ }
+ if (raw.xfade === false) options.xfade = false;
+ if (raw.chaptersOnly) options.chaptersOnly = true;
+ if (raw.preview && typeof raw.preview === "object") {
+ const pv = raw.preview as Record<string, unknown>;
+ const at = Number(pv.at);
+ const dur = Number(pv.dur);
+ if (!Number.isFinite(at) || at < 0 || !Number.isFinite(dur) || dur <= 0) {
+ return Response.json({ error: "preview needs at ≥ 0 and dur > 0, in seconds" }, { status: 400 });
+ }
+ options.preview = { at, dur };
+ }
+ if (options.chaptersOnly && options.preview) {
+ return Response.json({ error: "chaptersOnly and preview are different runs — pick one" }, { status: 400 });
+ }
+
const project = await projectRef(projectId);
if (!project) return Response.json({ error: "no such project" }, { status: 404 });
const manifest = await readManifest(project.dir);
@@ -80,11 +108,12 @@ export async function POST(request: Request) {
}
const clipCount = clipsOf(manifest).length;
- const steps = buildSteps(project, { preset, only, skipFetch, clipCount });
+ const steps = buildSteps(project, { preset, only, skipFetch, clipCount, options });
const view = {
project: project.id,
preset,
only,
+ options,
steps: steps.map((s: { label: string; argv: string[]; cwd: string; timeoutMs?: number }) => ({
label: s.label,
argv: s.argv,
@@ -102,8 +131,13 @@ export async function POST(request: Request) {
// fetches must not be destroyed to make a new one, so an existing output that
// is NEWER than the manifest is refused; on ?replace=1 it is stamped aside
// rather than overwritten.
- const finalPath = path.join(project.dir, "out", `${manifest.slug}.mp4`);
- if (!only) {
+ //
+ // Through variantPaths(), so `full` guards `<slug>-full.mp4` and never trips
+ // on `sourced`'s file (or the other way round). A chapters-only run rewrites
+ // the same file in place by design, and a preview writes a different one, so
+ // neither is guarded.
+ const finalPath = variantPaths(path.join(project.dir, "out"), manifest.slug, options.variant ?? DEFAULT_VARIANT).final;
+ if (!only && !options.chaptersOnly && !options.preview) {
const [fin, man] = await Promise.all([
stat(finalPath).catch(() => null),
stat(path.join(project.dir, "video.manifest.json")).catch(() => null),
@@ -120,7 +154,12 @@ export async function POST(request: Request) {
{ status: 409 },
);
}
- await rename(finalPath, finalPath.replace(/\.mp4$/, `.${stamp()}.mp4`));
+ const aside = finalPath.replace(/\.mp4$/, `.${stamp()}.mp4`);
+ await rename(finalPath, aside);
+ // The manifest that produced the file it just moved aside, kept beside
+ // it as a snapshot -- so a stamped deliverable and its manifest stay
+ // paired instead of the file outliving the description of it.
+ await recordBuildSnapshot(project.dir, path.basename(aside)).catch(() => null);
}
}
@@ -133,7 +172,7 @@ export async function POST(request: Request) {
}
try {
- const job = startJob(`build ${project.id} (${preset})`, steps);
+ const job = startJob(`build ${project.id} (${preset})`, steps, { project: project.id });
return Response.json({ ok: true, ...view, job: jobView(job) }, { headers: { "cache-control": "no-store" } });
} catch (e) {
return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 409 });
diff --git a/umtool/app/api/report/check-sources/route.ts b/umtool/app/api/report/check-sources/route.ts
@@ -0,0 +1,51 @@
+import { stat } from "node:fs/promises";
+import path from "node:path";
+import { runningJob, jobView, startJob } from "@/lib/jobs";
+import { projectRef } from "@/lib/projects";
+import { MANIFEST_NAME } from "@/lib/projects/report.mjs";
+import { checkSourcesSteps } from "@/lib/report/driver.mjs";
+
+export const dynamic = "force-dynamic";
+
+// Re-check sources across projects: one check-availability step per report
+// project, through the same job runner as a build -- so it serialises with
+// builds, is cancellable, and writes each project's out/availability.json with
+// the same script the preflight uses.
+//
+// The body names PROJECT IDS. Never a path, never an argv.
+export async function POST(request: Request) {
+ const url = new URL(request.url);
+ const dry = url.searchParams.get("dry") === "1";
+ const body = (await request.json().catch(() => ({}))) as { projects?: unknown };
+ const ids = Array.isArray(body.projects) ? body.projects.map(String) : [];
+ if (!ids.length) return Response.json({ error: "projects is required" }, { status: 400 });
+
+ const refs = [];
+ for (const id of ids) {
+ const p = await projectRef(id);
+ if (!p) return Response.json({ error: `no such project: ${id}` }, { status: 404 });
+ // Only a project WITH A MANIFEST has sources to preflight. Asking the disk
+ // rather than the registry keeps this file from naming a kind.
+ if (!(await stat(path.join(p.dir, MANIFEST_NAME)).then((s) => s.isFile(), () => false))) {
+ return Response.json({ error: `${id} has no ${MANIFEST_NAME} to check` }, { status: 400 });
+ }
+ refs.push(p);
+ }
+
+ const steps = checkSourcesSteps(refs);
+ const view = { projects: refs.map((p) => p.id), steps: steps.map((s) => ({ label: s.label, argv: s.argv, cwd: s.cwd, timeoutMs: s.timeoutMs ?? null })) };
+ if (dry) return Response.json({ dry: true, ...view }, { headers: { "cache-control": "no-store" } });
+
+ const running = runningJob();
+ if (running) {
+ return Response.json({ error: `a job is already running (${running.kind})`, running: jobView(running) }, { status: 409 });
+ }
+ try {
+ const job = startJob(`check-sources (${refs.length} project${refs.length === 1 ? "" : "s"})`, steps, {
+ project: refs.length === 1 ? refs[0].id : null,
+ });
+ return Response.json({ ok: true, ...view, job: jobView(job) }, { headers: { "cache-control": "no-store" } });
+ } catch (e) {
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 409 });
+ }
+}
diff --git a/umtool/app/api/report/export/route.ts b/umtool/app/api/report/export/route.ts
@@ -0,0 +1,29 @@
+import { projectRef } from "@/lib/projects";
+import { EXPORT_FORMATS, exportProject } from "@/lib/report/export.mjs";
+
+export const dynamic = "force-dynamic";
+
+// GET ?project=<id>&format=toc-bbcode|toc-markdown|description|chapters[&variant=]
+// Plain text, so CopyButton can fetch it straight onto the clipboard.
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const project = await projectRef(url.searchParams.get("project") ?? "");
+ if (!project) return new Response("no such project", { status: 404 });
+ const format = url.searchParams.get("format") ?? "";
+ if (!EXPORT_FORMATS.includes(format)) {
+ return new Response(`format must be one of ${EXPORT_FORMATS.join(", ")}`, { status: 400 });
+ }
+ const variant = url.searchParams.get("variant") || undefined;
+ try {
+ const r = await exportProject(project.dir, format, variant ? { variant } : {});
+ return new Response(r.text, {
+ headers: {
+ "content-type": "text/plain; charset=utf-8",
+ "cache-control": "no-store",
+ "x-offsets": r.offsets.source,
+ },
+ });
+ } catch (e) {
+ return new Response(e instanceof Error ? e.message : String(e), { status: 409 });
+ }
+}
diff --git a/umtool/app/api/report/fetch/route.ts b/umtool/app/api/report/fetch/route.ts
@@ -33,7 +33,7 @@ export async function POST(request: Request) {
);
}
- const job = startJob(`fetch ${projectId}/${clipId}`, fetchSteps(r.project, clipId, pad));
+ const job = startJob(`fetch ${projectId}/${clipId}`, fetchSteps(r.project, clipId, pad), { project: r.project.id });
return Response.json({ job: jobView(job) }, { status: 202 });
}
diff --git a/umtool/app/api/report/snapshot/route.ts b/umtool/app/api/report/snapshot/route.ts
@@ -0,0 +1,39 @@
+import { invalidateProjects, projectRef } from "@/lib/projects";
+import { readManifest } from "@/lib/projects/report.mjs";
+import { diffManifests } from "@/lib/report/manifest-diff.mjs";
+import { createSnapshot, listSnapshots, readSnapshot } from "@/lib/report/snapshots.mjs";
+
+export const dynamic = "force-dynamic";
+
+// GET ?project=<id> every snapshot, newest first
+// GET ?project=<id>&diff=<rel> that snapshot diffed against the current manifest
+// POST { project, label? } copy the manifest into revisions/
+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 headers = { "cache-control": "no-store" };
+ const rel = url.searchParams.get("diff");
+ if (rel) {
+ try {
+ const [before, after] = await Promise.all([readSnapshot(project.dir, rel), readManifest(project.dir)]);
+ return Response.json({ snapshot: rel, ...diffManifests(before, after) }, { headers });
+ } catch (e) {
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
+ }
+ }
+ return Response.json({ snapshots: await listSnapshots(project.dir) }, { headers });
+}
+
+export async function POST(request: Request) {
+ const body = (await request.json().catch(() => ({}))) as { project?: string; label?: string | null };
+ const project = await projectRef(String(body.project ?? ""));
+ if (!project) return Response.json({ error: "no such project" }, { status: 404 });
+ try {
+ const r = await createSnapshot(project.dir, { label: body.label ? String(body.label) : null });
+ invalidateProjects();
+ 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/app/browse/decisions/page.tsx b/umtool/app/browse/decisions/page.tsx
@@ -69,7 +69,7 @@ export default async function DecisionsPage({
<div className="flex h-full flex-col">
<BrowseHeader
active="decisions"
- crumbs={[{ href: "/browse", label: "songs" }, { label: "decisions" }]}
+ crumbs={[{ href: "/browse", label: "projects" }, { label: "decisions" }]}
note={`${openCount(all)} waiting · ${all.length - openCount(all)} for information`}
/>
@@ -96,7 +96,7 @@ export default async function DecisionsPage({
<div className="ml-auto">
<CopyButton
label="copy the whole picture"
- title="every song as markdown — including this list"
+ title="every project as markdown — including this list"
url="/api/browse/context"
/>
</div>
diff --git a/umtool/app/browse/page.tsx b/umtool/app/browse/page.tsx
@@ -1,11 +1,12 @@
import Link from "next/link";
import { badgeVariants, type BadgeVariants } from "@/components/ui/badge";
import BrowseHeader from "@/components/BrowseHeader";
-import NewSongForm from "@/components/NewSongForm";
+import NewProjectMenu from "@/components/NewProjectMenu";
import CopyButton from "@/components/CopyButton";
import ProjectGrid from "@/components/projects/ProjectGrid";
import { KINDS, decisionCounts, indexHealth, listFolders, listProjects } from "@/lib/projects";
import { PROJECT_STATES } from "@/lib/project-types";
+import { projectsNote } from "@/lib/dashboard";
export const dynamic = "force-dynamic";
@@ -93,7 +94,7 @@ export default async function BrowsePage({
<div className="flex h-full flex-col">
<BrowseHeader
crumbs={[{ label: "projects" }]}
- note={`${all.length} projects · ${blocking} blocking · ${openN} open`}
+ note={projectsNote(all.length, blocking, openN)}
/>
<main className="deck-main flex-1 p-4">
{/* --- filters, as links ---------------------------------------- */}
@@ -171,7 +172,7 @@ export default async function BrowsePage({
)}
</form>
- <NewSongForm />
+ <NewProjectMenu kinds={KINDS.map((k) => ({ id: k.id, label: k.label, scaffold: k.scaffold }))} />
<div className="ml-auto flex items-center gap-2">
<Link
href="/browse/decisions"
@@ -181,7 +182,7 @@ export default async function BrowsePage({
</Link>
<CopyButton
label="copy the whole picture"
- title="every song as markdown — spec, cuts, verdicts, notes and resolved marks"
+ title="every project as markdown — spec, cuts, verdicts, notes and resolved marks"
url="/api/browse/context"
className="rounded border border-[var(--color-sel)] px-2.5 py-1 text-[12px] text-[var(--color-sel)] hover:bg-[color-mix(in_srgb,var(--color-sel)_14%,transparent)]"
/>
diff --git a/umtool/app/layout.tsx b/umtool/app/layout.tsx
@@ -3,8 +3,8 @@ import "./globals.css";
import { LastPageRecorder } from "@/components/LastPage";
export const metadata: Metadata = {
- title: "um triage",
- description: "Judge mined filler-sound candidates against the full episode audio.",
+ title: "umtool",
+ description: "Report videos, songs and sweeps: what is running, what is waiting, what is built.",
};
// Deliberately no loading.tsx anywhere in this app. In this Next it turns
diff --git a/umtool/app/page.tsx b/umtool/app/page.tsx
@@ -1,22 +1,90 @@
-import { LastPageRestore } from "@/components/LastPage";
+import { statfs } from "node:fs/promises";
+import AppNav from "@/components/AppNav";
+import DecisionsPanel from "@/components/dashboard/DecisionsPanel";
+import DeliverablesPanel from "@/components/dashboard/DeliverablesPanel";
+import NowPanel from "@/components/dashboard/NowPanel";
+import Panel from "@/components/dashboard/Panel";
+import ResumeChip from "@/components/dashboard/ResumeChip";
+import SourcesPanel from "@/components/dashboard/SourcesPanel";
+import StateStrip from "@/components/dashboard/StateStrip";
+import ToolsPanel from "@/components/dashboard/ToolsPanel";
+import NewProjectMenu from "@/components/NewProjectMenu";
+import ProjectGrid from "@/components/projects/ProjectGrid";
+import { listActivity, runningActivity } from "@/lib/activity";
+import { projectsNote, sourceBuckets, stateRows } from "@/lib/dashboard";
+import { toolsCache } from "@/lib/doctor";
+import { REPORTS_ROOT } from "@/lib/paths";
+import { KINDS, decisionCounts, indexHealth, listProjects, openDecisions } from "@/lib/projects";
+
+export const dynamic = "force-dynamic";
+
+// ---------------------------------------------------------------------------
+// The front door.
+//
+// It used to be a doorway that put you back in the song pile you last judged.
+// It is a page now: what is running, what is waiting on you, where every
+// project stands, which sources nobody has checked, what has been built, and
+// whether the machine can build at all. Everything on it is read from the
+// index and the two job registries; NOTHING on it shells out -- the tools
+// panel shows a cache or "not checked", and probing is a button.
+// ---------------------------------------------------------------------------
+
+async function freeBytesUnder(dir: string): Promise<number | null> {
+ try {
+ const s = await statfs(dir);
+ return Number(s.bavail) * Number(s.bsize);
+ } catch {
+ return null;
+ }
+}
+
+export default async function Home({
+ searchParams,
+}: {
+ searchParams: Promise<{ from?: string }>;
+}) {
+ const { from } = await searchParams;
+ const projects = await listProjects();
+ const counts = await decisionCounts();
+ const decisions = await openDecisions();
+ const running = runningActivity();
+ const recent = listActivity(8);
+ const tools = toolsCache();
+ const ix = indexHealth();
+ const free = await freeBytesUnder(REPORTS_ROOT);
+
+ const blocking = [...counts.values()].reduce((n, c) => n + c.blocking, 0);
+ const openN = [...counts.values()].reduce((n, c) => n + c.open, 0);
+ const buckets = sourceBuckets(projects);
+ const newest = [...projects].sort((a, b) => b.newestMtimeMs - a.newestMtimeMs).slice(0, 6);
-// The base URL is a doorway, not a page. It used to be redirect("/sort"), which
-// dropped you in the sort pile whatever you were last doing -- and a server
-// redirect fires before any client code, so nothing could ever read where you
-// had been. The decision moves to the client; the redirect itself has not.
-export default function Home() {
return (
- <>
- {/* With JS off there is no storage to read, so keep the old behaviour.
- Written as raw HTML on purpose: React 19 hoists <meta> it parses into
- <head>, and a refresh tag hoisted OUT of the <noscript> would fire for
- everyone and beat the restore to it. */}
- <noscript
- dangerouslySetInnerHTML={{
- __html: '<meta http-equiv="refresh" content="0;url=/sort">',
- }}
- />
- <LastPageRestore />
- </>
+ <div className="flex h-full flex-col">
+ <header className="flex flex-wrap items-center gap-3 border-b border-[var(--color-line)] bg-[var(--color-panel)] px-4 py-2">
+ <AppNav active="home" />
+ <ResumeChip />
+ <div className="ml-auto flex items-center gap-2">
+ <NewProjectMenu kinds={KINDS.map((k) => ({ id: k.id, label: k.label, scaffold: k.scaffold }))} from={from ?? null} />
+ </div>
+ </header>
+
+ <main className="deck-main flex-1 p-4">
+ <div className="grid gap-3 lg:grid-cols-2 2xl:grid-cols-3">
+ <NowPanel running={running} recent={recent} />
+ <DecisionsPanel decisions={decisions} />
+ <StateStrip
+ rows={stateRows(KINDS, projects)}
+ note={projectsNote(projects.length, blocking, openN)}
+ />
+ <SourcesPanel never={buckets.never} old={buckets.old} bad={buckets.bad} />
+ <DeliverablesPanel projects={projects} />
+ <ToolsPanel initial={tools} freeBytes={free} index={ix} />
+ </div>
+
+ <Panel title="recent" testid="recent" more={{ href: "/browse", label: "every project →" }} className="mt-3">
+ <ProjectGrid projects={newest} counts={counts} />
+ </Panel>
+ </main>
+ </div>
);
}
diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs
@@ -20,7 +20,13 @@
// umtool window <project> <clip> [--start S] [--end E] [--lock] [--lock-end] ...
// umtool build <project> [--preset preview|fast|final] [--only ID] [--dry]
// umtool index [--rebuild] [--prune] [--since MS] [--json]
-// umtool new <slug> [--kind report-video] [--from <sweep-report.md>]
+// umtool new <slug> [--kind report-video] [--from <report.md>|<share URL>|<channel>/<id>]
+// [--site-origin URL] [--seed chapters]
+// umtool doctor [--json] exit 1 if the report pipeline is missing a tool
+// umtool snapshot <project> [--label L] copy the manifest into revisions/
+// umtool diff <project> <snapshot> what changed since that snapshot
+// umtool export <project> --format toc-bbcode|toc-markdown|description|chapters [--variant V]
+// umtool check-sources [<project>…] prints the re-check chain
import process from "node:process";
import {
PROJECT_KINDS,
@@ -32,12 +38,16 @@ import {
resolveProject,
summarise,
} from "../lib/projects/core.mjs";
-import { readClipDetail, readManifest } from "../lib/projects/report.mjs";
+import { readClipDetail, readManifest, sourcesOf } from "../lib/projects/report.mjs";
+import { createSnapshot, listSnapshots, readSnapshot } from "../lib/report/snapshots.mjs";
+import { diffManifests, formatChange } from "../lib/report/manifest-diff.mjs";
+import { EXPORT_FORMATS, exportProject } from "../lib/report/export.mjs";
import path from "node:path";
-import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
import { updateClip } from "../lib/report/manifest.mjs";
-import { buildSteps, PRESETS } from "../lib/report/driver.mjs";
+import { buildSteps, checkSourcesSteps, PRESETS } from "../lib/report/driver.mjs";
import { openIndex, signRecord } from "../lib/projects/index-db.mjs";
+import { probeTools } from "../lib/tools.mjs";
+import { scaffoldReportVideo } from "../lib/projects/scaffold.mjs";
const argv = process.argv.slice(2);
const cmd = argv.find((a) => !a.startsWith("-")) ?? "help";
@@ -158,7 +168,11 @@ async function cmdShow() {
const ds = await decisionsFor(p);
const detail = p.kind === "report-video" ? await readClipDetail(p.dir) : null;
- if (json) return out({ ...s, decisions: ds, entries: detail?.entries ?? null });
+ if (json) {
+ const src = detail ? await sourcesOf(p.dir, { manifest: detail.manifest }) : null;
+ const snapshots = detail ? await listSnapshots(p.dir) : null;
+ return out({ ...s, decisions: ds, entries: detail?.entries ?? null, sources: src?.rows ?? null, snapshots });
+ }
console.log(`${s.title}`);
console.log(`${p.id} [${p.kind} · ${p.template}] ${s.state}`);
@@ -200,6 +214,29 @@ async function cmdShow() {
}
}
+ if (detail) {
+ const src = await sourcesOf(p.dir, { manifest: detail.manifest });
+ console.log(`\nsources (${src.rows.length}):`);
+ const w = Math.max(10, ...src.rows.map((r) => r.key.length));
+ for (const r of src.rows) {
+ const a = r.availability;
+ const marks = [
+ r.cues === "missing" ? "NO CUES" : r.cues === "no-punctuation" ? "no-punctuation" : "",
+ r.coverageGap ? `CUE GAP: cues end ${r.coverageGap.cuesEnd.toFixed(0)} s · ${r.coverageGap.clip} needs ${r.coverageGap.needs.toFixed(0)} s` : "",
+ a ? (a.ok ? `ok ${ago(a.checkedAtMs)} ago` : `${a.state.toUpperCase()} ${ago(a.checkedAtMs)} ago`) : "never checked",
+ r.cite.differs ? "citeUrl override" : "",
+ ].filter(Boolean);
+ console.log(` ${r.key.padEnd(w)} ${r.clips.join(",").padEnd(12)} ${marks.join(" · ")}`);
+ }
+ const snaps = await listSnapshots(p.dir);
+ if (snaps.length) {
+ console.log(`\nsnapshots (${snaps.length}):`);
+ for (const sn of snaps) {
+ console.log(` ${sn.rel.padEnd(44)} ${ago(sn.mtimeMs).padStart(4)} ago ${sn.legacy ? "legacy" : ""}${sn.label ? ` [${sn.label}]` : ""}`);
+ }
+ }
+ }
+
if (ds.length) {
console.log("");
for (const d of sortDecisions(ds)) {
@@ -281,6 +318,106 @@ function cmdKinds() {
}
}
+async function cmdDoctor() {
+ // The one command that shells out on purpose. Seven version flags, ~100 ms.
+ const r = await probeTools();
+ if (json) {
+ out(r);
+ } else {
+ for (const t of r.tools) {
+ const mark = t.present ? "ok " : t.required ? "MISSING" : "absent";
+ console.log(
+ `${mark.padEnd(8)} ${t.id.padEnd(13)} ${(t.version ?? "").padEnd(14)} ${t.neededBy.join(", ")}` +
+ (t.error ? `\n ${t.error}` : ""),
+ );
+ }
+ console.log(
+ r.ok
+ ? "\nthe report pipeline can build here"
+ : "\nthe report pipeline is MISSING a tool it cannot run without",
+ );
+ }
+ process.exit(r.ok ? 0 : 1);
+}
+
+async function cmdSnapshot() {
+ const p = await pick(positional[0]);
+ try {
+ const r = await createSnapshot(p.dir, { label: val("--label") ?? null });
+ if (json) return out({ ok: true, ...r });
+ console.log(`${p.id}: ${r.rel}`);
+ } catch (e) {
+ die(e?.message ?? String(e));
+ }
+}
+
+async function cmdDiff() {
+ const p = await pick(positional[0]);
+ const rel = positional[1];
+ if (!rel) {
+ const snaps = await listSnapshots(p.dir);
+ if (!snaps.length) die(`${p.id} has no snapshots — \`umtool snapshot ${p.id}\` makes one`);
+ die(`which snapshot?\n${snaps.map((sn) => ` ${sn.rel}`).join("\n")}`);
+ }
+ let before;
+ try {
+ before = await readSnapshot(p.dir, rel);
+ } catch (e) {
+ die(e?.message ?? String(e));
+ }
+ const after = await readManifest(p.dir);
+ const d = diffManifests(before, after);
+ if (json) return out({ project: p.id, snapshot: rel, ...d });
+ console.log(`${rel} -> video.manifest.json (${d.entriesBefore} -> ${d.entriesAfter} entries)`);
+ if (d.same) return console.log(" no change to the timeline");
+ for (const c of d.changes) console.log(` ${formatChange(c)}`);
+ console.log(
+ `\n${Object.entries(d.counts).map(([k, n]) => `${n} ${k}`).join(" · ")}`,
+ );
+}
+
+async function cmdExport() {
+ const p = await pick(positional[0]);
+ const format = val("--format");
+ if (!format) die(`--format is one of ${EXPORT_FORMATS.join(", ")}`);
+ try {
+ const r = await exportProject(p.dir, format, val("--variant") ? { variant: val("--variant") } : {});
+ if (json) return out(r);
+ process.stdout.write(r.text);
+ if (r.offsets.note) console.error(`# ${r.offsets.note}`);
+ } catch (e) {
+ die(e?.message ?? String(e));
+ }
+}
+
+async function cmdCheckSources() {
+ // Print, never run -- the same posture as `build`. With no arguments, the
+ // projects the dashboard would re-check: never checked, or older than 30 d.
+ let refs;
+ if (positional.length) {
+ refs = [];
+ for (const a of positional) refs.push(await pick(a));
+ } else {
+ const all = await summaries();
+ const STALE = 30 * 86_400_000;
+ refs = all.filter((p) => {
+ const a = p.attrs ?? {};
+ if (!("sources" in a) || Number(a.sources) === 0) return false;
+ const at = Number(a["sources-checked-at"] ?? 0);
+ return !at || Date.now() - at > STALE;
+ });
+ if (!refs.length) return console.log("every source was checked in the last 30 days");
+ }
+ const steps = checkSourcesSteps(refs);
+ if (json) return out({ projects: refs.map((p) => p.id), steps });
+ console.log(`# re-check sources of ${refs.length} project(s)`);
+ for (const s of steps) {
+ console.log(`# ${s.label}`);
+ console.log(`(cd ${s.cwd} && ${s.argv.join(" ")})\n`);
+ }
+ console.log("# Nothing was run. The dashboard's Sources panel runs this chain through the job runner.");
+}
+
function usage() {
console.log(
[
@@ -296,7 +433,12 @@ function usage() {
" umtool window <project> <clip> [--start S] [--end E] [--lock|--lock-end|…]",
" umtool build <project> [--preset preview|fast|final] [--only ID]",
" umtool index [--rebuild] [--prune] [--since MS] [--json]",
- " umtool new <slug> [--kind report-video] [--from <sweep-report.md>]",
+ " umtool new <slug> [--from <report.md>|<share URL>|<channel>/<id>] [--site-origin URL] [--seed chapters]",
+ " umtool doctor [--json] exit 1 if the report pipeline is missing a tool",
+ " umtool snapshot <project> [--label L] copy the manifest into revisions/",
+ " umtool diff <project> <snapshot> what changed since that snapshot",
+ " umtool export <project> --format toc-bbcode|toc-markdown|description|chapters [--variant V]",
+ " umtool check-sources [<project>…] prints the re-check chain (never-checked/old when no args)",
"",
`reading ${REPORTS_ROOT} (set REPORTS_DIR to move it)`,
"",
@@ -317,6 +459,11 @@ const COMMANDS = {
decisions: cmdDecisions,
folders: cmdFolders,
kinds: cmdKinds,
+ doctor: cmdDoctor,
+ snapshot: cmdSnapshot,
+ diff: cmdDiff,
+ export: cmdExport,
+ "check-sources": cmdCheckSources,
help: usage,
};
@@ -460,134 +607,54 @@ async function cmdIndex() {
// ---------------------------------------------------------------------------
-// Scaffolding.
+// Scaffolding. The writer is lib/projects/scaffold.mjs, shared with the route.
// ---------------------------------------------------------------------------
-/**
- * A manifest skeleton, and deliberately an EMPTY timeline.
- *
- * It would be easy to derive first-draft clips from a report's citations: the
- * shape is regular (`> "quote"` then `— [title @ h:mm:ss](…?v=slug%2Fid&t=sec)`).
- * It is not done, and that is the honest position rather than a missing feature.
- * A report records ONE second per citation; a window needs a start AND an end
- * taken from transcript.cues.json, and matching a quote to its cues is the
- * actual work of authoring a cut. A generated timeline of guessed windows would
- * look finished and be wrong, and every clip would have to be opened anyway.
- *
- * So this writes what can be known -- the slug, the origin, the channel, the
- * render block -- lists the citations it found as a checklist, and says what to
- * do next.
- */
-function skeleton(slug, title, provenance) {
- return {
- schemaVersion: 1,
- slug,
- title,
- subtitle: "",
- generatedOn: new Date().toISOString().slice(0, 10),
- provenance: {
- // The field that shipped broken TWICE. It is first, and it is empty rather
- // than plausible, so `umtool check` blocks until somebody sets it.
- siteOrigin: "",
- channelSlug: "",
- channel: "",
- ...provenance,
- },
- render: {
- width: 1920,
- height: 1080,
- fps: 30,
- audioRate: 48000,
- audioChannels: 2,
- maxHeightSource: 1080,
- fontRegular: "/usr/share/fonts/TTF/FiraSans-Regular.ttf",
- fontBold: "/usr/share/fonts/TTF/FiraSans-Bold.ttf",
- palette: { bg: "#12100c", fg: "#f6f1e6", muted: "#a2957f", accent: "#c8752a", amber: "#ffc860" },
- transition: 0.4,
- fetchPad: 3,
- snapWindow: 1.6,
- silenceMinDur: 0.09,
- silenceRelDb: 6,
- headerHeight: 56,
- footerHeight: 0,
- crf: 21,
- preset: "slow",
- qr: { scale: 4, quiet: 3, ecc: "M", margin: 28 },
- },
- timelineNodes: [],
- timeline: [],
- };
-}
-
async function cmdNew() {
const slug = positional[0];
- if (!slug) die("usage: umtool new <slug> [--kind report-video] [--from <sweep-report.md>]");
- if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
- die(`"${slug}" will not route — use lower-case letters, digits and dashes`);
+ if (!slug) {
+ die(
+ "usage: umtool new <slug> [--kind report-video] [--title T]\n" +
+ " [--from <sweep-report.md> | <share URL> | <channel>/<videoId>]\n" +
+ " [--site-origin <url>] [--seed chapters]",
+ );
}
const kind = val("--kind") ?? "report-video";
- if (kind !== "report-video") die(`only report-video can be scaffolded so far, not ${kind}`);
-
- const dir = path.join(REPORTS_ROOT, slug);
- if (await stat(dir).then(() => true, () => false)) die(`${dir} already exists`);
-
- let title = slug.replace(/-/g, " ");
- const citations = [];
- const fromArg = val("--from");
- if (fromArg) {
- const text = await readFile(fromArg, "utf8").catch(() => null);
- if (text === null) die(`could not read ${fromArg}`);
- title = text.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? title;
- // `?v=<channel>%2F<id>&t=<sec>` -- the shape the viewer's share links use.
- for (const m of text.matchAll(/\]\([^)]*[?&]v=([^&)]+)&t=(\d+)/g)) {
- const [chan, id] = decodeURIComponent(m[1]).split("/");
- citations.push({ channel: chan, video: id, second: Number(m[2]) });
- }
- }
+ if (kind !== "report-video") die(`only report-video can be scaffolded from here, not ${kind}`);
- const channels = [...new Set(citations.map((c) => c.channel))];
- const doc = skeleton(slug, title, channels.length === 1 ? { channelSlug: channels[0] } : {});
+ let r;
+ try {
+ r = await scaffoldReportVideo({
+ root: REPORTS_ROOT,
+ slug,
+ title: val("--title"),
+ from: val("--from") ?? null,
+ siteOrigin: val("--site-origin") ?? null,
+ seed: val("--seed") ?? null,
+ });
+ } catch (e) {
+ die(e?.message ?? String(e));
+ }
- await mkdir(dir, { recursive: true });
- await writeFile(path.join(dir, "video.manifest.json"), JSON.stringify(doc, null, 2) + "\n", "utf8");
+ if (json) return out({ ok: true, ...r });
- if (fromArg) {
- await writeFile(
- path.join(dir, "sweep-report.md"),
- await readFile(fromArg, "utf8"),
- "utf8",
- );
+ console.log(`${r.dir}`);
+ if (r.seeded) {
+ console.log(` video.manifest.json ${r.seeded} clip(s) SEEDED from digest chapters — review each`);
+ } else {
+ console.log(` video.manifest.json an EMPTY timeline — see README.md`);
}
-
- const lines = [
- `# ${title}`,
- "",
- "What this cut argues, which sources it draws on, and anything cut short on",
- "purpose (with why — that is what a `lock` in the manifest means).",
- "",
- "## Windows still to write",
- "",
- citations.length
- ? "Each of these is ONE second from the report. A clip needs a start AND an" +
- " end, read from the source's transcript.cues.json — that matching is the work."
- : "No `?v=` citations were found, so there is nothing to work from yet.",
- "",
- ...citations.map(
- (c, i) => `- [ ] c${String(i).padStart(2, "0")} ${c.channel}/${c.video} @ ${c.second}s`,
- ),
- ];
- await writeFile(path.join(dir, "README.md"), lines.join("\n") + "\n", "utf8");
-
- if (json) return out({ ok: true, dir, citations: citations.length, channels });
-
- console.log(`${dir}`);
- console.log(` video.manifest.json an EMPTY timeline — see README.md`);
- if (fromArg) console.log(` sweep-report.md copied from ${fromArg}`);
- console.log(` README.md ${citations.length} citation(s) as a checklist`);
+ if (r.from?.kind === "report") console.log(` sweep-report.md copied from ${r.from.path}`);
+ console.log(` README.md ${r.seeded ? "the seeded clips" : `${r.citations} citation(s)`} as a checklist`);
+ for (const n of r.notes) console.log(` NOTE: ${n}`);
console.log("");
console.log("Next, in order:");
- console.log(` 1. set provenance.siteOrigin — it is EMPTY, and \`check\` blocks until it is not.`);
- console.log(` Two finished videos shipped with QR codes that resolve to nothing.`);
+ if (r.siteOrigin) {
+ console.log(` 1. provenance.siteOrigin is ${r.siteOrigin} — check it is the archive people will scan.`);
+ } else {
+ console.log(` 1. set provenance.siteOrigin — it is EMPTY, and \`check\` blocks until it is not.`);
+ console.log(` Two finished videos shipped with QR codes that resolve to nothing.`);
+ }
console.log(` 2. write the timeline (umtool/docs/authoring.md)`);
console.log(` 3. umtool check ${slug}`);
console.log(` 4. umtool build ${slug} --preset fast`);
diff --git a/umtool/components/AppNav.tsx b/umtool/components/AppNav.tsx
@@ -1,31 +1,43 @@
import Link from "next/link";
import { MODES } from "@/lib/types";
+import NavGroup from "./NavGroup";
-// The four judging piles, plus the bench that assembles what they produce.
+// The places, in the order somebody works: the dashboard, every project, what
+// is waiting, the two benches that are not a project (mix, find), and the song
+// piles folded under one entry.
//
-// The active link used to be `--color-accent`, a second green sitting two
-// inches from `--color-good` on a verdict button -- so a NAVIGATION state wore
-// a VERDICT's colour. It is `sel` now, the one interaction colour, and accent
-// is gone rather than retuned.
+// SEVEN visible entries, and the cap is still NINE. A tenth wraps the header on
+// a laptop, and a nav that wraps stops reading as one row of places and starts
+// reading as a list. The next tool goes UNDER one of these, not beside them --
+// which is exactly what happened to the four judging piles and the sources
+// page: they are the `song ▸` group now, at the same URLs they always had, so
+// `/sort`, `/keeps` and every bookmark still land where they did.
+//
+// The active link is `sel`, the one interaction colour. It used to be
+// `--color-accent`, a second green two inches from `--color-good` on a verdict
+// button -- a NAVIGATION state wearing a VERDICT's colour -- and accent is gone
+// rather than retuned.
+const SONG_ITEMS = [
+ ...MODES.map((m) => ({ href: `/${m}`, label: m as string })),
+ // Where the palette comes from, across every song at once.
+ { href: "/browse/sources", label: "sources" },
+];
+
export default function AppNav({ active }: { active: string }) {
const items = [
- ...MODES.map((m) => ({ href: `/${m}`, label: m as string })),
- { href: "/mix", label: "mix" },
+ { href: "/", label: "home" },
{ href: "/browse", label: "browse" },
// The worklist across every project, not a sixth pile. It sits beside
// browse because that is where every decision it names gets settled.
{ href: "/browse/decisions", label: "decisions" },
- // Where the palette comes from, across every song at once.
- { href: "/browse/sources", label: "sources" },
+ { href: "/mix", label: "mix" },
// Every occurrence of a word across the corpus. It sits with browse because
// what it retrieves is raw material for a build, not a pile to judge.
{ href: "/browse/find", label: "find" },
];
- // NINE, and that is the limit. A tenth wraps the header on a laptop, and a
- // nav that wraps stops reading as one row of places and starts reading as a
- // list. The next tool goes UNDER one of these, not beside them.
+ const songActive = SONG_ITEMS.some((it) => it.label === active);
return (
- <nav className="flex gap-1">
+ <nav className="flex gap-1" aria-label="Main">
{items.map((it) => {
const on = it.label === active;
return (
@@ -43,6 +55,7 @@ export default function AppNav({ active }: { active: string }) {
</Link>
);
})}
+ <NavGroup label="song" items={SONG_ITEMS} active={active} on={songActive} />
</nav>
);
}
diff --git a/umtool/components/LastPage.tsx b/umtool/components/LastPage.tsx
@@ -1,11 +1,12 @@
"use client";
-import { useEffect, useState } from "react";
-import { usePathname, useRouter } from "next/navigation";
-import { DEFAULT_PAGE, LAST_PAGE_KEY, isRecordable, restoreCandidates } from "@/lib/last-page";
+import { useEffect } from "react";
+import { usePathname } from "next/navigation";
+import { LAST_PAGE_KEY, isRecordable } from "@/lib/last-page";
-// Two halves of one behaviour: the recorder rides in the root layout and writes
-// down where you are, and the restore renders at / and takes you back there.
+// The recorder rides in the root layout and writes down where you are. What
+// reads it back is components/dashboard/ResumeChip.tsx, which offers the URL as
+// a link on the dashboard -- / stopped being a doorway when it became a page.
//
// Neither uses useSearchParams(). That hook demands a Suspense boundary, and
// this app has none anywhere on purpose -- putting the first one in the root
@@ -47,85 +48,3 @@ export function LastPageRecorder() {
}, [pathname]);
return null;
}
-
-/**
- * Rendered by /. Finds the best live URL and replaces onto it.
- *
- * The candidates are probed against the REAL router with a HEAD, rather than
- * checked against a list of routes rebuilt here. A local re-implementation
- * would drift the first time a route moved, and it would have to re-derive
- * precedence this app leans on -- /mix beating [mode], /browse/decisions
- * sitting inside [song] territory. Asking the server costs one request and
- * cannot be wrong.
- *
- * replace(), never push(): / is a doorway, and it should not sit in the back
- * history of the page it opened.
- */
-export function LastPageRestore() {
- const router = useRouter();
- const [target, setTarget] = useState<string | null>(null);
-
- useEffect(() => {
- let done = false;
-
- const go = (url: string) => {
- if (done) return;
- setTarget(url);
- router.replace(url);
- };
-
- (async () => {
- let stored: string | null = null;
- try {
- stored = localStorage.getItem(LAST_PAGE_KEY);
- } catch {
- stored = null;
- }
- // Nothing remembered, or something that is not a page here: behave
- // exactly as the old redirect("/sort") did.
- if (!stored || !isRecordable(stored)) {
- go(DEFAULT_PAGE);
- return;
- }
-
- for (const url of restoreCandidates(stored)) {
- if (done) return;
- let alive = false;
- try {
- const res = await fetch(url, { method: "HEAD", cache: "no-store" });
- alive = res.status !== 404;
- } catch {
- alive = false;
- }
- if (alive) {
- go(url);
- return;
- }
- }
-
- // Every candidate down to /sort answered 404, which should not happen --
- // but a stored URL that can never resolve must not cost a probe on every
- // open, so it goes.
- try {
- localStorage.removeItem(LAST_PAGE_KEY);
- } catch {
- /* nothing to clean up */
- }
- go(DEFAULT_PAGE);
- })();
-
- return () => {
- done = true;
- };
- }, [router]);
-
- // A blank screen reads as broken; a slow probe should read as work. One line,
- // no chrome -- this page is visible for a few hundred milliseconds.
- return (
- <main className="flex h-full items-center justify-center p-4">
- <p className="micro" data-testid="last-page-restore">
- returning to {target ?? "where you were"}
- </p>
- </main>
- );
-}
diff --git a/umtool/components/NavGroup.tsx b/umtool/components/NavGroup.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useRef, useState } from "react";
+
+// One nav entry that opens into several. Click (or Enter/Space) to expand,
+// Escape or a click elsewhere to close; the items are real links, so the
+// keyboard reaches every one of them and the URLs are unchanged.
+//
+// Native <details> would do most of this with no JS, but it cannot close on an
+// outside click and it renders its own marker; this is small enough to own.
+export default function NavGroup({
+ label,
+ items,
+ active,
+ on,
+}: {
+ label: string;
+ items: { href: string; label: string }[];
+ active: string;
+ on: boolean;
+}) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const away = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ };
+ const key = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setOpen(false);
+ };
+ document.addEventListener("mousedown", away);
+ document.addEventListener("keydown", key);
+ return () => {
+ document.removeEventListener("mousedown", away);
+ document.removeEventListener("keydown", key);
+ };
+ }, [open]);
+
+ return (
+ <div ref={ref} className="relative" data-nav-group={label}>
+ <button
+ type="button"
+ aria-haspopup="menu"
+ aria-expanded={open}
+ aria-current={on ? "page" : undefined}
+ onClick={() => setOpen((v) => !v)}
+ className={`rounded px-2.5 py-1 text-sm ${
+ on
+ ? "bg-[var(--color-panel-2)] text-[var(--color-sel)]"
+ : "text-[var(--color-dim)] hover:text-[var(--color-text)]"
+ }`}
+ >
+ {label} <span aria-hidden="true">▸</span>
+ </button>
+ {open && (
+ <div
+ role="menu"
+ className="absolute left-0 top-full z-20 mt-1 flex min-w-32 flex-col rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-1 shadow-lg"
+ >
+ {items.map((it) => {
+ const lit = it.label === active;
+ return (
+ <Link
+ key={it.href}
+ href={it.href}
+ role="menuitem"
+ aria-current={lit ? "page" : undefined}
+ onClick={() => setOpen(false)}
+ className={`rounded px-2.5 py-1 text-sm ${
+ lit
+ ? "bg-[var(--color-panel-2)] text-[var(--color-sel)]"
+ : "text-[var(--color-dim)] hover:text-[var(--color-text)]"
+ }`}
+ >
+ {it.label}
+ </Link>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/umtool/components/NewProjectMenu.tsx b/umtool/components/NewProjectMenu.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+// Start a project that is not there yet -- of any kind the registry says can be
+// scaffolded. The kind select comes first; the fields under it are the ones
+// that kind DECLARES (`scaffold.fields` in the registry), so this file renders
+// from data and never branches on a kind id. `from` prefills the source field
+// when the dashboard was opened with ?from=<channel>/<id>.
+//
+// It creates no cut files and no clip windows it cannot know. What it writes is
+// a shape to fill in, which is the whole reason it exists: the five songs and
+// six report videos made by hand each came out slightly different.
+
+type Kind = { id: string; label: string; scaffold?: { fields: string[] } | null };
+
+const FIELD_LABEL: Record<string, string> = {
+ from: "from — a sweep-report .md, a viewer share URL, or <channel>/<videoId>",
+ siteOrigin: "site origin — the archive every QR resolves to (e.g. https://jeralyzer.pages.dev)",
+ seed: "seed the timeline from the video's digest chapters (review each)",
+};
+
+export default function NewProjectMenu({ kinds, from = null }: { kinds: Kind[]; from?: string | null }) {
+ const router = useRouter();
+ const can = kinds.filter((k) => k.scaffold);
+ const [open, setOpen] = useState(!!from);
+ const [kind, setKind] = useState(can[0]?.id ?? "");
+ const [slug, setSlug] = useState("");
+ const [title, setTitle] = useState("");
+ const [src, setSrc] = useState(from ?? "");
+ const [origin, setOrigin] = useState("");
+ const [seed, setSeed] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const fields = can.find((k) => k.id === kind)?.scaffold?.fields ?? [];
+ // Seeding only means something for a video ref, and the server refuses it
+ // otherwise -- so the box is offered only when the source looks like one.
+ const looksLikeVideo = /[?&]v=/.test(src) || /^[\w.-]+\/[\w.-]+$/.test(src.trim());
+
+ const create = async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ const body: Record<string, unknown> = { kind, slug, title };
+ if (fields.includes("from") && src.trim()) body.from = src.trim();
+ if (fields.includes("siteOrigin") && origin.trim()) body.siteOrigin = origin.trim();
+ if (fields.includes("seed") && seed && looksLikeVideo) body.seed = "chapters";
+ const res = await fetch("/api/projects/new", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ cache: "no-store",
+ });
+ const j = await res.json();
+ if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
+ router.push(j.href);
+ router.refresh();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ setBusy(false);
+ }
+ };
+
+ if (!can.length) return null;
+ if (!open) {
+ return (
+ <button
+ type="button"
+ onClick={() => setOpen(true)}
+ data-new-project
+ className="rounded border border-dashed border-[var(--color-line)] px-2.5 py-1 text-[12px] text-[var(--color-dim)] hover:border-[var(--color-sel)] hover:text-[var(--color-sel)]"
+ >
+ + new project
+ </button>
+ );
+ }
+
+ const input = "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)]";
+ const onEnter = (e: React.KeyboardEvent) => e.key === "Enter" && slug.trim() && create();
+
+ return (
+ <div
+ data-new-project-form
+ className="flex flex-wrap items-center gap-2 rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-2"
+ >
+ <select
+ value={kind}
+ onChange={(e) => setKind(e.target.value)}
+ aria-label="project kind"
+ data-new-kind
+ className="rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] px-2 py-1 text-[12px]"
+ >
+ {can.map((k) => (
+ <option key={k.id} value={k.id}>
+ {k.label}
+ </option>
+ ))}
+ </select>
+ <input
+ autoFocus
+ value={slug}
+ onChange={(e) => setSlug(e.target.value)}
+ onKeyDown={onEnter}
+ placeholder="directory name, e.g. donkey-kong"
+ aria-label="project directory name"
+ data-new-slug
+ className={`w-52 font-mono ${input}`}
+ />
+ <input
+ value={title}
+ onChange={(e) => setTitle(e.target.value)}
+ onKeyDown={onEnter}
+ placeholder="title (optional)"
+ aria-label="project title"
+ data-new-title
+ className={`w-56 ${input}`}
+ />
+ {fields.includes("from") && (
+ <input
+ value={src}
+ onChange={(e) => setSrc(e.target.value)}
+ onKeyDown={onEnter}
+ placeholder="from: report.md, share URL, or channel/videoId"
+ aria-label={FIELD_LABEL.from}
+ title={FIELD_LABEL.from}
+ data-new-from
+ className={`w-72 font-mono ${input}`}
+ />
+ )}
+ {fields.includes("siteOrigin") && (
+ <input
+ value={origin}
+ onChange={(e) => setOrigin(e.target.value)}
+ onKeyDown={onEnter}
+ placeholder="site origin, e.g. https://…pages.dev"
+ aria-label={FIELD_LABEL.siteOrigin}
+ title={FIELD_LABEL.siteOrigin}
+ data-new-origin
+ className={`w-56 font-mono ${input}`}
+ />
+ )}
+ {fields.includes("seed") && looksLikeVideo && (
+ <label className="flex items-center gap-1 text-[11px] text-[var(--color-dim)]" title={FIELD_LABEL.seed}>
+ <input type="checkbox" checked={seed} onChange={(e) => setSeed(e.target.checked)} data-new-seed />
+ seed from chapters
+ </label>
+ )}
+ <button
+ type="button"
+ disabled={busy || !slug.trim()}
+ onClick={create}
+ data-new-create
+ className="rounded border border-[var(--color-sel)] px-2.5 py-1 text-[12px] text-[var(--color-sel)] disabled:opacity-40"
+ >
+ create
+ </button>
+ <button
+ type="button"
+ onClick={() => {
+ setOpen(false);
+ setError(null);
+ }}
+ className="text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]"
+ >
+ cancel
+ </button>
+ {error && (
+ <span className="w-full text-[11px] text-[var(--color-bad)]" data-new-error>
+ {error}
+ </span>
+ )}
+ </div>
+ );
+}
diff --git a/umtool/components/NewSongForm.tsx b/umtool/components/NewSongForm.tsx
@@ -1,97 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-
-// Start a song that is not there yet.
-//
-// The five in this tree were each made by hand, and each one came out slightly
-// different -- one has a clips.csv, two have no vertical cut, three have plan/
-// directories nobody can now attribute. This makes the shape once, and lands
-// you on a spec sheet to fill in rather than on an empty directory.
-//
-// It creates no cut files. A cut that does not exist has to read as a hole in
-// the set, and a zero-byte wide.mp4 would read as a built one.
-
-export default function NewSongForm() {
- const router = useRouter();
- const [open, setOpen] = useState(false);
- const [id, setId] = useState("");
- const [title, setTitle] = useState("");
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState<string | null>(null);
-
- const create = async () => {
- setBusy(true);
- setError(null);
- try {
- const res = await fetch("/api/browse/init", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ song: id, title }),
- cache: "no-store",
- });
- const j = await res.json();
- if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
- router.push(j.href);
- router.refresh();
- } catch (e) {
- setError(e instanceof Error ? e.message : String(e));
- setBusy(false);
- }
- };
-
- if (!open) {
- return (
- <button
- type="button"
- onClick={() => setOpen(true)}
- data-new-song
- className="rounded border border-dashed border-[var(--color-line)] px-2.5 py-1 text-[12px] text-[var(--color-dim)] hover:border-[var(--color-sel)] hover:text-[var(--color-sel)]"
- >
- + new song
- </button>
- );
- }
-
- return (
- <div className="flex flex-wrap items-center gap-2 rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-2">
- <input
- autoFocus
- value={id}
- onChange={(e) => setId(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && id.trim() && create()}
- placeholder="directory name, e.g. donkey-kong"
- aria-label="song directory name"
- className="w-56 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-1 font-mono text-[12px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]"
- />
- <input
- value={title}
- onChange={(e) => setTitle(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && id.trim() && create()}
- placeholder="title (optional)"
- aria-label="song title"
- className="w-64 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)]"
- />
- <button
- type="button"
- disabled={busy || !id.trim()}
- onClick={create}
- className="rounded border border-[var(--color-sel)] px-2.5 py-1 text-[12px] text-[var(--color-sel)] disabled:opacity-40"
- >
- create
- </button>
- <button
- type="button"
- onClick={() => {
- setOpen(false);
- setError(null);
- }}
- className="text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]"
- >
- cancel
- </button>
- {error && <span className="w-full text-[11px] text-[var(--color-bad)]">{error}</span>}
- </div>
- );
-}
diff --git a/umtool/components/dashboard/CheckSourcesButton.tsx b/umtool/components/dashboard/CheckSourcesButton.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useState } from "react";
+import { buttonVariants } from "@/components/ui/button";
+
+// Starts one check-sources job over the given projects. The route serialises it
+// with builds through the same job runner, so a running build makes this a 409
+// that is shown, not swallowed.
+export default function CheckSourcesButton({
+ projects,
+ label,
+}: {
+ projects: string[];
+ label?: string;
+}) {
+ const [state, setState] = useState<"idle" | "busy" | "started" | "failed">("idle");
+ const [msg, setMsg] = useState<string | null>(null);
+ if (!projects.length && !label) return null;
+ return (
+ <>
+ <button
+ type="button"
+ data-action="check-sources"
+ data-state={state}
+ disabled={state === "busy" || !projects.length}
+ className={buttonVariants({ size: "sm" })}
+ onClick={async () => {
+ setState("busy");
+ setMsg(null);
+ try {
+ const r = await fetch("/api/report/check-sources", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ projects }),
+ });
+ const j = (await r.json()) as { error?: string };
+ if (!r.ok) throw new Error(j.error ?? String(r.status));
+ setState("started");
+ } catch (e) {
+ setState("failed");
+ setMsg(e instanceof Error ? e.message : String(e));
+ }
+ }}
+ >
+ {state === "started"
+ ? "checking…"
+ : label ?? `re-check ${projects.length} project${projects.length === 1 ? "" : "s"}`}
+ </button>
+ {msg && <span className="ml-2 text-[11px] text-[var(--color-bad)]">{msg}</span>}
+ </>
+ );
+}
diff --git a/umtool/components/dashboard/DecisionsPanel.tsx b/umtool/components/dashboard/DecisionsPanel.tsx
@@ -0,0 +1,51 @@
+import Link from "next/link";
+import { badgeVariants, type BadgeVariants } from "@/components/ui/badge";
+import { countBySeverity, type Decision, type Severity } from "@/lib/decisions";
+import Panel from "./Panel";
+
+const TONE: Record<Severity, BadgeVariants["variant"]> = {
+ blocking: "blocking",
+ open: "open",
+ info: "info",
+};
+
+/** Worst first, eight of them, and the count of the rest. */
+export default function DecisionsPanel({ decisions }: { decisions: Decision[] }) {
+ const counts = countBySeverity(decisions);
+ const worst = decisions.slice(0, 8);
+ return (
+ <Panel
+ title="needs a decision"
+ testid="decisions"
+ more={{ href: "/browse/decisions", label: `all ${decisions.length} →` }}
+ >
+ <div className="mb-1.5 flex gap-1.5" data-decision-totals={`${counts.blocking}/${counts.open}/${counts.info}`}>
+ <span className={badgeVariants({ variant: "blocking", size: "sm" })}>{counts.blocking} blocking</span>
+ <span className={badgeVariants({ variant: "open", size: "sm" })}>{counts.open} open</span>
+ <span className={badgeVariants({ variant: "info", size: "sm" })}>{counts.info} info</span>
+ </div>
+ {worst.length === 0 ? (
+ <p className="text-[12px] text-[var(--color-dim)]">nothing undecided</p>
+ ) : (
+ <ul className="space-y-0.5">
+ {worst.map((d, i) => (
+ <li
+ key={`${d.project}-${d.kind}-${d.target}-${i}`}
+ data-decision={d.kind}
+ data-severity={d.severity}
+ className="flex flex-wrap items-baseline gap-1.5 text-[11px]"
+ >
+ <span className={badgeVariants({ variant: TONE[d.severity], size: "sm" })}>{d.severity}</span>
+ <Link href={d.href} className="font-mono text-[var(--color-sel)] hover:underline">
+ {d.project}
+ </Link>
+ <span className="truncate text-[var(--color-dim)]">
+ {d.target} — {d.why}
+ </span>
+ </li>
+ ))}
+ </ul>
+ )}
+ </Panel>
+ );
+}
diff --git a/umtool/components/dashboard/DeliverablesPanel.tsx b/umtool/components/dashboard/DeliverablesPanel.tsx
@@ -0,0 +1,39 @@
+import Link from "next/link";
+import type { ProjectSummary } from "@/lib/project-types";
+import { fmtAgo, fmtBytes } from "@/lib/format";
+import Panel from "./Panel";
+
+// Built count, total bytes, newest, stale -- all from `attrs.finalBytes` and
+// each project's state. Nothing here stats a file; the summariser already did.
+export default function DeliverablesPanel({ projects }: { projects: ProjectSummary[] }) {
+ const built = projects.filter((p) => p.attrs?.["final-bytes"]);
+ const bytes = built.reduce((n, p) => n + Number(p.attrs?.["final-bytes"] ?? 0), 0);
+ const newest = [...built].sort((a, b) => b.newestMtimeMs - a.newestMtimeMs)[0] ?? null;
+ const stale = projects.filter((p) => p.state === "stale");
+ return (
+ <Panel title="deliverables" testid="deliverables" more={{ href: "/browse?state=built", label: "built →" }}>
+ <div className="flex flex-wrap gap-3 text-[11px] text-[var(--color-dim)]" data-deliverables={`${built.length}/${bytes}`}>
+ <span>
+ <b className="num text-[var(--color-text)]">{built.length}</b> built
+ </span>
+ <span>
+ <b className="num text-[var(--color-meter)]">{fmtBytes(bytes)}</b> on disk
+ </span>
+ {newest && (
+ <span>
+ newest{" "}
+ <Link href={`/browse/${newest.id}`} className="font-mono text-[var(--color-sel)] hover:underline">
+ {newest.id}
+ </Link>{" "}
+ <span className="num">{fmtAgo(newest.newestMtimeMs)}</span>
+ </span>
+ )}
+ {stale.length > 0 && (
+ <Link href="/browse?state=stale" className="text-[var(--color-dirty)] hover:underline" data-stale={stale.length}>
+ {stale.length} stale
+ </Link>
+ )}
+ </div>
+ </Panel>
+ );
+}
diff --git a/umtool/components/dashboard/NowLive.tsx b/umtool/components/dashboard/NowLive.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import type { ActivityItem } from "@/lib/activity-types";
+import { fmtAgo } from "@/lib/format";
+
+// The running job, live. Polls /api/jobs every two seconds ONLY while something
+// is running: an idle dashboard makes no requests at all, and one that has just
+// watched a build finish stops the moment the state leaves "running".
+//
+// The per-entry boxes use the same vocabulary ReportBuildChain paints from
+// (fetching / cutting / done / failed), computed server-side in lib/activity.ts
+// so this file never re-parses events.
+
+const BOX = {
+ fetching: "border-[var(--color-meter)] text-[var(--color-meter)]",
+ cutting: "border-[var(--color-sel)] text-[var(--color-sel)]",
+ done: "border-[var(--color-good)] text-[var(--color-good)]",
+ failed: "border-[var(--color-bad)] text-[var(--color-bad)]",
+} as const;
+
+export default function NowLive({ initial }: { initial: ActivityItem | null }) {
+ const [now, setNow] = useState<ActivityItem | null>(initial);
+
+ // One fetch on mount, so the panel says what the registry says even if the
+ // page render and the route disagreed; then a 2 s poll ONLY while running.
+ useEffect(() => {
+ let alive = true;
+ void fetch("/api/jobs?n=1", { cache: "no-store" })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((j: { running: ActivityItem | null } | null) => {
+ if (alive && j && j.running) setNow(j.running);
+ })
+ .catch(() => {});
+ return () => {
+ alive = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!now || now.state !== "running") return;
+ const t = setInterval(async () => {
+ try {
+ const r = await fetch("/api/jobs?n=1", { cache: "no-store" });
+ if (!r.ok) return;
+ const j = (await r.json()) as { running: ActivityItem | null; jobs: ActivityItem[] };
+ // When the running item has gone, show what it became rather than
+ // blanking: the newest job in the list is the one that just finished.
+ setNow(j.running ?? j.jobs.find((x) => x.id === now.id) ?? null);
+ } catch {
+ /* a missed poll is the next poll's problem */
+ }
+ }, 2000);
+ return () => clearInterval(t);
+ }, [now]);
+
+ if (!now) {
+ return (
+ <p className="text-[12px] text-[var(--color-dim)]" data-now="idle">
+ nothing running
+ </p>
+ );
+ }
+
+ const p = now.progress;
+ const entries = Object.entries(p?.entries ?? {});
+ return (
+ <div data-now={now.state} data-now-id={now.id} className="space-y-1.5">
+ <div className="flex flex-wrap items-baseline gap-2 text-[12px]">
+ <Link href={now.href} className="font-mono text-[var(--color-sel)] hover:underline">
+ {now.label}
+ </Link>
+ <span className="micro">{now.source}</span>
+ {p && (
+ <span className="num text-[11px] text-[var(--color-dim)]" data-now-step={`${p.step}/${p.of}`}>
+ step {p.step}/{p.of}
+ </span>
+ )}
+ {p?.clipsOf !== undefined && (
+ <span className="num text-[11px] text-[var(--color-meter)]">
+ {p.clipsDone ?? 0}/{p.clipsOf} entries
+ </span>
+ )}
+ <span className="num ml-auto text-[11px] text-[var(--color-dim)]">
+ {now.state === "running" ? `started ${fmtAgo(now.startedAt)}` : now.state}
+ {now.error ? ` — ${now.error}` : ""}
+ </span>
+ </div>
+ {entries.length > 0 && (
+ <div className="flex flex-wrap gap-1">
+ {entries.map(([id, st]) => (
+ <span
+ key={id}
+ data-now-entry={id}
+ data-entry-state={st}
+ className={`rounded border px-1.5 py-0.5 font-mono text-[10px] ${BOX[st]}`}
+ >
+ {id}
+ </span>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/umtool/components/dashboard/NowPanel.tsx b/umtool/components/dashboard/NowPanel.tsx
@@ -0,0 +1,46 @@
+import Link from "next/link";
+import type { ActivityItem } from "@/lib/activity";
+import { fmtAgo } from "@/lib/format";
+import NowLive from "./NowLive";
+import Panel from "./Panel";
+
+// What is running, and what ran. Two registries (chain jobs, mix renders), one
+// list -- merged in lib/activity.ts, never here.
+export default function NowPanel({
+ running,
+ recent,
+}: {
+ running: ActivityItem | null;
+ recent: ActivityItem[];
+}) {
+ const done = recent.filter((a) => a.state !== "running");
+ return (
+ <Panel title="now" testid="now">
+ <NowLive initial={running} />
+ {done.length > 0 && (
+ <ul className="mt-2 space-y-0.5 border-t border-[var(--color-line)] pt-2" data-activity="">
+ {done.map((a) => (
+ <li
+ key={a.id}
+ data-activity-id={a.id}
+ data-activity-state={a.state}
+ className="flex flex-wrap items-baseline gap-2 text-[11px]"
+ >
+ <span
+ className={
+ a.state === "failed" ? "text-[var(--color-bad)]" : "text-[var(--color-dim)]"
+ }
+ >
+ {a.state}
+ </span>
+ <Link href={a.href} className="font-mono text-[var(--color-text)] hover:text-[var(--color-sel)]">
+ {a.label}
+ </Link>
+ <span className="num ml-auto text-[var(--color-dim)]">{fmtAgo(a.endedAt ?? a.startedAt)}</span>
+ </li>
+ ))}
+ </ul>
+ )}
+ </Panel>
+ );
+}
diff --git a/umtool/components/dashboard/Panel.tsx b/umtool/components/dashboard/Panel.tsx
@@ -0,0 +1,33 @@
+import Link from "next/link";
+
+/** The dashboard's one box shape: a micro title, an optional "all →", a body. */
+export default function Panel({
+ title,
+ testid,
+ more,
+ children,
+ className = "",
+}: {
+ title: string;
+ testid: string;
+ more?: { href: string; label: string };
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <section
+ data-panel={testid}
+ className={`rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2 ${className}`}
+ >
+ <div className="mb-1.5 flex items-baseline">
+ <h2 className="micro">{title}</h2>
+ {more && (
+ <Link href={more.href} className="ml-auto text-[11px] text-[var(--color-sel)] hover:underline">
+ {more.label}
+ </Link>
+ )}
+ </div>
+ {children}
+ </section>
+ );
+}
diff --git a/umtool/components/dashboard/ResumeChip.tsx b/umtool/components/dashboard/ResumeChip.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { LAST_PAGE_KEY, isRecordable, restoreCandidates } from "@/lib/last-page";
+
+// Where you were, as a LINK rather than a redirect. The dashboard is a page in
+// its own right now, so / no longer replaces itself with your last URL -- it
+// offers it. Same storage, same guard, same HEAD-probed walk-up as the old
+// restore, so a stale URL degrades to its parent instead of to a 404.
+export default function ResumeChip() {
+ const [target, setTarget] = useState<string | null>(null);
+
+ useEffect(() => {
+ let done = false;
+ (async () => {
+ let stored: string | null = null;
+ try {
+ stored = localStorage.getItem(LAST_PAGE_KEY);
+ } catch {
+ stored = null;
+ }
+ if (!stored || !isRecordable(stored)) return;
+ for (const url of restoreCandidates(stored)) {
+ if (done) return;
+ let alive = false;
+ try {
+ const res = await fetch(url, { method: "HEAD", cache: "no-store" });
+ alive = res.status !== 404;
+ } catch {
+ alive = false;
+ }
+ if (alive) {
+ setTarget(url);
+ return;
+ }
+ }
+ // Nothing down to the floor answered. A URL that can never resolve must
+ // not cost a probe on every open, so it goes.
+ try {
+ localStorage.removeItem(LAST_PAGE_KEY);
+ } catch {
+ /* nothing to clean up */
+ }
+ })();
+ return () => {
+ done = true;
+ };
+ }, []);
+
+ if (!target) return null;
+ return (
+ <Link
+ href={target}
+ data-resume={target}
+ className="rounded border border-[var(--color-line)] px-2 py-0.5 text-[11px] text-[var(--color-dim)] hover:border-[var(--color-sel)] hover:text-[var(--color-sel)]"
+ >
+ resume {target} →
+ </Link>
+ );
+}
diff --git a/umtool/components/dashboard/SourcesPanel.tsx b/umtool/components/dashboard/SourcesPanel.tsx
@@ -0,0 +1,75 @@
+import Link from "next/link";
+import type { ProjectSummary } from "@/lib/project-types";
+import { SOURCES_STALE_DAYS } from "@/lib/dashboard";
+import CheckSourcesButton from "./CheckSourcesButton";
+import Panel from "./Panel";
+
+// The long-form problem, on the front door: which projects have sources nobody
+// has checked, checked too long ago, or known to be dead. All of it read from
+// each project's summary attributes -- never probed here.
+export default function SourcesPanel({
+ never,
+ old,
+ bad,
+}: {
+ never: ProjectSummary[];
+ old: ProjectSummary[];
+ bad: ProjectSummary[];
+}) {
+ const List = ({ items, attr }: { items: ProjectSummary[]; attr: string }) => (
+ <ul className="space-y-0.5" data-sources-list={attr}>
+ {items.map((p) => (
+ <li key={p.id} data-source-project={p.id} className="flex flex-wrap gap-2 text-[11px]">
+ <Link href={`/browse/${p.id}`} className="font-mono text-[var(--color-sel)] hover:underline">
+ {p.id}
+ </Link>
+ <span className="text-[var(--color-dim)]">
+ {[
+ p.attrs?.["sources-dead"] ? `${p.attrs["sources-dead"]} dead` : "",
+ p.attrs?.["sources-missing"] ? `${p.attrs["sources-missing"]} no cues` : "",
+ p.attrs?.["cue-gaps"] ? `${p.attrs["cue-gaps"]} cue gap${p.attrs["cue-gaps"] === "1" ? "" : "s"}` : "",
+ ]
+ .filter(Boolean)
+ .join(" · ")}
+ </span>
+ </li>
+ ))}
+ </ul>
+ );
+ const recheck = [...never, ...old].map((p) => p.id);
+ return (
+ <Panel title="sources" testid="sources">
+ <div className="mb-1.5 flex flex-wrap items-baseline gap-3 text-[11px]" data-sources-counts={`${never.length}/${old.length}/${bad.length}`}>
+ <span className="text-[var(--color-dim)]">
+ <b className="num text-[var(--color-text)]">{never.length}</b> never checked
+ </span>
+ <span className="text-[var(--color-dim)]">
+ <b className="num text-[var(--color-text)]">{old.length}</b> older than {SOURCES_STALE_DAYS} d
+ </span>
+ <span className="text-[var(--color-dim)]">
+ <b className={`num ${bad.length ? "text-[var(--color-bad)]" : "text-[var(--color-text)]"}`}>{bad.length}</b>{" "}
+ with dead or missing sources
+ </span>
+ <span className="ml-auto">
+ <CheckSourcesButton projects={recheck} />
+ </span>
+ </div>
+ {bad.length > 0 && <List items={bad} attr="bad" />}
+ {never.length > 0 && (
+ <>
+ <div className="micro mt-1.5">never checked</div>
+ <List items={never} attr="never" />
+ </>
+ )}
+ {old.length > 0 && (
+ <>
+ <div className="micro mt-1.5">older than {SOURCES_STALE_DAYS} d</div>
+ <List items={old} attr="old" />
+ </>
+ )}
+ {!never.length && !old.length && !bad.length && (
+ <p className="text-[12px] text-[var(--color-dim)]">every source was checked recently and is reachable</p>
+ )}
+ </Panel>
+ );
+}
diff --git a/umtool/components/dashboard/StateStrip.tsx b/umtool/components/dashboard/StateStrip.tsx
@@ -0,0 +1,56 @@
+import Link from "next/link";
+import type { StateRow } from "@/lib/dashboard";
+import Panel from "./Panel";
+
+// One row per kind, the six states as proportional segments.
+//
+// ONE meaningful colour. `stale` is the only state that is a judgement -- a
+// deliverable older than what describes it -- so it wears --color-dirty, and
+// every other state is a step on a neutral ramp. A strip that coloured "built"
+// green would be handing a verdict hue to a fact.
+const FILL: Record<string, string> = {
+ draft: "var(--color-panel-2)",
+ windows: "var(--color-line)",
+ fetched: "color-mix(in srgb, var(--color-dim) 45%, transparent)",
+ built: "color-mix(in srgb, var(--color-text) 55%, transparent)",
+ shipped: "var(--color-text)",
+ stale: "var(--color-dirty)",
+};
+
+export default function StateStrip({ rows, note }: { rows: StateRow[]; note: string }) {
+ return (
+ <Panel title="projects" testid="projects" more={{ href: "/browse", label: "browse →" }}>
+ <p className="micro mb-1.5" data-projects-note="">
+ {note}
+ </p>
+ <div className="space-y-1.5">
+ {rows.map((r) => (
+ <div key={r.kind} data-state-row={r.kind} className="flex items-center gap-2 text-[11px]">
+ <Link href={`/browse?kind=${encodeURIComponent(r.kind)}`} className="w-24 truncate text-[var(--color-dim)] hover:text-[var(--color-text)]">
+ {r.label}
+ </Link>
+ <span className="num w-6 text-right text-[var(--color-text)]">{r.total}</span>
+ <div className="flex h-3 flex-1 overflow-hidden rounded-sm border border-[var(--color-line)]">
+ {r.segments
+ .filter((s) => s.n > 0)
+ .map((s) => (
+ <Link
+ key={s.state}
+ href={s.href}
+ title={`${s.n} ${s.state}`}
+ data-state-seg={s.state}
+ data-n={s.n}
+ style={{ flexGrow: s.n, background: FILL[s.state] }}
+ className="block min-w-[3px] hover:opacity-80"
+ />
+ ))}
+ </div>
+ <span className="num w-40 truncate text-[10px] text-[var(--color-dim)]">
+ {r.segments.filter((s) => s.n > 0).map((s) => `${s.n} ${s.state}`).join(" · ")}
+ </span>
+ </div>
+ ))}
+ </div>
+ </Panel>
+ );
+}
diff --git a/umtool/components/dashboard/ToolsPanel.tsx b/umtool/components/dashboard/ToolsPanel.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useState } from "react";
+import { buttonVariants } from "@/components/ui/button";
+import type { DoctorReport } from "@/lib/tool-types";
+import { fmtGB } from "@/lib/dashboard";
+import { fmtAgo } from "@/lib/format";
+
+// Tools, disk, index. The tool list is the CACHE handed down by the page, or
+// "not checked": the page never probes. The button POSTs, which is the one
+// place a probe happens in the app.
+//
+// Red ONLY when the report pipeline needs it and it is absent. A missing python
+// on a machine that never cuts a face is a fact, not a fault.
+export default function ToolsPanel({
+ initial,
+ freeBytes,
+ index,
+}: {
+ initial: DoctorReport | null;
+ freeBytes: number | null;
+ index: { ok: boolean; fresh: number; total: number };
+}) {
+ const [report, setReport] = useState<DoctorReport | null>(initial);
+ const [busy, setBusy] = useState(false);
+
+ return (
+ <section data-panel="tools" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2">
+ <div className="mb-1.5 flex items-baseline gap-2">
+ <h2 className="micro">tools & disk</h2>
+ <span className="micro ml-auto" data-tools-checked={report ? String(report.checkedAt) : "never"}>
+ {report ? `checked ${fmtAgo(report.checkedAt)}` : "not checked"}
+ </span>
+ <button
+ type="button"
+ data-action="doctor"
+ disabled={busy}
+ className={buttonVariants({ size: "sm" })}
+ onClick={async () => {
+ setBusy(true);
+ try {
+ const r = await fetch("/api/doctor", { method: "POST" });
+ if (r.ok) setReport((await r.json()) as DoctorReport);
+ } finally {
+ setBusy(false);
+ }
+ }}
+ >
+ {busy ? "…" : report ? "re-check" : "check"}
+ </button>
+ </div>
+ {report && (
+ <ul className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
+ {report.tools.map((t) => {
+ const bad = t.required && !t.present;
+ return (
+ <li
+ key={t.id}
+ data-tool={t.id}
+ data-present={t.present ? "1" : "0"}
+ data-required={t.required ? "1" : "0"}
+ title={t.error ?? t.bin}
+ className={`flex gap-1.5 ${bad ? "text-[var(--color-bad)]" : t.present ? "text-[var(--color-dim)]" : "text-[var(--color-dim)] opacity-70"}`}
+ >
+ <span className="font-mono text-[var(--color-text)]">{t.id}</span>
+ <span className="num truncate">{t.present ? (t.version ?? "present") : "absent"}</span>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ <div className="mt-1.5 flex flex-wrap gap-3 border-t border-[var(--color-line)] pt-1.5 text-[11px] text-[var(--color-dim)]">
+ <span data-free-bytes={freeBytes ?? ""}>
+ free <b className="num text-[var(--color-meter)]">{fmtGB(freeBytes)}</b>
+ </span>
+ <span data-index={index.ok ? `${index.fresh}/${index.total}` : "off"}>
+ index{" "}
+ {index.ok ? (
+ <>
+ <b className="num text-[var(--color-text)]">{index.fresh}/{index.total}</b> fresh
+ </>
+ ) : (
+ "off"
+ )}
+ </span>
+ </div>
+ </section>
+ );
+}
diff --git a/umtool/components/projects/ReportBuildChain.tsx b/umtool/components/projects/ReportBuildChain.tsx
@@ -49,8 +49,17 @@ export default function ReportBuildChain({
entries: { id: string; kind: string }[];
}) {
const [presets, setPresets] = useState<Preset[]>([]);
+ const [variants, setVariants] = useState<string[]>([]);
const [preset, setPreset] = useState("fast");
const [only, setOnly] = useState("");
+ // The options the pipeline has and the driver used to hide. The dry-run
+ // argv panel is the proof of what each one does.
+ const [variant, setVariant] = useState("");
+ const [noXfade, setNoXfade] = useState(false);
+ const [chaptersOnly, setChaptersOnly] = useState(false);
+ const [previewOn, setPreviewOn] = useState(false);
+ const [previewAt, setPreviewAt] = useState("0");
+ const [previewDur, setPreviewDur] = useState("20");
const [dry, setDry] = useState<StepView[] | null>(null);
const [job, setJob] = useState<JobView | null>(null);
const [events, setEvents] = useState<Ev[]>([]);
@@ -65,6 +74,7 @@ export default function ReportBuildChain({
.then((r) => r.json())
.then((j) => {
setPresets(j.presets ?? []);
+ setVariants(j.variants ?? []);
// Adopt a build already running, so a reload does not lose it.
if (j.running) {
setJob(j.running as JobView);
@@ -95,10 +105,15 @@ export default function ReportBuildChain({
async (qs: string, extra: Record<string, unknown> = {}) => {
setBusy(true);
setError(null);
+ const options: Record<string, unknown> = {};
+ if (variant) options.variant = variant;
+ if (noXfade) options.xfade = false;
+ if (chaptersOnly) options.chaptersOnly = true;
+ if (previewOn) options.preview = { at: Number(previewAt), dur: Number(previewDur) };
const r = await fetch(`/api/report/build${qs}`, {
method: "POST",
headers: { "content-type": "application/json" },
- body: JSON.stringify({ project, preset, only: only || null, ...extra }),
+ body: JSON.stringify({ project, preset, only: only || null, options, ...extra }),
});
const j = (await r.json()) as Record<string, unknown>;
setBusy(false);
@@ -110,7 +125,7 @@ export default function ReportBuildChain({
setNeedsReplace(false);
return j;
},
- [project, preset, only],
+ [project, preset, only, variant, noXfade, chaptersOnly, previewOn, previewAt, previewDur],
);
const clipStates = new Map<string, ClipState>();
@@ -198,6 +213,80 @@ export default function ReportBuildChain({
)}
</div>
+ {/* The options row. Each maps to one flag of build-video.mjs; "show the
+ command" is where to see it land. */}
+ <div className="flex flex-wrap items-center gap-3 text-[11px] text-[var(--color-dim)]" data-build-options="">
+ <label className="flex items-center gap-1">
+ variant
+ <select
+ value={variant}
+ onChange={(e) => setVariant(e.target.value)}
+ data-option="variant"
+ className="rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] px-1.5 py-0.5 font-mono text-[11px]"
+ >
+ <option value="">default (sourced)</option>
+ {variants.map((v) => (
+ <option key={v} value={v}>
+ {v}
+ </option>
+ ))}
+ </select>
+ </label>
+ <label className="flex items-center gap-1">
+ <input type="checkbox" checked={noXfade} onChange={(e) => setNoXfade(e.target.checked)} data-option="no-xfade" />
+ hard cuts (--no-xfade)
+ </label>
+ <label className="flex items-center gap-1">
+ <input
+ type="checkbox"
+ checked={chaptersOnly}
+ onChange={(e) => {
+ setChaptersOnly(e.target.checked);
+ if (e.target.checked) setPreviewOn(false);
+ }}
+ data-option="chapters-only"
+ />
+ chapters only (no encode)
+ </label>
+ <label className="flex items-center gap-1">
+ <input
+ type="checkbox"
+ checked={previewOn}
+ onChange={(e) => {
+ setPreviewOn(e.target.checked);
+ if (e.target.checked) setChaptersOnly(false);
+ }}
+ data-option="preview"
+ />
+ rail preview
+ </label>
+ {previewOn && (
+ <span className="flex items-center gap-1">
+ at
+ <input
+ type="number"
+ min={0}
+ step={1}
+ value={previewAt}
+ onChange={(e) => setPreviewAt(e.target.value)}
+ data-option="preview-at"
+ className="w-16 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1 py-0.5 text-[11px]"
+ />
+ s for
+ <input
+ type="number"
+ min={1}
+ step={1}
+ value={previewDur}
+ onChange={(e) => setPreviewDur(e.target.value)}
+ data-option="preview-dur"
+ className="w-16 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1 py-0.5 text-[11px]"
+ />
+ s
+ </span>
+ )}
+ </div>
+
{error && (
<p data-build-error="" className="text-[12px] text-[var(--color-bad)]">
{error}
diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx
@@ -1,9 +1,17 @@
+import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import Link from "next/link";
import BrowseHeader from "@/components/BrowseHeader";
+import CopyButton from "@/components/CopyButton";
+import CheckSourcesButton from "@/components/dashboard/CheckSourcesButton";
import { fmtAgo, fmtBytes } from "@/lib/format";
-import { readClipDetail } from "@/lib/projects/report.mjs";
+import { Markdown } from "@/lib/markdown";
+import { readClipDetail, sourcesOf } from "@/lib/projects/report.mjs";
+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 ReportBuildChain from "./ReportBuildChain";
+import SnapshotButton from "./SnapshotButton";
import { decisionsForProject } from "@/lib/projects";
import { badgeVariants, type BadgeVariants } from "@/components/ui/badge";
import type { Severity } from "@/lib/decisions";
@@ -67,7 +75,35 @@ export default async function ReportProject({
const nonClips = entries.filter((e) => e.kind !== "clip");
const runtime = clips.reduce((n, e) => n + Math.max(0, e.end - e.start), 0);
const showAll = search.all === "1";
- const p = m.provenance ?? {};
+ const p = (m.provenance ?? {}) as Record<string, unknown>;
+
+ // ---- the long-form reads: sources, revisions, notes, exports ------------
+ const sources = await sourcesOf(project.dir, { manifest: m });
+ const snapshots = await listSnapshots(project.dir);
+ const diffs = await Promise.all(
+ snapshots.map(async (sn) => {
+ try {
+ return diffManifests(await readSnapshot(project.dir, sn.rel), m);
+ } catch {
+ return null;
+ }
+ }),
+ );
+ const readme = await readFile(path.join(project.dir, "README.md"), "utf8").catch(() => null);
+ // Sibling notes: every .md that is neither the report nor the README.
+ const names = await readdir(project.dir).catch(() => [] as string[]);
+ 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);
+
+ // 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
+ // paragraph under Notes. No field is moved or rewritten.
+ const isProse = (v: unknown) => typeof v === "string" && (v.length > 120 || v.includes("\n"));
+ const scalars = Object.entries(p).filter(([, v]) => !isProse(v) && (typeof v !== "object" || v === null));
+ const prose = Object.entries(p).filter(([, v]) => isProse(v)) as [string, string][];
+ const structured = Object.entries(p).filter(([, v]) => typeof v === "object" && v !== null);
+ const days = (ms: number) => Math.floor((Date.now() - ms) / 86_400_000);
// ---- what a clip's "mix" link opens ---------------------------------------
//
@@ -118,7 +154,7 @@ export default async function ReportProject({
<h1 className="truncate text-[15px] text-[var(--color-text)]">{m.title}</h1>
{m.subtitle && <p className="text-[12px] text-[var(--color-dim)]">{m.subtitle}</p>}
<div className="num mt-1 flex flex-wrap items-center gap-x-3 text-[11px] text-[var(--color-dim)]">
- {p.channel && <span>{p.channel}</span>}
+ {typeof p.channel === "string" && p.channel && <span>{p.channel}</span>}
{m.generatedOn && <span>generated {m.generatedOn}</span>}
{typeof p.videosCited === "number" && <span>{p.videosCited} videos cited</span>}
{typeof p.enumeratedMatches === "number" && (
@@ -145,6 +181,15 @@ export default async function ReportProject({
</div>
<main className="deck-main flex-1 space-y-4 p-4">
+ {/* --- the author's own account of the cut, first ------------------ */}
+ {readme && (
+ <section data-readme="" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2">
+ <div className="max-h-[40vh] overflow-y-auto">
+ <Markdown text={readme} />
+ </div>
+ </section>
+ )}
+
{/* --- what is wrong, first ------------------------------------- */}
{decisions.length > 0 && (
<section data-project={project.id}>
@@ -172,9 +217,21 @@ export default async function ReportProject({
</section>
)}
- {/* --- where the sources are read from --------------------------- */}
- <section className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2">
- <div className="micro mb-1">sources</div>
+ {/* --- the sources ---------------------------------------------- */}
+ <section data-sources="" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2">
+ <div className="mb-1 flex flex-wrap items-baseline gap-3">
+ <h2 className="micro">
+ sources — {sources.rows.length}
+ </h2>
+ <span className="num text-[11px] text-[var(--color-dim)]" data-sources-checked={sources.checkedAtMs ? String(Math.round(sources.checkedAtMs)) : "never"}>
+ {sources.checkedAtMs
+ ? `availability checked ${days(sources.checkedAtMs)} d ago`
+ : "availability never checked"}
+ </span>
+ <span className="ml-auto">
+ <CheckSourcesButton projects={[project.id]} label="re-check availability" />
+ </span>
+ </div>
<div className="num text-[11px] text-[var(--color-dim)]">
cues from <code className="font-mono">{channelsDir}</code>
{shadowExists && (
@@ -182,11 +239,99 @@ export default async function ReportProject({
(this project’s own shadow tree)
</span>
)}
+ {" · "}QR codes resolve to{" "}
+ <code className="font-mono">{String(p.siteOrigin ?? "") || "(nothing — siteOrigin is unset)"}</code>
</div>
- <div className="num mt-1 text-[11px] text-[var(--color-dim)]">
- QR codes resolve to{" "}
- <code className="font-mono">{p.siteOrigin ?? "(nothing — siteOrigin is unset)"}</code>
- </div>
+ {sources.rows.length > 0 && (
+ <div className="mt-2 overflow-x-auto">
+ <table className="w-full border-collapse text-[11px]">
+ <thead>
+ <tr className="micro text-left">
+ <th className="px-1.5 py-0.5">source</th>
+ <th className="px-1.5 py-0.5">clips</th>
+ <th className="px-1.5 py-0.5">cues</th>
+ <th className="px-1.5 py-0.5">coverage</th>
+ <th className="px-1.5 py-0.5">availability</th>
+ <th className="px-1.5 py-0.5">cite target</th>
+ </tr>
+ </thead>
+ <tbody>
+ {sources.rows.map((r) => {
+ const a = r.availability;
+ return (
+ <tr
+ key={r.key}
+ data-source={r.key}
+ data-cues={r.cues}
+ data-cue-gap={r.coverageGap ? r.coverageGap.clip : ""}
+ data-availability={a ? a.state : "never"}
+ data-cite-override={r.cite.differs ? "1" : "0"}
+ className="border-t border-[var(--color-line)] align-top"
+ >
+ <td className="px-1.5 py-1">
+ <a href={r.cite.override ?? r.cite.derived} target="_blank" rel="noreferrer" className="font-mono text-[var(--color-sel)] hover:underline">
+ {r.key}
+ </a>
+ {r.title && <div className="truncate text-[var(--color-dim)]" style={{ maxWidth: "24rem" }}>{r.title}</div>}
+ </td>
+ <td className="px-1.5 py-1 font-mono">
+ {r.clips.map((c, i) => (
+ <span key={c}>
+ {i > 0 && " "}
+ <Link href={`/browse/${project.id}/clip/${c}`} className="text-[var(--color-sel)] hover:underline">
+ {c}
+ </Link>
+ </span>
+ ))}
+ </td>
+ <td className="px-1.5 py-1">
+ {r.cues === "missing" ? (
+ <Pill tone="blocking">missing</Pill>
+ ) : r.cues === "no-punctuation" ? (
+ <Pill>no punctuation</Pill>
+ ) : (
+ <span className="text-[var(--color-dim)]">present</span>
+ )}
+ </td>
+ <td className="num px-1.5 py-1">
+ {r.coverageGap ? (
+ <span className="text-[var(--color-dirty)]">
+ cues end {r.coverageGap.cuesEnd.toFixed(0)} s · {r.coverageGap.clip} needs {r.coverageGap.needs.toFixed(0)} s
+ </span>
+ ) : r.cuesEnd != null ? (
+ <span className="text-[var(--color-dim)]">cues to {r.cuesEnd.toFixed(0)} s</span>
+ ) : (
+ <span className="text-[var(--color-dim)]">—</span>
+ )}
+ </td>
+ <td className="num px-1.5 py-1">
+ {a ? (
+ <span className={a.ok ? "text-[var(--color-dim)]" : "text-[var(--color-bad)]"} title={a.error ?? undefined}>
+ {a.state} · {days(a.checkedAtMs)} d ago
+ </span>
+ ) : (
+ <span className="text-[var(--color-dim)]">never checked</span>
+ )}
+ </td>
+ <td className="px-1.5 py-1">
+ {r.cite.differs ? (
+ <span className="text-[var(--color-dim)]">
+ <Pill>citeUrl</Pill>{" "}
+ <a href={r.cite.override!} target="_blank" rel="noreferrer" className="font-mono text-[var(--color-sel)] hover:underline">
+ override
+ </a>
+ </span>
+ ) : (
+ <span className="text-[var(--color-dim)]">derived</span>
+ )}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ )}
</section>
{/* --- building it ---------------------------------------------- */}
@@ -318,20 +463,134 @@ export default async function ReportProject({
</div>
</section>
- {/* --- provenance, as written ------------------------------------ */}
- <section>
+ {/* --- revisions ------------------------------------------------- */}
+ <section data-revisions="" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2">
+ <div className="mb-1 flex flex-wrap items-baseline gap-3">
+ <h2 className="micro">revisions — {snapshots.length}</h2>
+ <span className="ml-auto">
+ <SnapshotButton project={project.id} />
+ </span>
+ </div>
+ {snapshots.length === 0 ? (
+ <p className="text-[11px] text-[var(--color-dim)]">
+ no snapshots. One copies <code className="font-mono">video.manifest.json</code> into{" "}
+ <code className="font-mono">revisions/</code>; a build that stamps a deliverable aside
+ takes one of the manifest that made it.
+ </p>
+ ) : (
+ <ul className="space-y-1">
+ {snapshots.map((sn, i) => {
+ const d = diffs[i];
+ const summary = !d
+ ? "unreadable"
+ : d.same
+ ? "same timeline as now"
+ : Object.entries(d.counts).map(([k, n]) => `${n} ${k}`).join(" · ");
+ return (
+ <li
+ key={sn.rel}
+ data-snapshot={sn.rel}
+ data-legacy={sn.legacy ? "1" : "0"}
+ data-diff={d ? d.changes.length : "x"}
+ className="text-[11px]"
+ >
+ <details>
+ <summary className="flex cursor-pointer flex-wrap items-baseline gap-2">
+ <span className="font-mono text-[var(--color-text)]">{sn.rel}</span>
+ {sn.legacy && <Pill>legacy</Pill>}
+ {sn.label && !sn.legacy && <Pill>{sn.label}</Pill>}
+ <span className="num text-[var(--color-dim)]">{fmtAgo(sn.mtimeMs)}</span>
+ <span className={`num ml-auto ${d && !d.same ? "text-[var(--color-dirty)]" : "text-[var(--color-dim)]"}`}>
+ {summary}
+ </span>
+ </summary>
+ {d && !d.same && (
+ <pre className="mt-1 max-h-56 overflow-auto rounded bg-[var(--color-ink)] p-2 font-mono text-[11px] text-[var(--color-dim)]">
+ {d.changes.map((c) => formatChange(c)).join("\n")}
+ </pre>
+ )}
+ </details>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </section>
+
+ {/* --- exports --------------------------------------------------- */}
+ <section data-exports="" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2">
+ <h2 className="micro mb-1">export</h2>
+ {variants.length === 0 ? (
+ <p className="text-[11px] text-[var(--color-dim)]">
+ nothing built to export from — a TOC needs the build’s chapter offsets, and there is
+ no <code className="font-mono">chapters.ffmeta</code> and no segments under{" "}
+ <code className="font-mono">out/</code>
+ </p>
+ ) : (
+ variants.map((v) => (
+ <div key={v} className="flex flex-wrap items-center gap-1.5 text-[11px]" data-export-variant={v}>
+ <span className="w-14 font-mono text-[var(--color-dim)]">{v}</span>
+ {EXPORT_FORMATS.map((f) => (
+ <CopyButton
+ key={f}
+ label={f}
+ title={`copy the ${f} for the ${v} cut`}
+ url={`/api/report/export?project=${encodeURIComponent(project.id)}&format=${f}&variant=${v}`}
+ />
+ ))}
+ </div>
+ ))
+ )}
+ </section>
+
+ {/* --- provenance, as written; prose under notes ------------------- */}
+ <section data-provenance="">
<h2 className="micro mb-1.5">provenance</h2>
- <dl className="space-y-1.5 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2 text-[11px]">
- {Object.entries(p)
- .filter(([, v]) => typeof v === "string" && v.length > 40)
- .map(([k, v]) => (
- <div key={k}>
- <dt className="micro">{k}</dt>
- <dd className="text-[var(--color-dim)]">{String(v)}</dd>
- </div>
- ))}
+ <dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-0.5 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2 text-[11px]">
+ {scalars.map(([k, v]) => (
+ <div key={k} className="contents">
+ <dt className="micro">{k}</dt>
+ <dd className="num break-all text-[var(--color-dim)]">
+ {typeof v === "string" && /^https?:\/\//.test(v) ? (
+ <a href={v} target="_blank" rel="noreferrer" className="text-[var(--color-sel)] hover:underline">
+ {v}
+ </a>
+ ) : (
+ String(v)
+ )}
+ </dd>
+ </div>
+ ))}
+ {structured.map(([k, v]) => (
+ <div key={k} className="contents">
+ <dt className="micro">{k}</dt>
+ <dd className="font-mono text-[var(--color-dim)]">{JSON.stringify(v).slice(0, 200)}</dd>
+ </div>
+ ))}
</dl>
</section>
+
+ {(prose.length > 0 || noteFiles.length > 0) && (
+ <section data-notes="">
+ <h2 className="micro mb-1.5">notes</h2>
+ <div className="space-y-2 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2">
+ {prose.map(([k, v]) => (
+ <div key={k} data-note={k}>
+ <div className="text-[12px] font-semibold text-[var(--color-text)]">{k}</div>
+ <p className="whitespace-pre-wrap text-[12px] leading-relaxed text-[var(--color-dim)]">{v}</p>
+ </div>
+ ))}
+ {noteFiles.map((n, i) => (
+ <details key={n} data-note-file={n}>
+ <summary className="cursor-pointer font-mono text-[12px] text-[var(--color-sel)]">{n}</summary>
+ <div className="mt-1 max-h-[50vh] overflow-y-auto">
+ <Markdown text={noteTexts[i]} />
+ </div>
+ </details>
+ ))}
+ </div>
+ </section>
+ )}
</main>
</div>
);
diff --git a/umtool/components/projects/SnapshotButton.tsx b/umtool/components/projects/SnapshotButton.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { buttonVariants } from "@/components/ui/button";
+
+// Copy the manifest into revisions/ with an optional label. The page re-renders
+// on success, so the new snapshot appears in the list with its (empty) diff.
+export default function SnapshotButton({ project }: { project: string }) {
+ const router = useRouter();
+ const [label, setLabel] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ return (
+ <span className="flex flex-wrap items-center gap-1.5">
+ <input
+ value={label}
+ onChange={(e) => setLabel(e.target.value)}
+ placeholder="label (optional)"
+ aria-label="snapshot label"
+ data-snapshot-label
+ className="w-36 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]"
+ />
+ <button
+ type="button"
+ data-action="snapshot"
+ disabled={busy}
+ className={buttonVariants({ size: "sm" })}
+ onClick={async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ const r = await fetch("/api/report/snapshot", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project, label: label.trim() || null }),
+ });
+ const j = (await r.json()) as { error?: string };
+ if (!r.ok) throw new Error(j.error ?? String(r.status));
+ setLabel("");
+ router.refresh();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(false);
+ }
+ }}
+ >
+ snapshot
+ </button>
+ {error && <span className="text-[11px] text-[var(--color-bad)]">{error}</span>}
+ </span>
+ );
+}
diff --git a/umtool/docs/README.md b/umtool/docs/README.md
@@ -5,7 +5,13 @@ kinds of work in it today and the tool treats them the same way: as **projects**
in a folder tree, each with a state, a set of open decisions, and a build.
These sheets live in the repo because they describe code and have to move with it.
-Notes about *one particular video* belong beside that video, in its own `README.md`.
+Notes about *one particular video* belong beside that video, in its own `README.md`
+— which the project page now renders at the top, with any other `*.md` beside it
+under **Notes**.
+
+`/` is the [dashboard](dashboard.md): what is running, what is waiting on you,
+where every project stands, which sources nobody has checked, what is built, and
+whether this machine can build. The song piles are one nav entry, `song ▸`.
## You have been asked for an umtool video
@@ -14,14 +20,19 @@ Work out which kind you are making first — the rest follows from it.
| What you were handed | Kind | Start here |
|---|---|---|
| A cited sweep report (`*sweep-report.md`) and "make this a video" | `report-video` | [report-video.md](report-video.md), then [authoring.md](authoring.md) |
+| One archived recording and "make a video from this" | `report-video` | `umtool new <slug> --from <channel>/<id> [--seed chapters]`, or the editor's "Open in umtool" |
| A song's clips and "judge these" | `song` (template `um-song`) | `~/reports/quartering-uh-song/specs/` |
| A report with no manifest yet | `sweep-report` | [authoring.md](authoring.md) |
The short path from a cited report to a built video:
```sh
-# 0. scaffold it (writes an EMPTY timeline and a citation checklist)
+# 0. scaffold it (writes an EMPTY timeline and a citation checklist), from a
+# report, a viewer share URL, or a bare <channel>/<videoId>. `--seed chapters`
+# writes one clip per digest chapter -- the digest's boundaries, not guesses.
umtool new <slug> --from <sweep-report.md>
+umtool new <slug> --from the-quartering/MpZuPjnF_O8 --site-origin https://jeralyzer.pages.dev --seed chapters
+umtool doctor # can this machine build at all?
# 1. write the timeline (authoring.md — this is the work, and nothing automates it)
@@ -44,6 +55,14 @@ node scripts/report-to-video/resolve-windows.mjs ~/reports/<slug>/video.manifest
# timeouts and the process-group kill live in the server's job runner.
# `umtool build` prints the same chain if you would rather paste it.
umtool build <slug> --preset fast
+
+# 6. before revising: keep the manifest that made the deliverable
+umtool snapshot <slug> --label v1
+# …edit…
+umtool diff <slug> revisions/<stamp>-v1.manifest.json
+
+# 7. post it: the TOC / description / chapters, derived from the build's offsets
+umtool export <slug> --format toc-bbcode
```
**A manifest may describe more than one cut.** `build-video.mjs --variant
@@ -62,6 +81,7 @@ defects that have already shipped in real videos: a manifest with no `siteOrigin
| Sheet | What it covers |
|---|---|
+| [dashboard.md](dashboard.md) | The front door: what each panel reads, `/api/jobs`, `umtool doctor` |
| [projects.md](projects.md) | Kinds vs templates, marker files, ids, how to add a kind |
| [folders.md](folders.md) | The walk, `REPORTS_ROOT`, read roots vs write roots |
| [browse.md](browse.md) | The project index, the four filters, cards per kind |
diff --git a/umtool/docs/build.md b/umtool/docs/build.md
@@ -34,6 +34,21 @@ like a finished video in a directory listing. `verify-build.mjs` checks duration
What you watch to check the argument.
- **final** — crossfades and chapters. The deliverable.
+## Options
+
+Under the preset, each mapping to one flag of `build-video.mjs`. "Show the
+command" prints the argv with them in it, which is the proof.
+
+| option | flag | what it does | chain |
+|---|---|---|---|
+| **variant** | `--variant sourced\|full` | which cut of a two-cut manifest; `full` writes `out/<slug>-full.mp4` | all four steps; the overwrite guard checks *that* variant's file |
+| **hard cuts** | `--no-xfade` | hard cuts on a preset that would crossfade | all four |
+| **chapters only** | `--chapters-only` | retitle the chapters from the segments already on disk — no fetch, no encode | build only, 5-minute cap; refuses when a segment is missing |
+| **rail preview** | `--preview <at> <dur>` | the rail alone over a window, to `<slug>.preview.mp4` | build only, 5-minute cap; no verify (nothing to verify) |
+
+An unknown variant is a 400 before anything runs. `chaptersOnly` and `preview`
+skip the preflight and the dry resolve because neither touches a source.
+
## Timeouts
Per step, not one number. The default is 15 minutes and exists to catch the
diff --git a/umtool/docs/cli.md b/umtool/docs/cli.md
@@ -24,7 +24,12 @@ decisions inbox cannot disagree about what is wrong with one.
| `window <project> <clip> [--start S] [--end E] [--lock] [--lock-end] [--no-lock-end] [--note …]` | edit a window |
| `build <project> [--preset preview\|fast\|final] [--only ID]` | **prints** the chain |
| `index [--rebuild] [--prune] [--since MS] [--json]` | the cache |
-| `new <slug> [--kind report-video] [--from <sweep-report.md>]` | scaffold |
+| `new <slug> [--from <report.md>\|<share URL>\|<channel>/<id>] [--site-origin URL] [--seed chapters]` | scaffold |
+| `doctor [--json]` | which tools are on this machine; **exit 1** if the report pipeline is missing one |
+| `snapshot <project> [--label L]` | copy the manifest into `revisions/` |
+| `diff <project> <snapshot>` | added / removed / moved / window / retyped, by entry id |
+| `export <project> --format toc-bbcode\|toc-markdown\|description\|chapters [--variant V]` | the posting artifacts, from the build's chapter offsets |
+| `check-sources [<project>…]` | **prints** the re-check chain; with no args, the never-checked and >30 d projects |
A project argument is an exact id, a directory, or a **unique** basename. Two
projects answering to one name is reported, never resolved by picking one.
@@ -76,6 +81,27 @@ c01: 43.12–61.48 -> 43.12–61.48
`--no-lock-end` **removes** the key rather than writing `false`: these manifests
are read by humans, and `"lockEnd": false` reads like a decision.
+## `show` lists the sources and the snapshots
+
+One row per distinct `(channel, video)`: which clips use it, whether its cue
+file is present / missing / unpunctuated, **cue coverage** (the last cue's end
+against the latest clip end on that source — `CUE GAP: cues end 880 s · c03
+needs 897 s`), availability from the last recorded preflight with its age, and
+whether the cite target is derived or a per-clip `citeUrl` override (the Rumble
+two-ids case). Then every snapshot under `revisions/` and every legacy
+`video.manifest*.bak` / `video.manifest.<x>.json`, by mtime, marked `legacy`.
+
+## `export` derives what used to be hand-made
+
+`toc-bbcode` reproduces the shape of `quartering-gout/toc.bbcode.txt` row for
+row (`time · clip title · [url=<cite>]open[/url]`); `toc-markdown` the same as a
+pipe table; `description` is title, subtitle, `mm:ss — title — <cite>` rows and
+the source list; `chapters` is YouTube's `00:00 Title` form, every entry. Times
+come from `out/<variant>/chapters.ffmeta` (or the pre-variant
+`out/chapters.ffmeta`), else from `segmentOffsets()` over the segments; with
+neither it refuses and says why. Cite URLs follow the QR's rule: the per-clip
+`citeUrl` when set, else the derived viewer moment.
+
## `new` writes an EMPTY timeline, on purpose
It would be easy to derive first-draft clips from a report's citations — the shape
@@ -87,3 +113,19 @@ wrong, and every clip would have to be opened anyway.
So it writes what can be known, lists the citations it found as a **checklist**,
and leaves `siteOrigin` empty so `check` blocks until somebody sets it.
+
+`--from` is detected by **shape**, never guessed: a `.md` path is a report; a
+viewer share URL (`…/?v=<channel>%2F<id>&t=<s>`) or a bare `<channel>/<videoId>`
+is a video ref, which sets `provenance.channelSlug`, records one citation at
+`t`, and (from a share URL) takes the origin as `siteOrigin` unless
+`--site-origin` says otherwise. Anything else is an error.
+
+**`--seed chapters`** is the one exception to the empty timeline, and it
+invents nothing: with a video ref and `CHANNELS_DIR`, it reads
+`ai-digest.json` + `ai-digest.overrides.json` directly (later source wins per
+id, `enabled: false` drops), and writes one unlocked clip per chapter —
+`start` is the chapter's start, `end` the next chapter's, the last from
+`metadata.info.json`'s duration or else **omitted and reported**. The README
+lists them as "seeded from digest chapters — review each". It refuses when the
+video directory or the digest is missing. Local media files are not a source:
+the manifest model is archive ids with cue files.
diff --git a/umtool/docs/dashboard.md b/umtool/docs/dashboard.md
@@ -0,0 +1,36 @@
+# The dashboard (`/`)
+
+The front door. It used to be a doorway that put you back in the song pile you
+last judged; it is a page now, and the song piles are one nav entry (`song ▸`)
+at the URLs they always had.
+
+**Nothing on it shells out.** Every panel reads the index, the two in-memory
+job registries, or a cache. The one probe on the page is behind a button.
+
+| panel | what it reads | where it goes |
+|---|---|---|
+| **now** | `runningActivity()` — whichever of the two job registries (`lib/jobs.ts` chain jobs, `lib/mix.ts` renders) is busy, merged in `lib/activity.ts`; step `n/of` and per-entry state from the build's NDJSON events. A client poll of `/api/jobs` every 2 s **only while something runs**. Under it, the newest finished jobs. | the project page, or `/mix` |
+| **needs a decision** | `openDecisions()` — the same reducer as `/browse/decisions`; totals and the worst eight | `/browse/decisions` |
+| **projects** | `listProjects()` + `decisionCounts()`; one row per kind, the six states as proportional segments. The header string comes from `lib/dashboard.ts`, shared with `/browse`, so the two cannot disagree. One meaningful colour: `stale` is `--color-dirty`; every other state is neutral. | `/browse?kind=&state=` |
+| **sources** | each report project's summary attrs (`sources-checked-at`, `sources-dead`, `sources-missing`, `cue-gaps`), written by `lib/projects/report.mjs`: never checked / older than 30 d / with dead or missing sources. "Re-check" starts one `check-sources` job. | the project page |
+| **deliverables** | `attrs["final-bytes"]` (a report's `out/<slug>.mp4` + `-full.mp4`; a song's shipped cuts) — built count, bytes, newest, stale | `/browse?state=built` |
+| **tools & disk** | the doctor **cache** (`lib/doctor.ts`, ten minutes) or "not checked"; free bytes under `REPORTS_ROOT` via `statfs`; index health. The button `POST`s `/api/doctor`, which is the only place the app probes. Red only when the report pipeline needs a tool and it is absent. | — |
+| **recent** | the six newest projects by `newestMtimeMs`, as the same cards `/browse` draws | the project page |
+| **resume** | the last URL the recorder wrote (`components/LastPage.tsx`), HEAD-probed down to `/browse`, offered as a chip rather than navigated to | wherever you were |
+
+`?from=<channel>/<id>` opens the new-project menu prefilled — the editor's
+"Open in umtool" link (`UMTOOL_URL`) lands here.
+
+## `/api/jobs`
+
+`GET` → `{ running, jobs }`, one shape over both registries, `no-store`. The
+registries stay separate: their one-at-a-time rules differ (a chain is
+process-wide exclusive; a render is one ffmpeg call).
+
+## `umtool doctor`
+
+The CLI form of the tools panel: `umtool doctor [--json]`, exit 1 if the
+report pipeline is missing a tool it cannot run without (ffmpeg, ffprobe,
+yt-dlp, qrencode). ImageMagick is probed as `magick` — every call in the
+pipeline is IM7's `magick`, and a machine with only `convert` cannot build a
+card, which the report says.
diff --git a/umtool/docs/decisions.md b/umtool/docs/decisions.md
@@ -40,6 +40,7 @@ every future kind editing it to say a word only it uses.
| `clip-mid-sentence` | open | the cut lands inside a cue that does not close a sentence, and `lockEnd` is unset |
| `window-overlap` | open/info | two clips from one source overlap |
| `no-punctuation` | info | a source's ASR has no terminators — one row per project |
+| `clip-cue-gap` | open | the source's cue file **ends before a clip does** (`cues end 880 s · c03 needs 897 s`); one row per source, naming the clip that reaches furthest. The build does not die — it cuts from audio — but the tail has no words behind it; recover the transcript or shorten the window |
| `claim-unadjudicated` | blocking | a `ledger[]` entry missing any of the six adjudication fields |
| `claim-incoherent` | open | a fired coherence predicate, in plain words |
| `stale-build` | open | the output is older than the manifest |
diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md
@@ -261,3 +261,57 @@ at a frame** was the only thing that found it.
and the timeline disagree about what is in it.
- **`--chapters-only` is cheap and separate.** Retitling chapters does not need a
re-encode; the per-clip segments on disk are all the offsets need.
+
+## Sources are a view of the project
+
+`sourcesOf()` in `lib/projects/report.mjs` gives one row per distinct
+`(channel ?? provenance.channelSlug, video)`: the clips using it; the cue file's
+state (present / missing / no-punctuation); **cue coverage** — the last cue's
+end against the latest clip end, so a clip past the transcript's end reads as
+`cues end 880 s · c03 needs 897 s` instead of living in a `transcriptGapNote`;
+availability from `out/availability.json` with its age, or "never checked"; and
+the cite target — derived `${siteOrigin}/?v=…` or the per-clip `citeUrl`
+override, marked as such when it differs (every clip in one real manifest
+carries a `citeUrl`; only the Rumble ones point elsewhere). The project page,
+`umtool show` and the dashboard's Sources panel all read this one function.
+Nothing in it probes.
+
+## Revisions, instead of `.bak`
+
+`umtool snapshot <project> [--label <l>]` (or the button) copies the manifest
+byte-for-byte to `revisions/<YYYYMMDD-HHMMSS>[-<label>].manifest.json` — a
+subdirectory, in `SKIP_DIRS`, so the walk never mistakes it for a project. The
+legacy shapes on disk (`video.manifest.json.bak`, `video.manifest.json.<x>.bak`,
+`video.manifest.<x>.json`) are **listed** as unlabelled snapshots by mtime:
+reported, never renamed. Each is diffed against the current manifest by entry
+id — added / removed / moved / window (start, end, lock, source) / retyped /
+other field — by `lib/report/manifest-diff.mjs` (`umtool diff`). A build that
+stamps a deliverable aside records a snapshot of the manifest that produced it,
+labelled `built-<file>`, so the file and its description stay paired.
+
+## Notes have a home
+
+`README.md` renders at the top of the project page. Provenance splits by
+shape: scalars and URLs stay in the table; any string over ~120 characters or
+containing a newline (`coverage`, `transcriptGapNote`, `qrNote`, `revisionNote`,
+`asrCaveat`, …) reads as a titled paragraph under **Notes**. Other `*.md` files
+beside the manifest (`build-notes.md`, `ADJUDICATION-DIVERGENCE.md`) are listed
+there too. No field is moved or rewritten.
+
+## Exports
+
+`umtool export <project> --format toc-bbcode|toc-markdown|description|chapters`
+and the Export row on the project page (one copy button per format, per
+variant with anything on disk). All derived from the manifest plus the build's
+offsets — `out/<variant>/chapters.ffmeta` when present, else `segmentOffsets()`
+over the segments, else a refusal with the reason. `toc-bbcode` reproduces the
+hand-made `toc.bbcode.txt` shape; `description` adds the source list; `chapters`
+is YouTube's form and includes cards. Cite URLs follow the QR's rule.
+
+## Re-checking sources across projects
+
+`check-sources` is one `check-availability.mjs --allow-missing` step per
+selected project — the driver's step 1, reused — through `startJob`, so it
+serialises with builds and is cancellable. The dashboard's Sources panel starts
+it over the never-checked and older-than-30-day projects; the project page over
+one; `umtool check-sources` prints the chain.
diff --git a/umtool/e2e/browse.spec.ts b/umtool/e2e/browse.spec.ts
@@ -332,7 +332,8 @@ test("a body outside the roots is refused, and an unknown recipe with it", async
// ---------------------------------------------------------------------------
test("a new song gets the shape but NO cut files", async ({ request, page }) => {
- const r = await request.post("/api/browse/init", { data: { song: "gamma", title: "Gamma" } });
+ // Through the one "new project" door, which took over from /api/browse/init.
+ const r = await request.post("/api/projects/new", { data: { kind: "song", slug: "gamma", title: "Gamma" } });
expect(r.ok()).toBe(true);
expect((await r.json()).href).toBe("/browse/gamma");
@@ -343,11 +344,14 @@ test("a new song gets the shape but NO cut files", async ({ request, page }) =>
}
// Same name twice, and names that are not names.
- expect((await request.post("/api/browse/init", { data: { song: "gamma" } })).status()).toBe(409);
+ expect((await request.post("/api/projects/new", { data: { kind: "song", slug: "gamma" } })).status()).toBe(409);
for (const bad of ["../escape", "a/b", "", ".hidden"]) {
- const res = await request.post("/api/browse/init", { data: { song: bad } });
+ const res = await request.post("/api/projects/new", { data: { kind: "song", slug: bad } });
expect(res.ok(), bad).toBe(false);
}
+ // A kind that cannot be scaffolded, and one nobody registered.
+ expect((await request.post("/api/projects/new", { data: { kind: "sweep-report", slug: "nope" } })).ok()).toBe(false);
+ expect((await request.post("/api/projects/new", { data: { kind: "no-such-kind", slug: "nope" } })).ok()).toBe(false);
});
test("notes attach to anything selectable, including things that do not exist yet", async ({
diff --git a/umtool/e2e/build.spec.ts b/umtool/e2e/build.spec.ts
@@ -68,6 +68,32 @@ test("a dry run prints the exact chain, and runs nothing", async ({ request }) =
expect(j.steps[2].timeoutMs).toBeGreaterThanOrEqual(15 * 60_000);
});
+test("the options the driver used to hide land in the argv", async ({ request }) => {
+ const dry = (o: Record<string, unknown>) =>
+ request.post("/api/report/build?dry=1", { data: { project: PROJECT, preset: "final", options: o } });
+
+ // chapters-only skips the preflight and the resolve: neither touches a source.
+ const co = (await (await dry({ chaptersOnly: true })).json()) as { steps: { argv: string[]; timeoutMs: number }[] };
+ expect(co.steps[0].argv).toContain("--chapters-only");
+ expect(co.steps.map((s) => s.argv.join(" ")).join("\n")).not.toContain("check-availability.mjs");
+ expect(co.steps[0].timeoutMs).toBe(5 * 60_000);
+
+ const full = (await (await dry({ variant: "full", xfade: false })).json()) as { steps: { argv: string[] }[] };
+ const build = full.steps.find((s) => s.argv.join(" ").includes("build-video.mjs"))!;
+ expect(build.argv.slice(build.argv.indexOf("--variant"), build.argv.indexOf("--variant") + 2)).toEqual(["--variant", "full"]);
+ expect(build.argv).toContain("--no-xfade");
+ // verify is told which cut to measure.
+ expect(full.steps.at(-1)!.argv).toContain("full");
+
+ const pv = (await (await dry({ preview: { at: 4, dur: 10 } })).json()) as { steps: { argv: string[] }[] };
+ expect(pv.steps).toHaveLength(1);
+ expect(pv.steps[0].argv.join(" ")).toContain("--preview 4 10");
+
+ // An unknown variant is refused before anything runs.
+ expect((await dry({ variant: "nope" })).status()).toBe(400);
+ expect((await dry({ chaptersOnly: true, preview: { at: 0, dur: 1 } })).status()).toBe(400);
+});
+
test("a build runs end to end, offline, and the file is verified", async ({ request }) => {
const start = await request.post("/api/report/build", {
data: { project: PROJECT, preset: "fast" },
diff --git a/umtool/e2e/dashboard.spec.ts b/umtool/e2e/dashboard.spec.ts
@@ -0,0 +1,134 @@
+import { test, expect } from "@playwright/test";
+import { execFileSync } from "node:child_process";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// The front door. Everything on it is read from the index and the job
+// registries; nothing on it shells out, and the tools panel proves that by
+// saying "not checked" until somebody presses the button.
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const UMTOOL = path.join(HERE, "..");
+const FIXTURE = path.join(UMTOOL, ".e2e-song");
+
+test("the projects panel says exactly what /browse's header says", async ({ page }) => {
+ await page.goto("/browse");
+ const header = (await page.locator("header .micro").last().textContent())?.trim();
+ expect(header).toMatch(/^\d+ projects · \d+ blocking · \d+ open$/);
+
+ await page.goto("/");
+ await expect(page.locator("[data-projects-note]")).toHaveText(header!);
+ // One row per kind, each with the six states as segments.
+ await expect(page.locator("[data-state-row]").first()).toBeVisible();
+ const seg = page.locator("[data-state-seg]").first();
+ await expect(seg).toHaveAttribute("href", /\/browse\?kind=.+&state=\w+/);
+});
+
+test("tools read 'not checked' until the button is pressed, and the stubs are found", async ({ page, request }) => {
+ const before = await (await request.get("/api/doctor")).json();
+ // Runs first in this file, and nothing else in the suite probes.
+ expect(before.checked).toBe(false);
+
+ await page.goto("/");
+ await expect(page.locator("[data-tools-checked]")).toHaveAttribute("data-tools-checked", "never");
+ await page.locator("[data-action=doctor]").click();
+ // The fixture's YTDLP_BIN and QRENCODE_BIN are node stubs that answer
+ // --version, so both are PRESENT with a version -- proof the probe read the
+ // same env the pipeline does.
+ await expect(page.locator("[data-tool=yt-dlp]")).toHaveAttribute("data-present", "1");
+ await expect(page.locator("[data-tool=qrencode]")).toHaveAttribute("data-present", "1");
+ await expect(page.locator("[data-tool=ffmpeg]")).toHaveAttribute("data-present", "1");
+
+ const after = await (await request.get("/api/doctor")).json();
+ expect(after.checked).toBe(true);
+ expect(after.tools.find((t: { id: string }) => t.id === "yt-dlp").version).toContain("fixture");
+});
+
+test("umtool doctor reports a deliberately bad path as absent, and exits 1", () => {
+ let code = 0;
+ let stdout = "";
+ try {
+ stdout = execFileSync("node", ["bin/umtool.mjs", "doctor", "--json"], {
+ cwd: UMTOOL,
+ encoding: "utf8",
+ env: { ...process.env, YTDLP_BIN: path.join(FIXTURE, "bin", "definitely-not-here"), QRENCODE_BIN: path.join(FIXTURE, "bin", "qrencode") },
+ });
+ } catch (e) {
+ const err = e as { status: number; stdout: string };
+ code = err.status;
+ stdout = err.stdout;
+ }
+ const j = JSON.parse(stdout);
+ const yt = j.tools.find((t: { id: string }) => t.id === "yt-dlp");
+ expect(yt.present).toBe(false);
+ expect(yt.required).toBe(true);
+ expect(j.ok).toBe(false);
+ // A script can gate on it: the report pipeline cannot run without yt-dlp.
+ expect(code).toBe(1);
+ expect(j.tools.find((t: { id: string }) => t.id === "qrencode").present).toBe(true);
+});
+
+test("/api/jobs has one shape over both registries", async ({ request }) => {
+ const j = await (await request.get("/api/jobs")).json();
+ expect(j).toHaveProperty("running");
+ expect(Array.isArray(j.jobs)).toBe(true);
+ for (const it of j.jobs) {
+ expect(["chain", "render"]).toContain(it.source);
+ expect(["running", "done", "failed"]).toContain(it.state);
+ expect(typeof it.href).toBe("string");
+ }
+});
+
+test("a build shows in Now, then in the activity list as done", async ({ page, request }) => {
+ const start = await request.post("/api/report/build", {
+ data: { project: "reports/dash-fixture", preset: "fast" },
+ });
+ expect(start.ok()).toBeTruthy();
+ const { job } = (await start.json()) as { job: { id: string } };
+
+ // Poll /api/jobs rather than the page: the offline build is ~3 s, and a
+ // page load can land on either side of it. What must be true is that the
+ // job is visible through the ONE endpoint while it runs, with its step.
+ let seenRunning = false;
+ for (let i = 0; i < 100; i += 1) {
+ const j = (await (await request.get("/api/jobs")).json()) as {
+ running: { id: string; progress?: { step: number; of: number } } | null;
+ jobs: { id: string; state: string; href: string }[];
+ };
+ if (j.running?.id === job.id) {
+ seenRunning = true;
+ expect(j.running.progress?.of).toBeGreaterThan(0);
+ }
+ const mine = j.jobs.find((x) => x.id === job.id);
+ if (mine && mine.state !== "running") {
+ expect(mine.state).toBe("done");
+ expect(mine.href).toBe("/browse/reports/dash-fixture");
+ break;
+ }
+ await new Promise((r) => setTimeout(r, 200));
+ }
+ expect(seenRunning).toBe(true);
+
+ await page.goto("/");
+ const row = page.locator(`[data-activity-id="${job.id}"]`);
+ await expect(row).toHaveAttribute("data-activity-state", "done");
+ await expect(row.getByRole("link")).toHaveAttribute("href", "/browse/reports/dash-fixture");
+ await expect(page.locator("[data-now=idle]")).toBeVisible();
+});
+
+test("the sources panel counts never-checked projects from the index, not from a probe", async ({ page }) => {
+ await page.goto("/");
+ const counts = await page.locator("[data-sources-counts]").getAttribute("data-sources-counts");
+ const [never] = counts!.split("/").map(Number);
+ // The fixture has report projects nothing has preflighted (report-fixture,
+ // bench-fixture, the long-form ones); at least one is never-checked.
+ expect(never).toBeGreaterThan(0);
+ await expect(page.locator("[data-sources-list=never] [data-source-project='reports/longform-fixture']")).toBeVisible();
+ await expect(page.locator("[data-action=check-sources]")).toBeEnabled();
+});
+
+test("the dashboard opens the new-project menu prefilled from ?from=", async ({ page }) => {
+ await page.goto("/?from=testchan%2Fvid1");
+ await expect(page.locator("[data-new-from]")).toHaveValue("testchan/vid1");
+ await expect(page.locator("[data-new-seed]")).toBeVisible();
+});
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -649,6 +649,13 @@ const CUES = {
[0, 3, "This upload has since been deleted."],
[3, 6, "But its transcript is still in the archive."],
],
+ // Cited by the long-form fixtures: a transcript that STOPS at 6 s under a
+ // clip that runs to 12. The real case is quartering-gout's c03 (861–897 s on
+ // cues that ended at 880), recorded only in prose until the Sources view.
+ short1: [
+ [0, 3, "The transcript covers this much."],
+ [3, 6, "And then it simply stops."],
+ ],
vid2: [
[0, 3, "no punctuation anywhere in this upload"],
[3, 6, "the asr never emitted a full stop"],
@@ -856,6 +863,10 @@ import path from "node:path";
const argv = process.argv.slice(2);
const all = argv.join(" ");
+if (argv.includes("--version")) {
+ process.stdout.write("2026.01.01-fixture\\n");
+ process.exit(0);
+}
if (all.includes("gone1")) {
process.stderr.write("ERROR: [youtube] gone1: Video unavailable. This video has been removed by the uploader\\n");
process.exit(1);
@@ -895,6 +906,10 @@ import { mkdirSync } from "node:fs";
import path from "node:path";
const argv = process.argv.slice(2);
+if (argv.includes("--version")) {
+ process.stdout.write("qrencode version 0.0.0-fixture\\n");
+ process.exit(0);
+}
const i = argv.indexOf("-o");
if (i < 0) process.exit(2);
const out = argv[i + 1];
@@ -984,6 +999,60 @@ copyFileSync(
path.join(reports, "no-origin-fixture", "out", "no-origin-fixture.mp4"),
);
+// -- the long-form pass: sources, revisions, notes, exports -------------------
+//
+// longform-fixture READ-ONLY. c02 cites short1 (cues end 6 s) to 12 s ->
+// one clip-cue-gap; its citeUrl points elsewhere -> a
+// cite override; a legacy .bak beside the manifest; a
+// hand-written chapters.ffmeta so `export` has the
+// build's offsets without a build; a build-notes.md
+// longform-edit-fixture the snapshot / window / diff specs WRITE here
+// dash-fixture the dashboard spec BUILDS this one (offline, ~3 s)
+const LONG = writeProject(
+ "longform-fixture",
+ {
+ ...manifest("longform-fixture", "The Longform Fixture", { siteOrigin: "https://archive.example", coverage: "A long provenance note, well over one hundred and twenty characters, so the project page has to render it as a paragraph under Notes rather than in the scalar table." }, [
+ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because", chapter: "First chapter" },
+ { type: "clip", id: "c02", video: "short1", start: 2.0, end: 12.0, cite: 2, section: 0, lock: true, quote: "stops", chapter: "Second chapter", citeUrl: "https://mirror.example/watch?v=other&t=2" },
+ ]),
+ },
+);
+// The legacy shape, as employee-count and ferret-rescue have it: c02 was
+// shorter when this copy was taken, so the diff must report ONE window change.
+{
+ const cur = JSON.parse(readFileSync(path.join(LONG, "video.manifest.json"), "utf8"));
+ cur.timeline[1].end = 9.0;
+ writeFileSync(path.join(LONG, "video.manifest.json.bak"), JSON.stringify(cur, null, 2) + "\n");
+}
+writeFileSync(path.join(LONG, "build-notes.md"), "# Build notes\n\nThe second clip outruns its transcript on purpose.\n");
+mkdirSync(path.join(LONG, "out"), { recursive: true });
+writeFileSync(
+ path.join(LONG, "out", "chapters.ffmeta"),
+ [";FFMETADATA1", "", "[CHAPTER]", "TIMEBASE=1/1000", "START=0", "END=3400", "title=First chapter", "",
+ "[CHAPTER]", "TIMEBASE=1/1000", "START=3400", "END=13800", "title=Second chapter", ""].join("\n"),
+);
+
+writeProject(
+ "longform-edit-fixture",
+ manifest("longform-edit-fixture", "The Longform Edit Fixture", { siteOrigin: "https://archive.example" }, [
+ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because" },
+ { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another" },
+ ]),
+);
+
+const DASH = writeProject(
+ "dash-fixture",
+ manifest("dash-fixture", "The Dashboard Fixture", { siteOrigin: "https://archive.example" }, [
+ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because" },
+ { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence" },
+ ]),
+);
+mkdirSync(path.join(DASH, "out", "clips-raw"), { recursive: true });
+copyFileSync(
+ path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"),
+ path.join(DASH, "out", "clips-raw", "vid1_0.00-9.00.mp4"),
+);
+
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)`);
@@ -1002,5 +1071,6 @@ console.log(` YTDLP_BIN=${path.join(BIN, "yt-dlp")} QRENCODE_BIN=${path.join(BI
console.log(` CHANNELS_DIR=${CHANNELS} (testchan/vid1 punctuated, vid2 not)`);
console.log(` projects: report-fixture (4 clips, 1 mid-sentence), no-origin-fixture,`);
console.log(` localhost-fixture, bike-fixture (sweep), find/ (shadowed),`);
-console.log(` deep/nested/solo-fixture (collapse case), bench-fixture (writable)`);
+console.log(` deep/nested/solo-fixture (collapse case), bench-fixture (writable),`);
+console.log(` longform-fixture (cue gap, legacy .bak, ffmeta), longform-edit-fixture, dash-fixture`);
console.log(` ${taken} candidate files copied, 2 mix tracks synthesised`);
diff --git a/umtool/e2e/last-page.spec.ts b/umtool/e2e/last-page.spec.ts
@@ -1,28 +1,20 @@
import { test, expect, type Page } from "@playwright/test";
-import { DEFAULT_PAGE, LAST_PAGE_KEY } from "../lib/last-page";
+import { LAST_PAGE_KEY } from "../lib/last-page";
-// / used to be redirect("/sort"). It now comes back to where you were, and
-// these cover the four ways that can go wrong: nothing remembered, a URL with a
-// query on it, a URL that has gone stale, and a URL somebody hand-edited into
-// storage.
+// / is the dashboard now. It used to REPLACE itself with wherever you were
+// last; it OFFERS that URL as a chip instead, with the same storage, the same
+// guard and the same HEAD-probed walk-up -- so a stale URL degrades to its
+// parent, and a hostile one is not offered at all.
//
// The suite runs workers: 1 with NO state reset between tests, so a remembered
-// URL leaks forward unless each test says what storage holds. Every test here
-// does -- either by planting a value or by letting the recorder write its own.
+// URL leaks forward unless each test says what storage holds.
/**
- * Plant (or clear) the remembered URL, applied ON THE DOORWAY DOCUMENT ONLY.
+ * Plant (or clear) the remembered URL, applied ON THE DASHBOARD DOCUMENT ONLY.
*
- * The obvious version -- goto a page, setItem, goto "/" -- silently does not
- * work, and passes for the wrong reason. The recorder also writes on
- * `pagehide`, so the moment the test navigates to /, the page it is leaving
- * overwrites the planted value with its own URL. A stale-URL test seeded that
- * way would be testing that /browse restores to /browse.
- *
- * An init script runs at document start, which is after the old page's pagehide
- * and before the restore effect reads storage -- so it is the one place a value
- * can be planted and still be there when it is read. Guarded to `/` so the
- * recorder stays free to do its real job on every other page.
+ * The recorder also writes on `pagehide`, so navigating to / from another page
+ * overwrites a value planted earlier. An init script runs at document start --
+ * after the old page's pagehide and before the chip's effect reads storage.
*/
async function remembered(page: Page, value: string | null) {
await page.addInitScript(
@@ -35,76 +27,58 @@ async function remembered(page: Page, value: string | null) {
);
}
-/** Which nav item is lit -- the cheapest "where am I", as triage.spec uses. */
-function lit(page: Page, label: string) {
- return page.getByRole("link", { name: label, exact: true });
-}
+const chip = (page: Page) => page.locator("[data-resume]");
-test("with nothing remembered, / still lands on the sort pile", async ({ page }) => {
+test("/ is the dashboard, and with nothing remembered there is no chip", async ({ page }) => {
await remembered(page, null);
- // Parked on /browse first, so a restore that read the wrong thing would land
- // there and fail, instead of this passing because / happens to point at /sort.
- await page.goto("/browse");
- await expect(lit(page, "browse")).toHaveAttribute("aria-current", "page");
-
- await page.goto("/");
-
- await expect(page).toHaveURL(new RegExp(`${DEFAULT_PAGE}$`));
- await expect(lit(page, "sort")).toHaveAttribute("aria-current", "page");
-});
-
-test("/ comes back to the page you were last on", async ({ page }) => {
- // No planted value: this is the recorder's OWN write, which is the thing
- // actually shipping.
- await page.goto("/browse/find");
- await expect(lit(page, "find")).toHaveAttribute("aria-current", "page");
-
await page.goto("/");
-
- await expect(page).toHaveURL(/\/browse\/find$/);
- await expect(lit(page, "find")).toHaveAttribute("aria-current", "page");
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByRole("link", { name: "home", exact: true })).toHaveAttribute("aria-current", "page");
+ await expect(page.locator("[data-panel=now]")).toBeVisible();
+ // Give the effect time to have run before asserting absence.
+ await expect(page.locator("[data-panel=projects]")).toBeVisible();
+ await expect(chip(page)).toHaveCount(0);
});
-test("the query string comes back with it", async ({ page }) => {
- // The reason the whole URL is remembered rather than the pathname: a search
- // you have to retype is not a page you came back to.
+test("the chip offers the page you were last on, query string included", async ({ page }) => {
+ // No planted value: this is the recorder's OWN write, which is what ships.
await page.goto("/sort?limit=5");
- await expect(lit(page, "sort")).toHaveAttribute("aria-current", "page");
-
await page.goto("/");
-
+ await expect(chip(page)).toHaveAttribute("data-resume", "/sort?limit=5");
+ await chip(page).click();
await expect(page).toHaveURL(/\/sort\?limit=5$/);
});
-test("a remembered URL that has gone stale walks UP instead of 404ing", async ({ page }) => {
- // A cut of a song that does not exist -- what a rename or a rebuild leaves
- // behind. This app has no not-found.tsx, so landing on it would mean Next's
- // bare 404: no nav on it, and no way out but the back button. It must degrade
- // one level at a time to /browse.
+test("a remembered URL that has gone stale walks UP instead of offering a 404", async ({ page }) => {
await remembered(page, "/browse/__gone__/wide");
-
await page.goto("/");
-
- await expect(page).toHaveURL(/\/browse$/);
- await expect(lit(page, "browse")).toHaveAttribute("aria-current", "page");
- // The assertion that says "not a dead end": the 404 has no nav at all.
- await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toBeVisible();
-
- // And the URL really is dead -- otherwise this would pass for the wrong
- // reason the day that route quietly started rendering something.
+ await expect(chip(page)).toHaveAttribute("data-resume", "/browse");
+ // And the URL really is dead -- otherwise this passes for the wrong reason
+ // the day that route quietly starts rendering something.
expect((await page.request.get("/browse/__gone__/wide")).status()).toBe(404);
});
for (const hostile of ["//evil.example", "https://evil.example", "/api/verdict", "/"]) {
- test(`a stored ${hostile} is ignored, not navigated to`, async ({ page }) => {
- // Storage is user-editable and outlives any deploy, so a value read back is
- // untrusted input. Two of these leave the machine, one is not a page, and
- // one is this page -- which would loop.
+ test(`a stored ${hostile} is not offered`, async ({ page }) => {
+ // Storage is user-editable and outlives any deploy, so a value read back
+ // is untrusted input. Two of these leave the machine, one is not a page,
+ // and one is this page.
await remembered(page, hostile);
-
await page.goto("/");
-
- await expect(page).toHaveURL(new RegExp(`${DEFAULT_PAGE}$`));
- await expect(lit(page, "sort")).toHaveAttribute("aria-current", "page");
+ await expect(page.locator("[data-panel=projects]")).toBeVisible();
+ await expect(chip(page)).toHaveCount(0);
});
}
+
+test("the song piles are one nav entry, and every pile URL still works", async ({ page }) => {
+ await page.goto("/");
+ await page.locator("[data-nav-group=song] button").click();
+ await page.getByRole("menuitem", { name: "sort" }).click();
+ await expect(page).toHaveURL(/\/sort$/);
+ // On a pile page the group itself is lit, since the pile is under it.
+ await expect(page.locator("[data-nav-group=song] button")).toHaveAttribute("aria-current", "page");
+ for (const p of ["/salvage", "/verify", "/keeps", "/browse/sources"]) {
+ const res = await page.goto(p);
+ expect(res?.status(), `${p} should still be 200`).toBe(200);
+ }
+});
diff --git a/umtool/e2e/report-longform.spec.ts b/umtool/e2e/report-longform.spec.ts
@@ -0,0 +1,176 @@
+import { test, expect } from "@playwright/test";
+import { execFileSync } from "node:child_process";
+import { existsSync, readFileSync, rmSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// ---------------------------------------------------------------------------
+// The long-form pass: sources, revisions, notes and exports of a report video.
+//
+// longform-fixture read-only: a clip past the end of its transcript, a
+// citeUrl override, a legacy .bak, a hand-written
+// chapters.ffmeta, a build-notes.md
+// longform-edit-fixture the snapshot / edit / diff round trip WRITES here
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const UMTOOL = path.join(HERE, "..");
+const FIXTURE = path.join(UMTOOL, ".e2e-song");
+const cliEnv = {
+ ...process.env,
+ SONG_REPORTS_DIR: path.join(FIXTURE, "reports"),
+ SONG_DIR: path.join(FIXTURE, "data"),
+ CHANNELS_DIR: path.join(FIXTURE, "channels"),
+ YTDLP_BIN: path.join(FIXTURE, "bin", "yt-dlp"),
+};
+const umtool = (args: string[]) =>
+ execFileSync("node", ["bin/umtool.mjs", ...args], { cwd: UMTOOL, encoding: "utf8", env: cliEnv });
+
+test("a clip that outruns its transcript is a cue gap, on the page and in the inbox", async ({ page }) => {
+ await page.goto("/browse/reports/longform-fixture");
+ const row = page.locator("[data-source='testchan/short1']");
+ await expect(row).toHaveAttribute("data-cue-gap", "c02");
+ await expect(row).toContainText("cues end 6 s · c02 needs 12 s");
+ // The Rumble case: a citeUrl that points somewhere other than the derived moment.
+ await expect(row).toHaveAttribute("data-cite-override", "1");
+ await expect(page.locator("[data-source='testchan/vid1']")).toHaveAttribute("data-cite-override", "0");
+ await expect(page.locator("[data-source='testchan/vid1']")).toHaveAttribute("data-cue-gap", "");
+
+ await expect(page.locator("[data-decision='clip-cue-gap'][data-target=c02]")).toHaveCount(1);
+ await expect(page.locator("[data-decision='clip-cue-gap'][data-target=c02]")).toHaveAttribute("data-severity", "open");
+});
+
+test("umtool show prints the same sources table", () => {
+ const j = JSON.parse(umtool(["show", "longform-fixture", "--json"]));
+ const short = j.sources.find((r: { key: string }) => r.key === "testchan/short1");
+ expect(short.coverageGap).toEqual({ clip: "c02", needs: 12, cuesEnd: 6 });
+ expect(short.cite.differs).toBe(true);
+ expect(short.availability).toBeNull();
+ expect(j.attrs["cue-gaps"]).toBe("1");
+ expect(j.attrs["sources-checked-at"]).toBeUndefined();
+});
+
+test("a legacy .bak is listed as a snapshot and diffed, never renamed", async ({ page }) => {
+ await page.goto("/browse/reports/longform-fixture");
+ const bak = page.locator("[data-snapshot='video.manifest.json.bak']");
+ await expect(bak).toHaveAttribute("data-legacy", "1");
+ // The .bak had c02 ending at 9; the manifest has 12: exactly one change.
+ await expect(bak).toHaveAttribute("data-diff", "1");
+ await expect(bak).toContainText("1 window");
+ expect(existsSync(path.join(FIXTURE, "reports", "longform-fixture", "video.manifest.json.bak"))).toBe(true);
+ expect(existsSync(path.join(FIXTURE, "reports", "longform-fixture", "revisions"))).toBe(false);
+});
+
+test("README and sibling notes render, and long provenance reads as prose", async ({ page }) => {
+ await page.goto("/browse/reports/longform-fixture");
+ await expect(page.locator("[data-note=coverage]")).toContainText("well over one hundred and twenty");
+ await expect(page.locator("[data-note-file='build-notes.md']")).toBeVisible();
+ await page.locator("[data-note-file='build-notes.md'] summary").click();
+ await expect(page.locator("[data-note-file='build-notes.md']")).toContainText("outruns its transcript");
+ // siteOrigin is a scalar and stays in the table.
+ await expect(page.locator("[data-provenance]")).toContainText("https://archive.example");
+});
+
+test("snapshot, then a window edit, then the diff shows that one change", async ({ page, request }) => {
+ const project = "reports/longform-edit-fixture";
+ const snap = await request.post("/api/report/snapshot", { data: { project, label: "before-edit" } });
+ expect(snap.ok()).toBeTruthy();
+ const { rel } = (await snap.json()) as { rel: string };
+ expect(rel).toMatch(/^revisions\/\d{8}-\d{6}-before-edit\.manifest\.json$/);
+
+ // Nothing changed yet.
+ const same = await (await request.get(`/api/report/snapshot?project=${project}&diff=${encodeURIComponent(rel)}`)).json();
+ expect(same.same).toBe(true);
+
+ umtool(["window", "longform-edit-fixture", "c02", "--end", "13"]);
+
+ const d = await (await request.get(`/api/report/snapshot?project=${project}&diff=${encodeURIComponent(rel)}`)).json();
+ expect(d.same).toBe(false);
+ expect(d.changes).toHaveLength(1);
+ expect(d.changes[0]).toMatchObject({ kind: "window", id: "c02", fields: ["end"] });
+ expect(d.changes[0].from.end).toBe(12);
+ expect(d.changes[0].to.end).toBe(13);
+
+ await page.goto(`/browse/${project}`);
+ const row = page.locator(`[data-snapshot="${rel}"]`);
+ await expect(row).toHaveAttribute("data-diff", "1");
+ await expect(row).toHaveAttribute("data-legacy", "0");
+
+ // The CLI reads the same diff.
+ const out = umtool(["diff", "longform-edit-fixture", rel]);
+ expect(out).toContain("c02 window: 9–12 -> 9–13");
+
+ // A snapshot directory never reads as a project.
+ const ids = JSON.parse(umtool(["ls", "--json"])).map((r: { id: string }) => r.id);
+ expect(ids.filter((id: string) => id.includes("revisions"))).toEqual([]);
+});
+
+test("export toc-bbcode matches the golden string, from the build's own offsets", async ({ request }) => {
+ const golden = [
+ "[b]The Longform Fixture — a fixture[/b]",
+ "",
+ "[table]",
+ "[tr][th]Time[/th][th]Clip[/th][th]Source[/th][/tr]",
+ "[tr][td]0:00[/td][td]First chapter[/td][td][url=https://archive.example/?v=testchan%2Fvid1&t=3]open[/url][/td][/tr]",
+ "[tr][td]0:03[/td][td]Second chapter[/td][td][url=https://mirror.example/watch?v=other&t=2]open[/url][/td][/tr]",
+ "[/table]",
+ "",
+ ].join("\n");
+ expect(umtool(["export", "longform-fixture", "--format", "toc-bbcode"])).toBe(golden);
+
+ const r = await request.get("/api/report/export?project=reports/longform-fixture&format=toc-bbcode");
+ expect(r.ok()).toBeTruthy();
+ expect(r.headers()["x-offsets"]).toBe("ffmeta");
+ expect(await r.text()).toBe(golden);
+
+ const chapters = await (await request.get("/api/report/export?project=reports/longform-fixture&format=chapters")).text();
+ expect(chapters).toBe("00:00 First chapter\n00:03 Second chapter\n");
+
+ // Nothing built: refused with the reason, not an empty table.
+ const none = await request.get("/api/report/export?project=reports/longform-edit-fixture&format=toc-bbcode");
+ expect(none.status()).toBe(409);
+ expect(await none.text()).toContain("nothing built");
+});
+
+test("check-sources over two projects writes both availability files, through the job runner", async ({ request }) => {
+ const projects = ["reports/longform-fixture", "reports/longform-edit-fixture"];
+ for (const p of projects) rmSync(path.join(FIXTURE, p, "out", "availability.json"), { force: true });
+
+ const dry = await (await request.post("/api/report/check-sources?dry=1", { data: { projects } })).json();
+ expect(dry.steps).toHaveLength(2);
+ expect(dry.steps[0].argv.join(" ")).toContain("check-availability.mjs");
+ expect(dry.steps[0].argv).toContain("--allow-missing");
+
+ const start = await request.post("/api/report/check-sources", { data: { projects } });
+ expect(start.ok()).toBeTruthy();
+ const { job } = (await start.json()) as { job: { id: string } };
+ const until = Date.now() + 60_000;
+ let state = "running";
+ while (Date.now() < until) {
+ const j = (await (await request.get(`/api/report/build?job=${job.id}`)).json()) as { state: string; error: string | null };
+ state = j.state;
+ if (state !== "running") {
+ expect(j.error).toBeNull();
+ break;
+ }
+ await new Promise((r) => setTimeout(r, 300));
+ }
+ expect(state).toBe("done");
+ for (const p of projects) {
+ const file = path.join(FIXTURE, p, "out", "availability.json");
+ expect(existsSync(file), file).toBe(true);
+ const doc = JSON.parse(readFileSync(file, "utf8"));
+ expect(doc.sources.length).toBeGreaterThan(0);
+ }
+
+ // And the index sees it: the project is no longer "never checked".
+ const j = JSON.parse(umtool(["show", "longform-fixture", "--json"]));
+ expect(Number(j.attrs["sources-checked-at"])).toBeGreaterThan(0);
+ const short = j.sources.find((r: { key: string }) => r.key === "testchan/short1");
+ expect(short.availability.ok).toBe(true);
+});
+
+test("a song is not something check-sources can preflight", async ({ request }) => {
+ const r = await request.post("/api/report/check-sources", { data: { projects: ["reports/videos/alpha"] } });
+ expect(r.status()).toBe(400);
+});
diff --git a/umtool/lib/activity-types.ts b/umtool/lib/activity-types.ts
@@ -0,0 +1,3 @@
+// Types only, for the client panel. The values live in lib/activity.ts, which
+// reaches both job registries and therefore `node:child_process`.
+export type { ActivityItem, ActivityProgress } from "./activity";
diff --git a/umtool/lib/activity.ts b/umtool/lib/activity.ts
@@ -0,0 +1,110 @@
+import path from "node:path";
+import { recentJobs as recentChainJobs, runningJob, type Job } from "./jobs";
+import { recentJobs as recentRenders, type RenderJob } from "./mix";
+import { labelFor } from "./paths";
+
+// ---------------------------------------------------------------------------
+// One activity list over the two job registries.
+//
+// Chain jobs (lib/jobs.ts: report builds, clip fetches, the song recipes) and
+// mix renders (lib/mix.ts) are kept in SEPARATE registries on purpose -- their
+// one-at-a-time rules differ: a chain is process-wide exclusive because two
+// builds would interleave in one out/, while a render is a single ffmpeg call
+// that does not care what else runs. This file does not merge the registries;
+// it merges their VIEWS, so the dashboard has one "now" and one "recent".
+//
+// SERVER ONLY, like everything that reaches the registries.
+// ---------------------------------------------------------------------------
+
+export type ActivityProgress = {
+ /** 1-based step, of how many. */
+ step: number;
+ of: number;
+ /** Timeline entries done / total, from the build's NDJSON events, when known. */
+ clipsDone?: number;
+ clipsOf?: number;
+ /** Which entries the events have touched, and what state each is in. */
+ entries?: Record<string, "fetching" | "cutting" | "done" | "failed">;
+};
+
+export type ActivityItem = {
+ id: string;
+ source: "chain" | "render";
+ label: string;
+ state: "running" | "done" | "failed";
+ startedAt: number;
+ endedAt: number | null;
+ error: string | null;
+ progress?: ActivityProgress;
+ /** Where to go to see it: the project page for a chain, /mix for a render. */
+ href: string;
+};
+
+/**
+ * Per-entry state from a build's events, the same vocabulary
+ * ReportBuildChain.tsx paints its clip boxes from. Kept in one place so the
+ * dashboard and the project page cannot disagree about what "done" means.
+ */
+export function entryStates(events: Record<string, unknown>[]): ActivityProgress["entries"] {
+ const out: NonNullable<ActivityProgress["entries"]> = {};
+ for (const e of events) {
+ const id = typeof e.id === "string" ? e.id : null;
+ if (!id) continue;
+ const ev = e.ev;
+ if (ev === "fetch") out[id] = e.cached ? "cutting" : "fetching";
+ else if (ev === "clip" || ev === "card" || ev === "snap") out[id] = "cutting";
+ else if (ev === "segment") out[id] = "done";
+ else if (ev === "entry-failed") out[id] = "failed";
+ }
+ return out;
+}
+
+function fromChain(j: Job): ActivityItem {
+ const entries = entryStates(j.events);
+ const start = j.events.find((e) => e.ev === "start") as { entries?: number } | undefined;
+ const clipsOf = typeof start?.entries === "number" ? start.entries : undefined;
+ const clipsDone = Object.values(entries ?? {}).filter((s) => s === "done").length;
+ return {
+ id: j.id,
+ source: "chain",
+ label: j.kind,
+ state: j.state,
+ startedAt: j.startedAt,
+ endedAt: j.endedAt,
+ error: j.error,
+ progress: {
+ step: Math.min(j.stepIndex + 1, j.steps.length),
+ of: j.steps.length,
+ ...(clipsOf !== undefined ? { clipsOf, clipsDone } : {}),
+ entries,
+ },
+ href: j.project ? `/browse/${j.project}` : "/browse",
+ };
+}
+
+function fromRender(r: RenderJob): ActivityItem {
+ return {
+ id: r.id,
+ source: "render",
+ label: `render ${r.label || labelFor(r.out) || path.basename(r.out)}`,
+ state: r.state,
+ startedAt: r.startedAt,
+ endedAt: r.endedAt,
+ error: r.error,
+ href: "/mix",
+ };
+}
+
+/** The newest n across both registries. */
+export function listActivity(n = 8): ActivityItem[] {
+ const all = [...recentChainJobs(n).map(fromChain), ...recentRenders(n).map(fromRender)];
+ return all.sort((a, b) => b.startedAt - a.startedAt).slice(0, n);
+}
+
+/** Whichever registry is busy, or null. A chain wins the tie: it is the long one. */
+export function runningActivity(): ActivityItem | null {
+ const chain = runningJob();
+ if (chain) return fromChain(chain);
+ const render = recentRenders(4).find((r) => r.state === "running");
+ return render ? fromRender(render) : null;
+}
diff --git a/umtool/lib/dashboard.ts b/umtool/lib/dashboard.ts
@@ -0,0 +1,73 @@
+// Pure helpers the dashboard and /browse share. NO `node:` import: the state
+// strip is rendered on the server but its labels are also what the e2e suite
+// compares against /browse's header, and one helper is how the two pages are
+// kept from disagreeing by a plural.
+import { PROJECT_STATES, type ProjectState } from "./project-types";
+
+/** The header /browse prints and the dashboard's Projects panel repeats. */
+export const projectsNote = (total: number, blocking: number, open: number) =>
+ `${total} projects · ${blocking} blocking · ${open} open`;
+
+export type StateRow = {
+ kind: string;
+ label: string;
+ total: number;
+ /** Every state, in PROJECT_STATES order, zero included -- the strip is proportional. */
+ segments: { state: ProjectState; n: number; href: string }[];
+};
+
+/** One row per kind: how many projects sit in each of the six states. */
+export function stateRows(
+ kinds: { id: string; label: string }[],
+ projects: { kind: string; state: ProjectState }[],
+): StateRow[] {
+ return kinds.map((k) => {
+ const mine = projects.filter((p) => p.kind === k.id);
+ return {
+ kind: k.id,
+ label: k.label,
+ total: mine.length,
+ segments: PROJECT_STATES.map((state) => ({
+ state,
+ n: mine.filter((p) => p.state === state).length,
+ href: `/browse?kind=${encodeURIComponent(k.id)}&state=${state}`,
+ })),
+ };
+ });
+}
+
+/** Thirty days: the age past which a recorded availability check is "old". */
+export const SOURCES_STALE_DAYS = 30;
+
+/**
+ * The Sources panel's three buckets, from each report project's summary
+ * attributes (report.mjs writes `sources-checked-at`, `sources-dead`,
+ * `cue-gaps`). Never checked / checked too long ago / something wrong.
+ */
+export function sourceBuckets<T extends { id: string; attrs?: Record<string, string> }>(
+ projects: T[],
+ now = Date.now(),
+): { never: T[]; old: T[]; bad: T[] } {
+ const never: T[] = [];
+ const old: T[] = [];
+ const bad: T[] = [];
+ for (const p of projects) {
+ const a = p.attrs ?? {};
+ // Only kinds that report sources at all -- a song has no `sources` attr.
+ if (!("sources" in a)) continue;
+ if (Number(a.sources) === 0) continue;
+ const at = Number(a["sources-checked-at"] ?? 0);
+ if (!at) never.push(p);
+ else if (now - at > SOURCES_STALE_DAYS * 86_400_000) old.push(p);
+ if (Number(a["sources-dead"] ?? 0) > 0 || Number(a["sources-missing"] ?? 0) > 0 || Number(a["cue-gaps"] ?? 0) > 0) {
+ bad.push(p);
+ }
+ }
+ return { never, old, bad };
+}
+
+export function fmtGB(bytes: number | null): string {
+ if (bytes == null) return "–";
+ const gb = bytes / 1e9;
+ return gb >= 100 ? `${gb.toFixed(0)} GB` : `${gb.toFixed(1)} GB`;
+}
diff --git a/umtool/lib/doctor.ts b/umtool/lib/doctor.ts
@@ -0,0 +1,21 @@
+import { probeTools } from "./tools.mjs";
+import type { DoctorReport } from "./tool-types";
+
+// The app's cache of the tool probe. In memory, ten minutes, and NEVER probed
+// from a page render: the dashboard reads whatever is here (or nothing) and the
+// only writer is POST /api/doctor. Probing seven binaries on every request would
+// be a fork/exec tax on a page whose whole point is being cheap to open.
+
+const TTL_MS = 10 * 60 * 1000;
+let cache: DoctorReport | null = null;
+
+/** The last probe, or null when nothing has been probed (or it is stale). */
+export function toolsCache(): DoctorReport | null {
+ if (cache && Date.now() - cache.checkedAt < TTL_MS) return cache;
+ return null;
+}
+
+export async function probeNow(): Promise<DoctorReport> {
+ cache = (await probeTools()) as DoctorReport;
+ return cache;
+}
diff --git a/umtool/lib/faces.ts b/umtool/lib/faces.ts
@@ -1,10 +1,11 @@
import { existsSync } from "node:fs";
import { execFile } from "node:child_process";
import path from "node:path";
+import { facecropPy, facedetPython } from "./tools.mjs";
import { promisify } from "node:util";
import { archiveMomentUrl } from "./archive";
import { sourceVideo } from "./clips";
-import { SONG_CODE, SONG_SCRATCH, stateFile } from "./paths";
+import { stateFile } from "./paths";
import { readJson, withStateLock, writeJsonAtomic } from "./state";
import { readThumbAccepted, readThumbManifest, type ThumbDoc } from "./thumbs";
import {
@@ -184,10 +185,12 @@ export async function rejectedVideos(): Promise<string[]> {
// by the python one.
// ---------------------------------------------------------------------------
-export const FACEDET_PYTHON = () =>
- process.env.FACEDET_PYTHON ?? path.join(SONG_SCRATCH, "facedet", "bin", "python");
+// The paths live in lib/tools.mjs so `umtool doctor` probes the SAME python and
+// the same script this runs -- a doctor that checked a different interpreter
+// than the one that cuts would be worse than no doctor.
+export const FACEDET_PYTHON = facedetPython;
-export const FACECROP_PY = () => path.join(SONG_CODE, "facecrop.py");
+export const FACECROP_PY = facecropPy;
export type Detection = {
face: FaceBox | null;
diff --git a/umtool/lib/jobs.ts b/umtool/lib/jobs.ts
@@ -25,6 +25,13 @@ export type JobState = "running" | "done" | "failed";
export type Job = {
id: string;
kind: string;
+ /**
+ * The project this job works on, as an id, when it works on one.
+ *
+ * Stamped by the caller rather than parsed back out of `kind`: the dashboard
+ * links a running build to its project page, and a label is not an id.
+ */
+ project: string | null;
state: JobState;
startedAt: number;
endedAt: number | null;
@@ -41,13 +48,21 @@ export type Job = {
};
/** Kept in memory, like lib/mix.ts: a job that does not survive a restart is a
- * job worth running again. */
-const jobs = new Map<string, Job>();
-let current: Job | null = null;
+ * job worth running again.
+ *
+ * ON globalThis, not at module scope. Next compiles each route entry -- a
+ * page, an API route -- into its own module graph in dev, so a module-scope
+ * Map is one Map PER ENTRY: the dashboard page read an empty registry while
+ * /api/jobs, in another entry, held the finished build. One process, one
+ * registry, whichever entry asks. */
+type Registry = { jobs: Map<string, Job>; current: Job | null; seq: number };
+const g = globalThis as typeof globalThis & { __umtoolJobs?: Registry };
+const reg: Registry = (g.__umtoolJobs ??= { jobs: new Map(), current: null, seq: 0 });
+const jobs = reg.jobs;
/** Two concurrent `pick-take --apply` runs would interleave cp calls into the
* same TAKE_DIR, so this is process-wide rather than per-recipe. */
-export const runningJob = () => (current && current.state === "running" ? current : null);
+export const runningJob = () => (reg.current && reg.current.state === "running" ? reg.current : null);
export const getJob = (id: string) => jobs.get(id) ?? null;
export const recentJobs = (n = 10) =>
@@ -171,16 +186,15 @@ function killGroup(pid: number | undefined) {
}, KILL_GRACE_MS);
}
-let seq = 0;
-
-export function startJob(kind: string, steps: Step[]): Job {
+export function startJob(kind: string, steps: Step[], { project = null }: { project?: string | null } = {}): Job {
const running = runningJob();
if (running) throw new Error(`a job is already running (${running.id})`);
- seq += 1;
+ reg.seq += 1;
const job: Job = {
- id: `job-${seq}-${process.pid}`,
+ id: `job-${reg.seq}-${process.pid}`,
kind,
+ project,
state: "running",
startedAt: Date.now(),
endedAt: null,
@@ -193,7 +207,7 @@ export function startJob(kind: string, steps: Step[]): Job {
error: null,
};
jobs.set(job.id, job);
- current = job;
+ reg.current = job;
void (async () => {
try {
@@ -212,7 +226,7 @@ export function startJob(kind: string, steps: Step[]): Job {
push(job, `** ${job.error}`);
} finally {
job.endedAt = Date.now();
- if (current === job) current = null;
+ if (reg.current === job) reg.current = null;
}
})();
@@ -240,6 +254,7 @@ export function jobView(job: Job, since = 0, sinceEvent = 0) {
return {
id: job.id,
kind: job.kind,
+ project: job.project,
state: job.state,
startedAt: job.startedAt,
endedAt: job.endedAt,
diff --git a/umtool/lib/last-page.ts b/umtool/lib/last-page.ts
@@ -13,8 +13,12 @@
/** Same `umtool:` prefix as SHORTLIST_EVENT -- one namespace for the app. */
export const LAST_PAGE_KEY = "umtool:last-page";
-/** What / redirected to unconditionally before this existed, now the floor. */
-export const DEFAULT_PAGE = "/sort";
+/**
+ * The floor of the walk-up. It was /sort when / was a doorway into the song
+ * piles; / is the dashboard now, and the page a stale URL degrades to is the
+ * list of every project rather than one kind's judging pile.
+ */
+export const DEFAULT_PAGE = "/browse";
/** Long enough for any real query string, short enough to bound a bad write. */
const URL_LIMIT = 2000;
@@ -47,7 +51,7 @@ export function isRecordable(url: string): boolean {
*
* The full URL, then the same path without its query, then each parent, then
* DEFAULT_PAGE. `/browse/yoshi/wide` gives
- * `["/browse/yoshi/wide", "/browse/yoshi", "/browse", "/sort"]` -- so a cut
+ * `["/browse/yoshi/wide", "/browse/yoshi", "/browse"]` -- so a cut
* that no longer exists degrades to its song, a song that no longer exists
* degrades to the list of songs, and nothing ever lands on a 404.
*/
diff --git a/umtool/lib/mix.ts b/umtool/lib/mix.ts
@@ -196,8 +196,12 @@ export type RenderJob = {
duration: number;
};
-const jobs = new Map<string, RenderJob>();
-let jobSeq = 0;
+// On globalThis for the reason lib/jobs.ts gives: one registry per process,
+// not one per route entry, so the dashboard sees what /api/mix/render started.
+type RenderRegistry = { jobs: Map<string, RenderJob>; seq: number };
+const g = globalThis as typeof globalThis & { __umtoolRenders?: RenderRegistry };
+const reg: RenderRegistry = (g.__umtoolRenders ??= { jobs: new Map(), seq: 0 });
+const jobs = reg.jobs;
export function getJob(id: string): RenderJob | null {
return jobs.get(id) ?? null;
@@ -211,8 +215,8 @@ export async function startRender(raw: Partial<MixSpec>): Promise<RenderJob> {
const r = await resolveMix(raw);
await mkdir(path.dirname(r.outPath), { recursive: true });
- jobSeq += 1;
- const id = `mix${jobSeq}`;
+ reg.seq += 1;
+ const id = `mix${reg.seq}`;
const job: RenderJob = {
id,
state: "running",
diff --git a/umtool/lib/project-types.ts b/umtool/lib/project-types.ts
@@ -127,4 +127,10 @@ export type ProjectKindMeta = {
badge: string;
stages: ProjectStage[];
decisionKinds: string[];
+ /**
+ * What "new project" asks for beyond a slug and a title, or null when the
+ * kind cannot be scaffolded. The menu renders these from data, so it never
+ * has to know what a kind is.
+ */
+ scaffold: { fields: string[] } | null;
};
diff --git a/umtool/lib/projects/index-db.mjs b/umtool/lib/projects/index-db.mjs
@@ -25,7 +25,9 @@ import path from "node:path";
import { INDEX_DIR } from "../paths.mjs";
/** Bump to invalidate every cached record at once. Folded into every signature. */
-export const INDEX_SCHEMA = 1;
+// 2: `finalBytes` and the source attrs (sources-checked-at, sources-dead, cue-gaps)
+// joined the record for the dashboard.
+export const INDEX_SCHEMA = 2;
const NOOP = {
ok: false,
diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs
@@ -42,6 +42,10 @@ export const SKIP_DIRS = new Set([
"thumbs",
// The e2e fixture symlinks 39 GB of audio here.
"data",
+ // Manifest snapshots (`umtool snapshot`). A project is a leaf, so the walk
+ // never reaches inside one -- but a snapshot directory left behind by a
+ // deleted manifest must not read as a project either.
+ "revisions",
]);
const has = (names, n) => names.has(n);
@@ -71,6 +75,9 @@ export const PROJECT_KINDS = [
// /browse/<project>/clip/<id> the window bench
// /browse/<project>/claim/<id> the adjudication bench
views: ["clip", "claim"],
+ // What "new project" asks for, beyond a slug and a title. Declared HERE so
+ // the menu renders fields from data and never branches on a kind id.
+ scaffold: { fields: ["from", "siteOrigin", "seed"] },
},
{
id: "song",
@@ -100,6 +107,7 @@ export const PROJECT_KINDS = [
decisions: null,
// `/browse/<song>/wide` -- today's cut page, unchanged.
views: CUT_NAMES,
+ scaffold: { fields: [] },
},
{
id: "sweep-report",
@@ -117,6 +125,8 @@ export const PROJECT_KINDS = [
signature: sweepSignature,
decisions: null,
views: [],
+ // A sweep report is written by the sweep, not scaffolded here.
+ scaffold: null,
},
];
@@ -134,6 +144,7 @@ if (process.env.UMTOOL_EXTRA_KINDS) {
signature: null,
decisions: null,
views: [],
+ scaffold: null,
...k,
detect: (names) => has(names, k.marker),
});
@@ -153,6 +164,7 @@ export const kindMeta = (k) => ({
badge: k.badge,
stages: k.stages,
decisionKinds: k.decisionKinds,
+ scaffold: k.scaffold ?? null,
});
export const KIND_META = () => PROJECT_KINDS.map(kindMeta);
diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs
@@ -33,7 +33,7 @@ export { widen };
export const MANIFEST_NAME = "video.manifest.json";
-const GLOBAL_CHANNELS_DIR = () =>
+export const GLOBAL_CHANNELS_DIR = () =>
process.env.CHANNELS_DIR ??
"/home/user/Projects/yt-dlp-transcript-browser/transcripts/channels";
@@ -96,6 +96,28 @@ export const cardsOf = (m) => (m?.timeline ?? []).filter((e) => e?.type === "car
export const channelFor = (m, e) => e.channel ?? m?.provenance?.channelSlug ?? null;
+/**
+ * Where a clip's QR points. THE SAME RULE build-video.mjs's qrForEntry() uses:
+ * an explicit per-clip `citeUrl` wins (the Rumble mirror case -- a local slug
+ * is not the id the site serves), else the viewer moment URL is derived.
+ */
+export const derivedCiteUrl = (m, e) =>
+ `${m?.provenance?.siteOrigin ?? ""}/?v=${encodeURIComponent(`${channelFor(m, e)}/${e.video}`)}&t=${Math.floor(e.start ?? 0)}`;
+export const citeUrlFor = (m, e) => e.citeUrl ?? derivedCiteUrl(m, e);
+
+/** The recorded preflight, and when it ran. Never a live probe. */
+export async function readAvailability(dir) {
+ const file = path.join(dir, "out", "availability.json");
+ const [st, text] = await Promise.all([stat0(file), readFile(file, "utf8").catch(() => null)]);
+ if (!st || text === null) return null;
+ try {
+ const doc = JSON.parse(text);
+ return { ...doc, checkedAtMs: Date.parse(doc.checkedAt ?? "") || st.mtimeMs, file };
+ } catch {
+ return null;
+ }
+}
+
export const cuePathFor = (m, e, channelsDir) => {
const chan = channelFor(m, e);
if (!chan) return null;
@@ -166,7 +188,10 @@ export async function buildStateOf(dir, manifest) {
const outDir = path.join(dir, "out");
const slug = manifest?.slug ?? path.basename(dir);
const finalPath = path.join(outDir, `${slug}.mp4`);
- const [fin, man] = await Promise.all([stat0(finalPath), stat0(manifestPath(dir))]);
+ // The `full` cut's file, when the manifest has one. One extra stat; it is a
+ // deliverable too and the dashboard's byte count would lie without it.
+ const fullPath = path.join(outDir, `${slug}-full.mp4`);
+ const [fin, man, full] = await Promise.all([stat0(finalPath), stat0(manifestPath(dir)), stat0(fullPath)]);
let rawCount = 0;
let segCount = 0;
@@ -202,6 +227,9 @@ export async function buildStateOf(dir, manifest) {
stale,
finalSize: fin?.size ?? 0,
finalMtimeMs: fin?.mtimeMs ?? 0,
+ fullPath,
+ fullSize: full?.size ?? 0,
+ fullMtimeMs: full?.mtimeMs ?? 0,
manifestMtimeMs: man?.mtimeMs ?? 0,
rawCount,
segCount,
@@ -213,14 +241,21 @@ export async function buildStateOf(dir, manifest) {
/** The signature the summary and the decisions are cached against. */
export async function reportSignature(dir) {
- const [man, out] = await Promise.all([
+ const [man, out, avail, rev] = await Promise.all([
stat0(manifestPath(dir)),
stat0(path.join(dir, "out")),
+ // Rewritten IN PLACE by every preflight, which leaves out/'s own mtime
+ // where it was -- so it has to be signed on its own or a re-check would
+ // never reach the index.
+ stat0(path.join(dir, "out", "availability.json")),
+ stat0(path.join(dir, "revisions")),
]);
return [
Math.round(man?.mtimeMs ?? 0),
man?.size ?? 0,
Math.round(out?.mtimeMs ?? 0),
+ Math.round(avail?.mtimeMs ?? 0),
+ Math.round(rev?.mtimeMs ?? 0),
].join(":");
}
@@ -232,6 +267,95 @@ const fmtDur = (s) => {
: `${m}m${String(t % 60).padStart(2, "0")}s`;
};
+// ---------------------------------------------------------------------------
+// Sources: one row per distinct (channel, video) a report draws on.
+//
+// This is where the long-form problem lives. Eight to eighteen sources per
+// video, across channels and platforms; a transcript that stops before the clip
+// it is cited for; a Rumble id that is not the id the site serves; sources that
+// go dead after the manifest is written. Every column here is READ from
+// something already on disk -- the cue file, out/availability.json, the
+// manifest -- and nothing is probed.
+//
+// Cue coverage is the one that had no home at all: gout's c03 (861–897 s) sat
+// on a transcript whose last cue ended at 880 s, and the only record of it was
+// a paragraph in provenance.transcriptGapNote.
+// ---------------------------------------------------------------------------
+
+/**
+ * @returns {Promise<{ rows: Array<{
+ * key: string, channel: string|null, video: string, clips: string[],
+ * cues: "present"|"missing"|"no-punctuation", cueFile: string|null,
+ * cuesEnd: number|null, latestClipEnd: number, coverageGap: null | { clip: string, needs: number, cuesEnd: number },
+ * availability: null | { state: string, ok: boolean, checkedAtMs: number, title: string|null, url: string|null, error: string|null },
+ * cite: { derived: string, override: string|null, differs: boolean },
+ * title: string|null,
+ * }>, checkedAtMs: number|null, channelsDir: string }>}
+ */
+export async function sourcesOf(dir, { manifest = null } = {}) {
+ const m = manifest ?? (await readManifest(dir));
+ if (!m) return { rows: [], checkedAtMs: null, channelsDir: GLOBAL_CHANNELS_DIR() };
+ const shadowExists = await hasShadowChannels(dir);
+ const channelsDir = channelsDirFor(dir, m, { shadowExists });
+ const avail = await readAvailability(dir);
+ const availBy = new Map((avail?.sources ?? []).map((s) => [s.key, s]));
+
+ const byKey = new Map();
+ for (const e of clipsOf(m)) {
+ const chan = channelFor(m, e);
+ const key = `${chan}/${e.video}`;
+ if (!byKey.has(key)) byKey.set(key, { chan, video: e.video, clips: [] });
+ byKey.get(key).clips.push(e);
+ }
+
+ const rows = [];
+ for (const [key, { chan, video, clips }] of byKey) {
+ const cueFile = cuePathFor(m, clips[0], channelsDir);
+ const doc = cueFile ? await readCues(cueFile) : null;
+ const cuesEnd = doc?.cues?.length ? Number(doc.cues[doc.cues.length - 1].end) : null;
+ const latest = clips.reduce((best, e) => (Number(e.end) > (best?.end ?? -1) ? { clip: e.id, end: Number(e.end) } : best), null);
+ // A gap is the cue file ENDING before a clip does: the tail of the quote
+ // has no words behind it, so the widener cannot see it and a build cuts
+ // footage the transcript never described. Half a second of slack, because
+ // a cue's end and a window's end are both rounded.
+ const coverageGap =
+ doc && cuesEnd != null && latest && latest.end > cuesEnd + 0.5
+ ? { clip: latest.clip, needs: latest.end, cuesEnd }
+ : null;
+ const a = availBy.get(key) ?? null;
+ // The QR/cite target. "Differs" is what marks the Rumble case: every clip
+ // in one real manifest carries a citeUrl, and only the ones that point
+ // somewhere other than the derived moment are overrides worth flagging.
+ const first = clips[0];
+ const derived = derivedCiteUrl(m, first);
+ const override = first.citeUrl ?? null;
+ rows.push({
+ key,
+ channel: chan,
+ video,
+ clips: clips.map((e) => e.id),
+ cues: !doc ? "missing" : doc.punctuationRate < 0.1 ? "no-punctuation" : "present",
+ cueFile,
+ cuesEnd,
+ latestClipEnd: latest?.end ?? 0,
+ coverageGap,
+ availability: a
+ ? {
+ state: a.state ?? (a.ok ? "ok" : "unknown"),
+ ok: !!a.ok,
+ checkedAtMs: Date.parse(a.checkedAt ?? "") || avail.checkedAtMs,
+ title: a.title ?? null,
+ url: a.url ?? null,
+ error: a.error ?? null,
+ }
+ : null,
+ cite: { derived, override, differs: !!override && override !== derived },
+ title: doc?.title ?? a?.title ?? null,
+ });
+ }
+ return { rows, checkedAtMs: avail?.checkedAtMs ?? null, channelsDir, shadowExists };
+}
+
/**
* The card. Cheap by construction: the manifest, one stat, one readdir.
*
@@ -253,6 +377,15 @@ export async function summariseReport(ctx) {
const sources = new Set(clips.map((e) => `${channelFor(m, e)}/${e.video}`)).size;
const locked = clips.filter((e) => e.lock).length;
+ // The source facts the dashboard's Sources panel and /browse read. The cue
+ // reads this costs are memoised per file for the life of the process, and
+ // the record is cached in the index against the signature -- so the first
+ // load of a project pays for its cue files once, and no page does again.
+ const src = m ? await sourcesOf(dir, { manifest: m }) : { rows: [], checkedAtMs: null };
+ const sourcesDead = src.rows.filter((r) => r.availability && !r.availability.ok && r.availability.state !== "no-cues").length;
+ const sourcesMissing = src.rows.filter((r) => r.cues === "missing").length;
+ const cueGaps = src.rows.filter((r) => r.coverageGap).length;
+
const state = !m
? "draft"
: build.stale
@@ -281,12 +414,14 @@ export async function summariseReport(ctx) {
if (isDeadOrigin(m?.provenance?.siteOrigin)) flags.push("dead QR origin");
if (!m?.provenance?.channelSlug) flags.push("no channelSlug");
if (build.stale) flags.push("older than its manifest");
+ if (sourcesDead) flags.push(`${sourcesDead} dead source${sourcesDead === 1 ? "" : "s"}`);
+ if (cueGaps) flags.push(`${cueGaps} cue gap${cueGaps === 1 ? "" : "s"}`);
return {
title: m?.title ?? ctx.name,
subtitle: m?.subtitle ?? m?.generatedOn ?? null,
state,
- newestMtimeMs: Math.max(build.manifestMtimeMs, build.finalMtimeMs),
+ newestMtimeMs: Math.max(build.manifestMtimeMs, build.finalMtimeMs, build.fullMtimeMs),
facts,
flags,
// A card should look like the video as soon as anything of it exists. A
@@ -313,12 +448,19 @@ export async function summariseReport(ctx) {
other: String(others.length),
sources: String(sources),
locked: String(locked),
- ...(build.built ? { built: "1" } : {}),
+ // Omitted when never checked, so "never" is the attribute not being
+ // there rather than a zero somebody has to know the meaning of.
+ ...(src.checkedAtMs ? { "sources-checked-at": String(Math.round(src.checkedAtMs)) } : {}),
+ ...(sourcesDead ? { "sources-dead": String(sourcesDead) } : {}),
+ ...(sourcesMissing ? { "sources-missing": String(sourcesMissing) } : {}),
+ ...(cueGaps ? { "cue-gaps": String(cueGaps) } : {}),
+ ...(build.built ? { built: "1", "final-bytes": String(build.finalSize + build.fullSize) } : {}),
...(isDeadOrigin(m?.provenance?.siteOrigin) ? { "dead-origin": "1" } : {}),
},
// Kept for the project page and the decisions pass, so neither re-reads.
manifest: m,
build,
+ sources: src,
};
}
@@ -338,6 +480,12 @@ export const REPORT_DECISION_KINDS = [
"clip-mid-sentence",
"window-overlap",
"no-punctuation",
+ // The cue file ENDS before a clip does. `open`, not blocking: the build does
+ // not die (it cuts from the audio), but the tail of that clip has no words
+ // behind it, so nothing can say whether it ends on a sentence -- and the
+ // fix (a recovered transcript, or a shorter window) is editorial. The
+ // reducer reads cue files already; it does not measure media.
+ "clip-cue-gap",
// A ledger entry nobody has ruled on. BLOCKING, which is earned here: both
// the stated and the implied total lie if you act on an unadjudicated ledger,
// and they lie quietly, in a chart, with his name on it.
@@ -529,6 +677,28 @@ export async function reportDecisions(ctx, summary) {
}
}
+ // A clip with no numeric window cannot be built, widened or benched. The
+ // scaffold's `--seed chapters` writes one when the last chapter has no
+ // duration to end at, and says so; this is where it stays visible.
+ for (const e of clips) {
+ if (Number.isFinite(Number(e.start)) && Number.isFinite(Number(e.end))) continue;
+ add("manifest-invalid", e.id, `no ${Number.isFinite(Number(e.start)) ? "end" : "start"} — a clip needs both edges in source seconds`, "blocking", { href: clipHref(e.id) });
+ }
+
+ // The cue file ends before the clip does. One row per SOURCE, naming the
+ // clip that reaches furthest past it.
+ for (const r of (s.sources?.rows ?? [])) {
+ if (!r.coverageGap) continue;
+ add(
+ "clip-cue-gap",
+ r.coverageGap.clip,
+ `cues end at ${r.coverageGap.cuesEnd.toFixed(0)} s · ${r.coverageGap.clip} needs ${r.coverageGap.needs.toFixed(0)} s — ` +
+ `the transcript of ${r.key} stops short; recover it (local whisper) or shorten the window`,
+ "open",
+ { href: clipHref(r.coverageGap.clip) },
+ );
+ }
+
// One row, not one per source. Six real projects produce forty-odd of these
// between them, and an inbox that says the same true thing forty times is an
// inbox whose blocking rows have scrolled off the top.
diff --git a/umtool/lib/projects/scaffold-song.ts b/umtool/lib/projects/scaffold-song.ts
@@ -0,0 +1,61 @@
+import { mkdir, stat, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { BROWSE_ROOT, isSegment, songDir } from "../browse";
+import { writeSpec } from "../spec";
+
+// Start a song. Moved verbatim from app/api/browse/init/route.ts, which this
+// replaces; TypeScript because writeSpec is, and the CLI keeps refusing
+// `--kind song` for the same reason it cannot compute the song reducer.
+//
+// The tree has been made by hand five times, and each time something was left
+// out -- pokemon is the only one with a clips.csv, two have no vertical cut, and
+// three have plan/ directories whose contents nobody can now attribute. This
+// makes the shape once: the directories the scan expects, and a spec sheet with
+// the song's name in it, so the very first thing the UI shows is a sheet to
+// fill in rather than an empty page.
+//
+// It does NOT create empty cut files. A cut that does not exist must read as a
+// hole in the set -- that is the whole reason the cut list is fixed rather than
+// derived -- and a zero-byte wide.mp4 would read as a built one.
+
+const README = (id: string, title: string) => `# ${title}
+
+_New song, nothing built yet._
+
+Fill in \`spec.json\` with what this song is made of — the background video, any
+one-off sounds, the drum map, the vocal hooks, the arrangement. What is declared
+there is what the browse UI will offer to do.
+
+| file | |
+|---|---|
+| \`wide.mp4\` | not built |
+| \`wide-short.mp4\` | not built |
+| \`vertical.mp4\` | not built |
+| \`vertical-short.mp4\` | not built |
+`;
+
+export class ScaffoldError extends Error {
+ constructor(message: string, public status = 400) {
+ super(message);
+ }
+}
+
+export async function scaffoldSong({ slug, title }: { slug: string; title?: string | null }) {
+ const id = (slug ?? "").trim().toLowerCase();
+ if (!isSegment(id)) {
+ throw new ScaffoldError("a song name is letters, digits, dots, dashes and underscores — no slashes");
+ }
+ const dir = songDir(id);
+ // Containment check as well as the name check. songDir() joins under
+ // BROWSE_ROOT, and isSegment() already rules out traversal, but a mkdir is
+ // the first thing here that CREATES something and it gets both gates.
+ if (dir !== path.join(BROWSE_ROOT, id)) throw new ScaffoldError("refusing to create outside the tree");
+ if (await stat(dir).then(() => true, () => false)) throw new ScaffoldError(`${id} already exists`, 409);
+
+ const finalTitle = (title ?? "").trim() || id;
+ await mkdir(path.join(dir, "plan"), { recursive: true });
+ await mkdir(path.join(dir, "variants"), { recursive: true });
+ await writeFile(path.join(dir, "README.md"), README(id, finalTitle));
+ const spec = await writeSpec(id, { version: 1, song: id, title: finalTitle });
+ return { song: id, spec, href: `/browse/${id}` };
+}
diff --git a/umtool/lib/projects/scaffold.mjs b/umtool/lib/projects/scaffold.mjs
@@ -0,0 +1,258 @@
+// Making a report-video project that is not there yet. ONE writer, for the CLI
+// and the route, so `umtool new` and the "new project" menu cannot produce two
+// different skeletons.
+//
+// Plain ESM: `umtool new` runs from a terminal with no server.
+import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { GLOBAL_CHANNELS_DIR, MANIFEST_NAME } from "./report.mjs";
+
+export const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
+
+/**
+ * A manifest skeleton, and deliberately an EMPTY timeline.
+ *
+ * It would be easy to derive first-draft clips from a report's citations: the
+ * shape is regular (`> "quote"` then `— [title @ h:mm:ss](…?v=slug%2Fid&t=sec)`).
+ * It is not done, and that is the honest position rather than a missing feature.
+ * A report records ONE second per citation; a window needs a start AND an end
+ * taken from transcript.cues.json, and matching a quote to its cues is the
+ * actual work of authoring a cut. A generated timeline of guessed windows would
+ * look finished and be wrong, and every clip would have to be opened anyway.
+ *
+ * (`--seed chapters` is the one exception, and it invents nothing: the windows
+ * it writes are the digest's own chapter boundaries.)
+ */
+export function skeleton(slug, title, provenance) {
+ return {
+ schemaVersion: 1,
+ slug,
+ title,
+ subtitle: "",
+ generatedOn: new Date().toISOString().slice(0, 10),
+ provenance: {
+ // The field that shipped broken TWICE. It is first, and it is empty rather
+ // than plausible, so `umtool check` blocks until somebody sets it.
+ siteOrigin: "",
+ channelSlug: "",
+ channel: "",
+ ...provenance,
+ },
+ render: {
+ width: 1920,
+ height: 1080,
+ fps: 30,
+ audioRate: 48000,
+ audioChannels: 2,
+ maxHeightSource: 1080,
+ fontRegular: "/usr/share/fonts/TTF/FiraSans-Regular.ttf",
+ fontBold: "/usr/share/fonts/TTF/FiraSans-Bold.ttf",
+ palette: { bg: "#12100c", fg: "#f6f1e6", muted: "#a2957f", accent: "#c8752a", amber: "#ffc860" },
+ transition: 0.4,
+ fetchPad: 3,
+ snapWindow: 1.6,
+ silenceMinDur: 0.09,
+ silenceRelDb: 6,
+ headerHeight: 56,
+ footerHeight: 0,
+ crf: 21,
+ preset: "slow",
+ qr: { scale: 4, quiet: 3, ecc: "M", margin: 28 },
+ },
+ timelineNodes: [],
+ timeline: [],
+ };
+}
+
+/**
+ * What `--from` is, by its SHAPE. Never guessed: a value that is none of these
+ * is an error, not "probably a report".
+ *
+ * report a path ending in .md
+ * video a viewer share URL `…/?v=<channel>%2F<id>&t=<s>`, or a bare
+ * `<channel>/<videoId>` ref
+ *
+ * @returns {{ kind: "report", path: string } | { kind: "video", channel: string, video: string, second: number | null, origin: string | null } | null}
+ */
+export function detectFrom(from) {
+ if (!from) return null;
+ const v = String(from).trim();
+ if (/\.md$/i.test(v)) return { kind: "report", path: v };
+ const url = v.match(/^(https?:\/\/[^/?#]+)[^?#]*\?(?:[^#]*&)?v=([^&#]+)(?:&(?:[^#]*&)?t=(\d+))?/i);
+ if (url) {
+ const [chan, id] = decodeURIComponent(url[2]).split("/");
+ if (chan && id) return { kind: "video", channel: chan, video: id, second: url[3] ? Number(url[3]) : null, origin: url[1] };
+ }
+ const bare = v.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)$/);
+ if (bare) return { kind: "video", channel: bare[1], video: bare[2], second: null, origin: null };
+ return null;
+}
+
+/** Citations in a sweep report: `?v=<channel>%2F<id>&t=<sec>` links. */
+export function citationsIn(text) {
+ const out = [];
+ for (const m of text.matchAll(/\]\([^)]*[?&]v=([^&)]+)&t=(\d+)/g)) {
+ const [chan, id] = decodeURIComponent(m[1]).split("/");
+ out.push({ channel: chan, video: id, second: Number(m[2]) });
+ }
+ return out;
+}
+
+const readJson = (file) => readFile(file, "utf8").then((t) => JSON.parse(t), () => null);
+
+/**
+ * The digest's chapters, as windows. The same merge common/lib/digest.ts
+ * performs -- later source wins per id, `enabled: false` suppresses, sorted by
+ * start -- done on the JSON directly because the CLI cannot import TypeScript.
+ *
+ * These are the DIGEST'S boundaries, not invented windows: `start` is the
+ * chapter's start and `end` is the next chapter's start. The last chapter ends
+ * at the video's duration when metadata.info.json has one, else its `end` is
+ * omitted and reported so nobody mistakes a guess for a fact.
+ */
+export async function chaptersAsClips({ channelsDir, channel, video }) {
+ const dir = path.join(channelsDir, channel, "data", video);
+ if (!(await stat(dir).then((s) => s.isDirectory(), () => false))) {
+ throw new Error(`no video directory at ${dir}`);
+ }
+ const digest = await readJson(path.join(dir, "ai-digest.json"));
+ if (!digest) throw new Error(`${channel}/${video} has no ai-digest.json — nothing to seed from`);
+ const overrides = await readJson(path.join(dir, "ai-digest.overrides.json"));
+ const meta = await readJson(path.join(dir, "metadata.info.json"));
+
+ const byId = new Map();
+ for (const c of digest.sections?.chapters?.items ?? []) byId.set(c.id, c);
+ for (const c of overrides?.chapters ?? []) byId.set(c.id, { ...(byId.get(c.id) ?? {}), ...c });
+ const chapters = [...byId.values()]
+ .filter((c) => c.enabled !== false && Number.isFinite(Number(c.start)))
+ .sort((a, b) => a.start - b.start || String(a.title).localeCompare(String(b.title)));
+
+ const duration = Number.isFinite(Number(meta?.duration)) ? Number(meta.duration) : null;
+ const clips = chapters.map((c, i) => {
+ const next = chapters[i + 1];
+ const end = next ? Number(next.start) : duration;
+ return {
+ type: "clip",
+ id: `c${String(i).padStart(2, "0")}`,
+ channel,
+ video,
+ start: Number(Number(c.start).toFixed(2)),
+ ...(end != null ? { end: Number(Number(end).toFixed(2)) } : {}),
+ cite: Math.floor(Number(c.start)),
+ chapter: String(c.title ?? ""),
+ section: 0,
+ quote: "",
+ };
+ });
+ return { clips, noEnd: duration == null && clips.length > 0 };
+}
+
+/**
+ * Write the project. Refuses an existing directory and a slug that will not
+ * route; everything else it cannot know is written EMPTY so `check` blocks.
+ *
+ * @param {{ root: string, slug: string, title?: string, from?: string | null, siteOrigin?: string | null, seed?: string | null, channelsDir?: string }} args
+ */
+export async function scaffoldReportVideo({ root, slug, title, from = null, siteOrigin = null, seed = null, channelsDir = GLOBAL_CHANNELS_DIR() }) {
+ if (!SLUG_RE.test(String(slug ?? ""))) {
+ throw new Error(`"${slug}" will not route — use lower-case letters, digits and dashes`);
+ }
+ const dir = path.join(root, slug);
+ if (dir !== path.resolve(root, slug) || path.dirname(dir) !== path.resolve(root)) {
+ throw new Error("refusing to create outside the tree");
+ }
+ if (await stat(dir).then(() => true, () => false)) throw new Error(`${dir} already exists`);
+
+ const src = detectFrom(from);
+ if (from && !src) {
+ throw new Error(
+ `could not tell what --from is: "${from}" is neither a .md report, a viewer share URL (…/?v=<channel>%2F<id>&t=<s>), nor a <channel>/<videoId> ref`,
+ );
+ }
+ if (seed && seed !== "chapters") throw new Error(`--seed must be "chapters", not "${seed}"`);
+ if (seed && src?.kind !== "video") throw new Error("--seed chapters needs --from to be a video ref");
+
+ let finalTitle = String(title ?? "").trim() || slug.replace(/-/g, " ");
+ let reportText = null;
+ let citations = [];
+ const provenance = {};
+ const notes = [];
+
+ if (src?.kind === "report") {
+ reportText = await readFile(src.path, "utf8").catch(() => null);
+ if (reportText === null) throw new Error(`could not read ${src.path}`);
+ if (!title) finalTitle = reportText.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? finalTitle;
+ citations = citationsIn(reportText);
+ const channels = [...new Set(citations.map((c) => c.channel))];
+ if (channels.length === 1) provenance.channelSlug = channels[0];
+ } else if (src?.kind === "video") {
+ provenance.channelSlug = src.channel;
+ citations = [{ channel: src.channel, video: src.video, second: src.second ?? 0 }];
+ // A share URL carries the origin the citation came from. Recorded only when
+ // nothing more explicit was given; still overridable, never guessed.
+ if (!siteOrigin && src.origin) siteOrigin = src.origin;
+ }
+ if (siteOrigin) provenance.siteOrigin = String(siteOrigin);
+
+ const doc = skeleton(slug, finalTitle, provenance);
+
+ let seeded = 0;
+ if (seed === "chapters") {
+ const { clips, noEnd } = await chaptersAsClips({ channelsDir, channel: src.channel, video: src.video });
+ doc.timeline = clips;
+ seeded = clips.length;
+ if (noEnd) notes.push("the last chapter has NO end: metadata.info.json carries no duration — set it by hand");
+ if (!clips.length) notes.push("the digest has no enabled chapters, so the timeline is empty");
+ }
+
+ await mkdir(dir, { recursive: true });
+ await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(doc, null, 2) + "\n", "utf8");
+ if (reportText !== null) await writeFile(path.join(dir, "sweep-report.md"), reportText, "utf8");
+
+ const lines = [
+ `# ${finalTitle}`,
+ "",
+ "What this cut argues, which sources it draws on, and anything cut short on",
+ "purpose (with why — that is what a `lock` in the manifest means).",
+ "",
+ ];
+ if (seeded) {
+ lines.push(
+ "## Clips seeded from digest chapters — review each",
+ "",
+ "Every window below is a chapter boundary from the archive's digest, not an",
+ "edit. None is locked. Open each in the bench, cut it to the quote that",
+ "matters, and lock what you meant.",
+ "",
+ ...doc.timeline.map(
+ (c) => `- [ ] ${c.id} ${c.channel}/${c.video} ${c.start}–${c.end ?? "?"} ${c.chapter}`,
+ ),
+ );
+ } else {
+ lines.push(
+ "## Windows still to write",
+ "",
+ citations.length
+ ? "Each of these is ONE second from the source. A clip needs a start AND an" +
+ " end, read from the source's transcript.cues.json — that matching is the work."
+ : "No `?v=` citations were found, so there is nothing to work from yet.",
+ "",
+ ...citations.map(
+ (c, i) => `- [ ] c${String(i).padStart(2, "0")} ${c.channel}/${c.video} @ ${c.second}s`,
+ ),
+ );
+ }
+ if (notes.length) lines.push("", "## Notes from the scaffold", "", ...notes.map((n) => `- ${n}`));
+ await writeFile(path.join(dir, "README.md"), lines.join("\n") + "\n", "utf8");
+
+ return {
+ dir,
+ id: slug,
+ title: finalTitle,
+ from: src,
+ citations: citations.length,
+ seeded,
+ notes,
+ siteOrigin: provenance.siteOrigin ?? "",
+ };
+}
diff --git a/umtool/lib/projects/scaffold.ts b/umtool/lib/projects/scaffold.ts
@@ -0,0 +1,51 @@
+import { REPORTS_ROOT } from "../paths";
+import { SONG_KIND } from "./song.mjs";
+import { ScaffoldError, scaffoldSong } from "./scaffold-song";
+import { scaffoldReportVideo } from "./scaffold.mjs";
+import { kindById } from "./kinds.mjs";
+import { invalidateProjects } from "../projects";
+
+export { ScaffoldError };
+
+export type NewProjectBody = {
+ kind?: string;
+ slug?: string;
+ title?: string | null;
+ from?: string | null;
+ siteOrigin?: string | null;
+ seed?: string | null;
+};
+
+/**
+ * The one place a "new project" request is dispatched by kind. Here rather
+ * than in the route, so the route never learns what a kind is.
+ */
+export async function scaffoldProject(body: NewProjectBody): Promise<{ href: string; [k: string]: unknown }> {
+ const kind = String(body.kind ?? "");
+ const k = kindById(kind);
+ if (!k) throw new ScaffoldError(`unknown kind "${kind}"`);
+ if (!k.scaffold) throw new ScaffoldError(`a ${k.label} is not scaffolded here`);
+ const slug = String(body.slug ?? "").trim().toLowerCase();
+
+ let result: { href: string; [k: string]: unknown };
+ if (kind === SONG_KIND) {
+ result = await scaffoldSong({ slug, title: body.title });
+ } else {
+ try {
+ const r = await scaffoldReportVideo({
+ root: REPORTS_ROOT,
+ slug,
+ title: body.title ?? undefined,
+ from: body.from || null,
+ siteOrigin: body.siteOrigin || null,
+ seed: body.seed || null,
+ });
+ result = { ...r, href: `/browse/${r.id}` };
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ throw new ScaffoldError(msg, /already exists/.test(msg) ? 409 : 400);
+ }
+ }
+ invalidateProjects();
+ return result;
+}
diff --git a/umtool/lib/projects/song.mjs b/umtool/lib/projects/song.mjs
@@ -57,9 +57,14 @@ export async function summariseSong(ctx) {
}
let newest = 0;
+ // The shipped cuts' bytes, for the dashboard's deliverables count. Variants
+ // are not deliverables, so they are not summed.
+ let finalBytes = 0;
for (const rel of all) {
const st = await stat0(path.join(dir, rel));
- if (st) newest = Math.max(newest, st.mtimeMs);
+ if (!st) continue;
+ newest = Math.max(newest, st.mtimeMs);
+ if (!rel.startsWith("variants/")) finalBytes += st.size;
}
const title = readme?.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? name;
@@ -87,6 +92,7 @@ export async function summariseSong(ctx) {
keep: String(counts.keep),
reject: String(counts.reject),
undecided: String(counts.undecided),
+ ...(present.length ? { "final-bytes": String(finalBytes) } : {}),
// Omitted entirely when the set is complete: a hole in the deliverables
// is information, and its ABSENCE has to be readable as "nothing missing"
// rather than as an empty list.
diff --git a/umtool/lib/report/driver.mjs b/umtool/lib/report/driver.mjs
@@ -46,30 +46,60 @@ export const buildTimeoutMs = (clipCount, xfade) =>
* explicit action.
*/
/**
+ * The build options the pipeline has and the driver used to hide.
+ *
+ * variant `--variant sourced|full` -- which cut of a two-cut manifest
+ * xfade: false `--no-xfade` on a preset that would crossfade
+ * chaptersOnly `--chapters-only` -- retitle the chapters from the segments
+ * already on disk; no fetch, no encode
+ * preview `--preview <at> <dur>` -- the rail alone over a window
+ *
+ * chaptersOnly and preview SKIP the preflight and the dry resolve: neither
+ * touches a source, and both are seconds of work under a five-minute cap.
+ *
+ * @typedef {{ variant?: string, xfade?: boolean, chaptersOnly?: boolean, preview?: { at: number, dur: number } | null }} BuildOptions
+ */
+
+/** The step-1 preflight alone, reused by the check-sources job. */
+export function availabilityStep(project, env = {}) {
+ const manifest = path.join(project.dir, "video.manifest.json");
+ return {
+ cwd: PIPELINE_DIR,
+ env,
+ label: `check every source of ${project.id} is still fetchable`,
+ argv: ["node", script("check-availability.mjs"), manifest, "--out", path.join(project.dir, "out"), "--allow-missing"],
+ timeoutMs: 10 * 60_000,
+ };
+}
+
+/**
* @param {{ id: string, dir: string }} project
- * @param {{ preset?: string, only?: string | null, skipFetch?: boolean, env?: Record<string,string>, clipCount?: number }} [opts]
+ * @param {{ preset?: string, only?: string | null, skipFetch?: boolean, env?: Record<string,string>, clipCount?: number, options?: BuildOptions }} [opts]
* @returns {import("../trim").Step[]}
*/
-export function buildSteps(project, { preset = "fast", only = null, skipFetch = false, env = {}, clipCount = 20 } = {}) {
+export function buildSteps(project, { preset = "fast", only = null, skipFetch = false, env = {}, clipCount = 20, options = {} } = {}) {
const p = PRESETS[preset] ?? PRESETS.fast;
const manifest = path.join(project.dir, "video.manifest.json");
const outDir = path.join(project.dir, "out");
const base = { cwd: PIPELINE_DIR, env };
+ const quick = !!(options.chaptersOnly || options.preview);
- const steps = [
- {
- ...base,
- label: "check every source is still fetchable",
- argv: ["node", script("check-availability.mjs"), manifest, "--out", outDir],
- timeoutMs: 10 * 60_000,
- },
- {
- ...base,
- label: "resolve windows (dry — nothing is written)",
- argv: ["node", script("resolve-windows.mjs"), manifest],
- timeoutMs: 5 * 60_000,
- },
- ];
+ const steps = quick
+ ? []
+ : [
+ {
+ ...base,
+ label: "check every source is still fetchable",
+ argv: ["node", script("check-availability.mjs"), manifest, "--out", outDir],
+ timeoutMs: 10 * 60_000,
+ },
+ {
+ ...base,
+ label: "resolve windows (dry — nothing is written)",
+ argv: ["node", script("resolve-windows.mjs"), manifest],
+ timeoutMs: 5 * 60_000,
+ },
+ ];
const buildArgv = [
"node",
@@ -81,17 +111,20 @@ export function buildSteps(project, { preset = "fast", only = null, skipFetch =
"ndjson",
"--continue-on-error",
];
- if (!p.xfade) buildArgv.push("--no-xfade");
+ if (options.variant) buildArgv.push("--variant", options.variant);
+ if (!p.xfade || options.xfade === false) buildArgv.push("--no-xfade");
if (!p.chapters) buildArgv.push("--no-chapters");
if (skipFetch) buildArgv.push("--skip-fetch");
if (p.only && only) buildArgv.push("--only", only);
+ if (options.chaptersOnly) buildArgv.push("--chapters-only");
+ if (options.preview) buildArgv.push("--preview", String(options.preview.at), String(options.preview.dur));
steps.push({
...base,
- label: p.label,
+ label: options.chaptersOnly ? "retitle the chapters (no encode)" : options.preview ? `rail preview at ${options.preview.at}s` : p.label,
argv: buildArgv,
ndjson: true,
- timeoutMs: buildTimeoutMs(clipCount, p.xfade),
+ timeoutMs: quick ? 5 * 60_000 : buildTimeoutMs(clipCount, p.xfade),
});
// A build can exit 0 and still be wrong: a concat that produced nothing, a
@@ -100,11 +133,16 @@ export function buildSteps(project, { preset = "fast", only = null, skipFetch =
//
// Skipped for a one-clip preview, which deliberately does not produce a
// deliverable to measure.
- if (!p.only || !only) {
+ //
+ // Also skipped for a rail preview: it writes <slug>.preview.mp4, which is
+ // not the deliverable and has no chapters to count.
+ if ((!p.only || !only) && !options.preview) {
+ const verify = ["node", script("verify-build.mjs"), manifest, "--out", outDir];
+ if (options.variant) verify.push("--variant", options.variant);
steps.push({
...base,
label: "verify the file that came out",
- argv: ["node", script("verify-build.mjs"), manifest, "--out", outDir],
+ argv: verify,
timeoutMs: 5 * 60_000,
});
}
@@ -143,3 +181,17 @@ export function fetchSteps(project, clipId, pad) {
},
];
}
+
+/**
+ * One preflight per project, in order. What the check-sources job runs: the
+ * driver's own step 1, reused as-is, so `out/availability.json` is written by
+ * the same script whether a build or a re-check asked for it.
+ *
+ * `--allow-missing`, because a dead source here is a FINDING to record, not a
+ * failure to abort the rest of the list on.
+ * @param {{ id: string, dir: string }[]} projects
+ * @returns {import("../trim").Step[]}
+ */
+export function checkSourcesSteps(projects, env = {}) {
+ return projects.map((p) => availabilityStep(p, env));
+}
diff --git a/umtool/lib/report/export.mjs b/umtool/lib/report/export.mjs
@@ -0,0 +1,195 @@
+// The posting artifacts, derived rather than hand-made.
+//
+// Three real projects carry a hand-built `time · clip · source-link` table
+// beside the manifest (quartering-gout/toc.bbcode.txt, hasan-bike's .bbcode.txt
+// and .html, quartering-employee-count/report.html). Each is a pure derivation
+// of the manifest plus the build's chapter offsets, so this derives them.
+//
+// OFFSETS COME FROM THE BUILD, never from the windows. A window is source
+// seconds; a chapter start is deliverable seconds, after snapping, padding and
+// crossfades. `out/<variant>/chapters.ffmeta` (or the pre-variant
+// `out/chapters.ffmeta`) is what the build wrote, so it is read first. When it
+// is absent, segmentOffsets() -- the pipeline's own function -- is run over the
+// segments on disk. When neither exists there is nothing honest to say, and
+// the export refuses with the reason.
+//
+// Plain ESM, so `umtool export` runs from a terminal.
+import { readFile, readdir, stat } from "node:fs/promises";
+import path from "node:path";
+import { DEFAULT_VARIANT, selectVariant, segmentOffsets } from "report-to-video/build-video";
+import { citeUrlFor, channelFor, readAvailability, readManifest } from "../projects/report.mjs";
+
+export const EXPORT_FORMATS = ["toc-bbcode", "toc-markdown", "description", "chapters"];
+
+/** `m:ss` past the hour as `h:mm:ss`, floored -- the shape toc.bbcode.txt uses. */
+export const mmss = (t) => {
+ const s = Math.max(0, Math.floor(Number(t) || 0));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = String(s % 60).padStart(2, "0");
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
+};
+
+/** YouTube's chapter form: `00:00`, and `1:02:03` past the hour. */
+export const ytTime = (t) => {
+ const s = Math.max(0, Math.floor(Number(t) || 0));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = String(s % 60).padStart(2, "0");
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${String(m).padStart(2, "0")}:${sec}`;
+};
+
+/** ffmetadata back into numbers. The build wrote `START=<ms>` per chapter. */
+export function parseFfmeta(text) {
+ const out = [];
+ let cur = null;
+ for (const raw of String(text).split("\n")) {
+ const line = raw.trim();
+ if (line === "[CHAPTER]") {
+ cur = { start: null, end: null, title: "" };
+ out.push(cur);
+ continue;
+ }
+ if (!cur) continue;
+ const m = line.match(/^(START|END|title)=(.*)$/);
+ if (!m) continue;
+ if (m[1] === "title") cur.title = m[2].replace(/\\([=;#\\])/g, "$1");
+ else cur[m[1] === "START" ? "start" : "end"] = Number(m[2]) / 1000;
+ }
+ return out;
+}
+
+/** The chapter title the build would print: `chapter`, else the entry's own words. */
+const titleOf = (e, i) => e.chapter ?? (e.type === "clip" ? `${i + 1}. ${e.video}` : e.title ?? e.heading ?? `Card ${i + 1}`);
+
+/**
+ * Deliverable-second offsets for every entry of the chosen variant.
+ * @returns {Promise<{ starts: number[], source: "ffmeta"|"segments", file: string, note?: string } | { error: string }>}
+ */
+export async function chapterOffsets(dir, manifest, variant) {
+ const entries = manifest.timeline ?? [];
+ const outDir = path.join(dir, "out");
+ const ffmetaCandidates = [path.join(outDir, variant, "chapters.ffmeta"), path.join(outDir, "chapters.ffmeta")];
+ for (const file of ffmetaCandidates) {
+ const text = await readFile(file, "utf8").catch(() => null);
+ if (text === null) continue;
+ const chapters = parseFfmeta(text);
+ if (chapters.length !== entries.length) {
+ return {
+ error:
+ `${path.relative(dir, file)} has ${chapters.length} chapter(s) for ${entries.length} timeline entr(ies) — ` +
+ "it describes a different cut; rebuild (a chapters-only build is enough)",
+ };
+ }
+ return { starts: chapters.map((c) => c.start), source: "ffmeta", file };
+ }
+
+ // No ffmeta: the pipeline's own offset arithmetic over the segments on disk.
+ const segDirs = [path.join(outDir, variant, "segments"), path.join(outDir, "segments")];
+ for (const segDir of segDirs) {
+ const names = new Set(await readdir(segDir).catch(() => []));
+ if (!names.size) continue;
+ const missing = entries.filter((e) => !names.has(`${e.id}.mp4`)).map((e) => e.id);
+ if (missing.length) {
+ return { error: `${path.relative(dir, segDir)} is missing ${missing.join(", ")} — the cut was never fully built` };
+ }
+ const render = manifest.render ?? {};
+ const D = render.transition ?? 0.5;
+ const { starts } = await segmentOffsets(entries.map((e) => path.join(segDir, `${e.id}.mp4`)), D, render.fps);
+ // Land just past the crossfade, as muxChapters does.
+ return {
+ starts: starts.map((s, i) => (i === 0 ? 0 : s + D)),
+ source: "segments",
+ file: segDir,
+ note: `no chapters.ffmeta; offsets computed from the segments assuming a ${D}s crossfade — a hard-cut build would be earlier by that much per entry`,
+ };
+ }
+ return { error: "nothing built: no chapters.ffmeta and no segments under out/ — build first" };
+}
+
+/**
+ * @param {string} dir
+ * @param {string} format one of EXPORT_FORMATS
+ * @param {{ variant?: string }} [opts]
+ * @returns {Promise<{ text: string, format: string, variant: string, offsets: { source: string, file: string, note?: string } }>}
+ */
+export async function exportProject(dir, format, { variant = DEFAULT_VARIANT } = {}) {
+ if (!EXPORT_FORMATS.includes(format)) throw new Error(`format must be one of ${EXPORT_FORMATS.join(", ")}`);
+ const whole = await readManifest(dir);
+ if (!whole) throw new Error("no manifest");
+ const m = selectVariant(whole, variant);
+ const off = await chapterOffsets(dir, m, variant);
+ if (off.error) throw new Error(off.error);
+
+ const entries = m.timeline ?? [];
+ const rows = entries.map((e, i) => ({
+ e,
+ i,
+ at: off.starts[i],
+ title: titleOf(e, i),
+ cite: e.type === "clip" ? citeUrlFor(m, e) : null,
+ }));
+ const clipRows = rows.filter((r) => r.e.type === "clip");
+
+ let text;
+ if (format === "toc-bbcode") {
+ text = [
+ `[b]${m.title}${m.subtitle ? ` — ${m.subtitle}` : ""}[/b]`,
+ "",
+ "[table]",
+ "[tr][th]Time[/th][th]Clip[/th][th]Source[/th][/tr]",
+ ...clipRows.map((r) => `[tr][td]${mmss(r.at)}[/td][td]${r.title}[/td][td][url=${r.cite}]open[/url][/td][/tr]`),
+ "[/table]",
+ "",
+ ].join("\n");
+ } else if (format === "toc-markdown") {
+ text = [
+ `**${m.title}**${m.subtitle ? ` — ${m.subtitle}` : ""}`,
+ "",
+ "| Time | Clip | Source |",
+ "|---|---|---|",
+ ...clipRows.map((r) => `| ${mmss(r.at)} | ${r.title.replace(/\|/g, "\\|")} | [open](${r.cite}) |`),
+ "",
+ ].join("\n");
+ } else if (format === "chapters") {
+ // YouTube needs the first at 00:00 and every entry present, cards included.
+ text = rows.map((r) => `${ytTime(r.i === 0 ? 0 : r.at)} ${r.title}`).join("\n") + "\n";
+ } else {
+ const avail = await readAvailability(dir);
+ const titles = new Map((avail?.sources ?? []).map((s) => [s.key, s.title]));
+ const seen = new Map();
+ for (const r of clipRows) {
+ const key = `${channelFor(m, r.e)}/${r.e.video}`;
+ if (!seen.has(key)) seen.set(key, { key, title: titles.get(key) ?? null, cite: r.cite });
+ }
+ text = [
+ m.title,
+ ...(m.subtitle ? [m.subtitle] : []),
+ "",
+ ...clipRows.map((r) => `${mmss(r.at)} — ${r.title} — ${r.cite}`),
+ "",
+ "Sources:",
+ ...[...seen.values()].map((s) => `- ${s.title ? `${s.title} (${s.key})` : s.key} — ${s.cite}`),
+ "",
+ ].join("\n");
+ }
+ return { text, format, variant, offsets: { source: off.source, file: off.file, ...(off.note ? { note: off.note } : {}) } };
+}
+
+/** Which variants have anything on disk to export from, for the UI's row. */
+export async function exportableVariants(dir) {
+ const out = [];
+ for (const v of ["sourced", "full"]) {
+ const has = await Promise.all([
+ stat(path.join(dir, "out", v, "chapters.ffmeta")).then(() => true, () => false),
+ stat(path.join(dir, "out", v, "segments")).then((s) => s.isDirectory(), () => false),
+ ]);
+ if (has.some(Boolean)) out.push(v);
+ }
+ const legacy = await Promise.all([
+ stat(path.join(dir, "out", "chapters.ffmeta")).then(() => true, () => false),
+ stat(path.join(dir, "out", "segments")).then((s) => s.isDirectory(), () => false),
+ ]);
+ if (legacy.some(Boolean) && !out.includes("sourced")) out.unshift("sourced");
+ return out;
+}
diff --git a/umtool/lib/report/manifest-diff.mjs b/umtool/lib/report/manifest-diff.mjs
@@ -0,0 +1,114 @@
+// What changed between two manifests, by timeline entry.
+//
+// PURE. Two parsed manifests in, a list of changes out: added / removed /
+// moved / window (start, end, lock) / retyped -- the same shape the
+// ferret-rescue `.bak` comparison produced by hand (23 entries then, 10 clips
+// now, a whole restructure). Entries are matched by `id`, because array order
+// IS the cut and a moved entry is a change worth its own line.
+//
+// Presentation follows PlanDiffPanel's idiom (a list of typed changes), not its
+// code: that one diffs song plans by slot, and a timeline has no slots.
+
+const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
+const lockOf = (e) => ({ lock: !!e.lock, lockStart: !!e.lockStart, lockEnd: !!e.lockEnd });
+const sameLock = (a, b) => a.lock === b.lock && a.lockStart === b.lockStart && a.lockEnd === b.lockEnd;
+
+/**
+ * @param {object} before the older manifest (a snapshot)
+ * @param {object} after the newer one (usually the current manifest)
+ * @returns {{ changes: Array<{ kind: "added"|"removed"|"moved"|"window"|"retyped"|"field", id: string, type?: string, from?: unknown, to?: unknown, fields?: string[] }>, counts: Record<string, number>, same: boolean }}
+ */
+export function diffManifests(before, after) {
+ const a = (before?.timeline ?? []).filter((e) => e && e.id != null);
+ const b = (after?.timeline ?? []).filter((e) => e && e.id != null);
+ const byA = new Map(a.map((e, i) => [String(e.id), { e, i }]));
+ const byB = new Map(b.map((e, i) => [String(e.id), { e, i }]));
+ const changes = [];
+
+ for (const [id, { e, i }] of byA) {
+ if (!byB.has(id)) changes.push({ kind: "removed", id, type: e.type ?? "entry", at: i });
+ }
+ for (const [id, { e, i }] of byB) {
+ if (!byA.has(id)) changes.push({ kind: "added", id, type: e.type ?? "entry", at: i });
+ }
+
+ // Order, compared on the ids BOTH have: an entry is "moved" when its rank
+ // among the survivors changed, so a removal above it does not report every
+ // entry below as moved.
+ const survivorsA = a.filter((e) => byB.has(String(e.id))).map((e) => String(e.id));
+ const survivorsB = b.filter((e) => byA.has(String(e.id))).map((e) => String(e.id));
+ for (let i = 0; i < survivorsB.length; i += 1) {
+ const id = survivorsB[i];
+ const was = survivorsA.indexOf(id);
+ if (was !== i) changes.push({ kind: "moved", id, from: was, to: i });
+ }
+
+ for (const [id, { e: x }] of byA) {
+ const y = byB.get(id)?.e;
+ if (!y) continue;
+ if ((x.type ?? "entry") !== (y.type ?? "entry")) {
+ changes.push({ kind: "retyped", id, from: x.type ?? "entry", to: y.type ?? "entry" });
+ continue;
+ }
+ if (x.type === "clip") {
+ const fields = [];
+ if (num(x.start) !== num(y.start)) fields.push("start");
+ if (num(x.end) !== num(y.end)) fields.push("end");
+ if (!sameLock(lockOf(x), lockOf(y))) fields.push("lock");
+ if (String(x.video ?? "") !== String(y.video ?? "") || String(x.channel ?? "") !== String(y.channel ?? "")) fields.push("source");
+ if (fields.length) {
+ changes.push({
+ kind: "window",
+ id,
+ fields,
+ from: { start: num(x.start), end: num(x.end), ...lockOf(x), video: x.video, channel: x.channel ?? null },
+ to: { start: num(y.start), end: num(y.end), ...lockOf(y), video: y.video, channel: y.channel ?? null },
+ });
+ }
+ }
+ // Any other field that differs, named but not expanded: a quote rewrite or
+ // a new chapter title is a change, and a diff that hid it would be lying.
+ const other = [];
+ for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) {
+ if (["start", "end", "lock", "lockStart", "lockEnd", "video", "channel", "type", "id"].includes(k)) continue;
+ if (JSON.stringify(x[k]) !== JSON.stringify(y[k])) other.push(k);
+ }
+ if (other.length) changes.push({ kind: "field", id, fields: other.sort() });
+ }
+
+ const counts = {};
+ for (const c of changes) counts[c.kind] = (counts[c.kind] ?? 0) + 1;
+ const rank = { removed: 0, added: 1, retyped: 2, window: 3, moved: 4, field: 5 };
+ changes.sort((p, q) => rank[p.kind] - rank[q.kind] || String(p.id).localeCompare(String(q.id)));
+ return { changes, counts, same: changes.length === 0, entriesBefore: a.length, entriesAfter: b.length };
+}
+
+/** One line per change, for the CLI. */
+export function formatChange(c) {
+ switch (c.kind) {
+ case "added":
+ return `+ ${c.id} added (${c.type}, at ${c.at})`;
+ case "removed":
+ return `- ${c.id} removed (${c.type}, was at ${c.at})`;
+ case "moved":
+ return `~ ${c.id} moved ${c.from} -> ${c.to}`;
+ case "retyped":
+ return `~ ${c.id} retyped ${c.from} -> ${c.to}`;
+ case "window": {
+ const parts = [];
+ if (c.fields.includes("start") || c.fields.includes("end")) {
+ parts.push(`${c.from.start ?? "?"}–${c.from.end ?? "?"} -> ${c.to.start ?? "?"}–${c.to.end ?? "?"}`);
+ }
+ if (c.fields.includes("lock")) {
+ const l = (o) => ["lock", "lockStart", "lockEnd"].filter((k) => o[k]).join("+") || "unlocked";
+ parts.push(`${l(c.from)} -> ${l(c.to)}`);
+ }
+ if (c.fields.includes("source")) parts.push(`${c.from.channel ?? ""}/${c.from.video} -> ${c.to.channel ?? ""}/${c.to.video}`);
+ return `~ ${c.id} window: ${parts.join("; ")}`;
+ }
+ case "field":
+ return `~ ${c.id} ${c.fields.join(", ")} changed`;
+ default:
+ return `? ${c.id}`;
+ }
+}
diff --git a/umtool/lib/report/snapshots.mjs b/umtool/lib/report/snapshots.mjs
@@ -0,0 +1,125 @@
+// Revisions of a manifest, instead of `.bak` files copied by hand.
+//
+// Five real projects revise by copying: quartering-employee-count has five
+// `video.manifest.json.*.bak` files, ferret-rescue one, quartering-gout a
+// hand-copied `video.manifest.noqr.json`. This gives that practice a shape --
+// `revisions/<stamp>[-<label>].manifest.json`, a subdirectory so the project
+// walk (a project is a leaf; `revisions` is in SKIP_DIRS besides) never mistakes
+// one for a project -- and LISTS the legacy files beside it, by mtime, as
+// unlabelled snapshots. Reported, never renamed, never guessed at.
+//
+// Plain ESM: `umtool snapshot` and `umtool diff` run from a terminal.
+import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { MANIFEST_NAME } from "../projects/report.mjs";
+
+export const REVISIONS_DIR = "revisions";
+
+/** `20260825-143012`, seconds included: two snapshots a minute apart must not collide. */
+export function stampOf(d = new Date()) {
+ const p = (n) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
+}
+
+const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
+
+/** A snapshot's file name, or null when the name is not one. */
+export function parseSnapshotName(name) {
+ const m = name.match(/^(\d{8}-\d{6})(?:-(.+))?\.manifest\.json$/);
+ if (!m) return null;
+ return { stamp: m[1], label: m[2] ?? null };
+}
+
+/**
+ * The legacy shapes, as they exist on disk:
+ * video.manifest.json.bak the writer's own rate-limited backup
+ * video.manifest.json.<x>.bak employee-count's five
+ * video.manifest.<x>.json gout's noqr copy
+ * `<x>` is kept as the label because it is the only name the author gave it.
+ */
+export function parseLegacyName(name) {
+ if (name === MANIFEST_NAME) return null;
+ let m = name.match(/^video\.manifest\.json(?:\.(.+))?\.bak$/);
+ if (m) return { label: m[1] ?? null, legacy: true };
+ m = name.match(/^video\.manifest\.(.+)\.json$/);
+ if (m && !m[1].includes(".tmp-")) return { label: m[1], legacy: true };
+ return null;
+}
+
+/**
+ * Every snapshot, newest first. `rel` is project-relative and is what the diff
+ * and the page address a snapshot by; nothing here accepts a bare path.
+ * @returns {Promise<Array<{ rel: string, name: string, stamp: string | null, label: string | null, legacy: boolean, mtimeMs: number, size: number }>>}
+ */
+export async function listSnapshots(dir) {
+ const out = [];
+ const revDir = path.join(dir, REVISIONS_DIR);
+ for (const name of await readdir(revDir).catch(() => [])) {
+ const p = parseSnapshotName(name);
+ if (!p) continue;
+ const st = await stat(path.join(revDir, name)).catch(() => null);
+ if (!st) continue;
+ out.push({ rel: path.posix.join(REVISIONS_DIR, name), name, stamp: p.stamp, label: p.label, legacy: false, mtimeMs: st.mtimeMs, size: st.size });
+ }
+ for (const name of await readdir(dir).catch(() => [])) {
+ const p = parseLegacyName(name);
+ if (!p) continue;
+ const st = await stat(path.join(dir, name)).catch(() => null);
+ if (!st?.isFile()) continue;
+ out.push({ rel: name, name, stamp: null, label: p.label, legacy: true, mtimeMs: st.mtimeMs, size: st.size });
+ }
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
+}
+
+/** Resolve a project-relative snapshot name to a path, refusing anything else. */
+export function snapshotPath(dir, rel) {
+ const r = String(rel ?? "");
+ if (!r || r.includes("..") || path.isAbsolute(r)) return null;
+ const inRev = r.startsWith(`${REVISIONS_DIR}/`) && parseSnapshotName(r.slice(REVISIONS_DIR.length + 1));
+ if (!inRev && !parseLegacyName(r)) return null;
+ return path.join(dir, r);
+}
+
+/**
+ * Copy the manifest into revisions/. The copy is byte-for-byte: a snapshot
+ * that was re-serialised would diff against its own source.
+ */
+/**
+ * @param {string} dir
+ * @param {{ label?: string | null, now?: Date }} [opts]
+ */
+export async function createSnapshot(dir, { label = null, now = new Date() } = {}) {
+ if (label !== null && !LABEL_RE.test(String(label))) {
+ throw new Error(`a label is letters, digits, dots, dashes and underscores — not "${label}"`);
+ }
+ const src = path.join(dir, MANIFEST_NAME);
+ if (!(await stat(src).then((s) => s.isFile(), () => false))) throw new Error(`no ${MANIFEST_NAME} in ${dir}`);
+ await mkdir(path.join(dir, REVISIONS_DIR), { recursive: true });
+ const name = `${stampOf(now)}${label ? `-${label}` : ""}.manifest.json`;
+ const dst = path.join(dir, REVISIONS_DIR, name);
+ if (await stat(dst).then(() => true, () => false)) throw new Error(`${name} already exists — wait a second`);
+ await copyFile(src, dst);
+ return { rel: path.posix.join(REVISIONS_DIR, name), name };
+}
+
+/**
+ * The build's "stamp aside" of a deliverable, recorded as a snapshot of the
+ * manifest that produced it. The label names the moved file so the pairing is
+ * visible in the directory listing without opening anything.
+ */
+export async function recordBuildSnapshot(dir, asideName) {
+ const label = `built-${String(asideName).replace(/\.mp4$/, "").replace(/[^A-Za-z0-9._-]/g, "_")}`;
+ return createSnapshot(dir, { label });
+}
+
+export async function readSnapshot(dir, rel) {
+ const file = snapshotPath(dir, rel);
+ if (!file) throw new Error(`not a snapshot of this project: ${rel}`);
+ return JSON.parse(await readFile(file, "utf8"));
+}
+
+/** Exposed for tests: write a snapshot with a given stamp. */
+export async function writeSnapshotRaw(dir, name, text) {
+ await mkdir(path.join(dir, REVISIONS_DIR), { recursive: true });
+ await writeFile(path.join(dir, REVISIONS_DIR, name), text, "utf8");
+}
diff --git a/umtool/lib/tool-types.ts b/umtool/lib/tool-types.ts
@@ -0,0 +1,19 @@
+// Types only. No `node:` import -- the dashboard's client panel imports these.
+
+export type ToolReport = {
+ id: string;
+ bin: string;
+ present: boolean;
+ version: string | null;
+ error: string | null;
+ neededBy: string[];
+ /** A report build cannot run at all without it. */
+ required: boolean;
+};
+
+export type DoctorReport = {
+ checkedAt: number;
+ tools: ToolReport[];
+ /** Every `required` tool is present. */
+ ok: boolean;
+};
diff --git a/umtool/lib/tools.mjs b/umtool/lib/tools.mjs
@@ -0,0 +1,98 @@
+// Which external tools are on this machine, and which pipeline needs which.
+//
+// Plain ESM with no app imports so `umtool doctor` runs from a terminal, and so
+// the app's /api/doctor and the CLI cannot disagree about what was probed.
+//
+// THE ONE RULE: this is never run during a render, and never from a page
+// render. Probing seven binaries is ~100 ms of fork/exec, which is nothing once
+// and a tax on every request if it leaks into a page. So the app keeps a cached
+// report with a TTL and the dashboard shows the cache or "not checked" plus a
+// button; only the button and the CLI probe.
+import { execFile } from "node:child_process";
+import { stat } from "node:fs/promises";
+import path from "node:path";
+import { promisify } from "node:util";
+import { SONG_SCRATCH } from "./paths.mjs";
+
+const execFileP = promisify(execFile);
+
+/** Mirrors lib/faces.ts, which re-exports these so the two cannot drift. */
+export const facedetPython = () =>
+ process.env.FACEDET_PYTHON ?? path.join(SONG_SCRATCH, "facedet", "bin", "python");
+export const songCode = () =>
+ process.env.SONG_CODE_DIR ? path.resolve(process.env.SONG_CODE_DIR) : path.join(process.cwd(), "song");
+export const facecropPy = () => path.join(songCode(), "facecrop.py");
+
+/**
+ * The tools, with the env override each pipeline script honours. `required`
+ * marks the ones a report build cannot run without at all; the rest are needed
+ * by parts of a build (the cards, the rail) or by another kind.
+ *
+ * `magick`, not `convert`: every ImageMagick call in the pipeline is
+ * `execFile("magick", …)` (ImageMagick 7). A machine with only `convert` has
+ * ImageMagick and still cannot build a card, and the report says so.
+ */
+export const TOOLS = () => [
+ { id: "ffmpeg", bin: process.env.FFMPEG_BIN ?? "ffmpeg", args: ["-version"], neededBy: ["report-video build", "song cuts", "mix"], required: true },
+ { id: "ffprobe", bin: process.env.FFPROBE_BIN ?? "ffprobe", args: ["-version"], neededBy: ["report-video verify", "browse posters", "mix"], required: true },
+ { id: "yt-dlp", bin: process.env.YTDLP_BIN ?? "yt-dlp", args: ["--version"], neededBy: ["report-video fetch", "check-sources"], required: true },
+ { id: "qrencode", bin: process.env.QRENCODE_BIN ?? "qrencode", args: ["--version"], neededBy: ["report-video QR"], required: true },
+ { id: "imagemagick", bin: "magick", args: ["-version"], fallback: "convert", neededBy: ["report-video cards, rail, chrome"], required: false },
+ { id: "rsvg-convert", bin: process.env.RSVG_BIN ?? "rsvg-convert", args: ["--version"], neededBy: ["report-video chart"], required: false },
+ { id: "python", bin: facedetPython(), args: ["--version"], neededBy: ["faces (facecrop.py)"], required: false },
+ { id: "facecrop.py", file: facecropPy(), neededBy: ["faces"], required: false },
+];
+
+const firstLine = (s) => String(s ?? "").split("\n").find((l) => l.trim()) ?? "";
+/** `ffmpeg version 7.1.1 …` -> `7.1.1`; `2025.08.11` -> itself. */
+const versionOf = (text) => {
+ const line = firstLine(text);
+ const m = line.match(/(\d+\.\d+(?:\.\d+)*(?:[-_.][A-Za-z0-9]+)*)/);
+ return m ? m[1] : line.slice(0, 60) || null;
+};
+
+async function probeOne(t) {
+ const base = { id: t.id, bin: t.file ?? t.bin, neededBy: t.neededBy, required: !!t.required };
+ if (t.file) {
+ const ok = await stat(t.file).then((s) => s.isFile(), () => false);
+ return { ...base, present: ok, version: null, error: ok ? null : `${t.file} is missing` };
+ }
+ const run = async (bin) => {
+ try {
+ const { stdout, stderr } = await execFileP(bin, t.args, { timeout: 5000, maxBuffer: 1 << 20 });
+ return { present: true, version: versionOf(stdout || stderr), error: null };
+ } catch (err) {
+ // ENOENT is "not on this machine". Any other exit means the binary RAN --
+ // an unusual version flag, say -- which is presence, honestly reported.
+ if (err?.code === "ENOENT") return { present: false, version: null, error: `${bin}: not found` };
+ const said = versionOf(err?.stdout || err?.stderr);
+ return { present: true, version: said || null, error: said ? null : `${bin} exited ${err?.code ?? "?"} on ${t.args.join(" ")}` };
+ }
+ };
+ let r = await run(t.bin);
+ if (!r.present && t.fallback) {
+ const f = await run(t.fallback);
+ if (f.present) {
+ r = {
+ present: false,
+ version: f.version,
+ error: `only \`${t.fallback}\` is installed (ImageMagick 6); the pipeline calls \`${t.bin}\``,
+ };
+ }
+ }
+ return { ...base, ...r };
+}
+
+/**
+ * Run every tool's version flag. ~100 ms in total, in parallel.
+ * @returns {Promise<{ checkedAt: number, tools: Array<{id:string,bin:string,present:boolean,version:string|null,error:string|null,neededBy:string[],required:boolean}>, ok: boolean }>}
+ */
+export async function probeTools() {
+ const tools = await Promise.all(TOOLS().map(probeOne));
+ return {
+ checkedAt: Date.now(),
+ tools,
+ // The report pipeline's hard requirements, all present.
+ ok: tools.every((t) => !t.required || t.present),
+ };
+}