Archilyzer · Source

archilyzer

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

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:
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 19+++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/page.tsx | 3+++
Dumtool/app/api/browse/init/route.ts | 79-------------------------------------------------------------------------------
Aumtool/app/api/doctor/route.ts | 19+++++++++++++++++++
Aumtool/app/api/jobs/route.ts | 15+++++++++++++++
Aumtool/app/api/projects/new/route.ts | 22++++++++++++++++++++++
Mumtool/app/api/report/build/route.ts | 49++++++++++++++++++++++++++++++++++++++++++++-----
Aumtool/app/api/report/check-sources/route.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/export/route.ts | 29+++++++++++++++++++++++++++++
Mumtool/app/api/report/fetch/route.ts | 2+-
Aumtool/app/api/report/snapshot/route.ts | 39+++++++++++++++++++++++++++++++++++++++
Mumtool/app/browse/decisions/page.tsx | 4++--
Mumtool/app/browse/page.tsx | 9+++++----
Mumtool/app/layout.tsx | 4++--
Mumtool/app/page.tsx | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mumtool/bin/umtool.mjs | 311++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mumtool/components/AppNav.tsx | 39++++++++++++++++++++++++++-------------
Mumtool/components/LastPage.tsx | 93++++++-------------------------------------------------------------------------
Aumtool/components/NavGroup.tsx | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/NewProjectMenu.tsx | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dumtool/components/NewSongForm.tsx | 97-------------------------------------------------------------------------------
Aumtool/components/dashboard/CheckSourcesButton.tsx | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/dashboard/DecisionsPanel.tsx | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/dashboard/DeliverablesPanel.tsx | 39+++++++++++++++++++++++++++++++++++++++
Aumtool/components/dashboard/NowLive.tsx | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/dashboard/NowPanel.tsx | 46++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/dashboard/Panel.tsx | 33+++++++++++++++++++++++++++++++++
Aumtool/components/dashboard/ResumeChip.tsx | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/dashboard/SourcesPanel.tsx | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/dashboard/StateStrip.tsx | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/dashboard/ToolsPanel.tsx | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/ReportBuildChain.tsx | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mumtool/components/projects/ReportProject.tsx | 301+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Aumtool/components/projects/SnapshotButton.tsx | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/docs/README.md | 24++++++++++++++++++++++--
Mumtool/docs/build.md | 15+++++++++++++++
Mumtool/docs/cli.md | 44+++++++++++++++++++++++++++++++++++++++++++-
Aumtool/docs/dashboard.md | 36++++++++++++++++++++++++++++++++++++
Mumtool/docs/decisions.md | 1+
Mumtool/docs/report-video.md | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/browse.spec.ts | 10+++++++---
Mumtool/e2e/build.spec.ts | 26++++++++++++++++++++++++++
Aumtool/e2e/dashboard.spec.ts | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/fixtures/make-fixture.mjs | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/e2e/last-page.spec.ts | 116+++++++++++++++++++++++++++++++------------------------------------------------
Aumtool/e2e/report-longform.spec.ts | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/activity-types.ts | 3+++
Aumtool/lib/activity.ts | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/dashboard.ts | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/doctor.ts | 21+++++++++++++++++++++
Mumtool/lib/faces.ts | 11+++++++----
Mumtool/lib/jobs.ts | 37++++++++++++++++++++++++++-----------
Mumtool/lib/last-page.ts | 10+++++++---
Mumtool/lib/mix.ts | 12++++++++----
Mumtool/lib/project-types.ts | 6++++++
Mumtool/lib/projects/index-db.mjs | 4+++-
Mumtool/lib/projects/kinds.mjs | 12++++++++++++
Mumtool/lib/projects/report.mjs | 180++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Aumtool/lib/projects/scaffold-song.ts | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/projects/scaffold.mjs | 258+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/projects/scaffold.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/projects/song.mjs | 8+++++++-
Mumtool/lib/report/driver.mjs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Aumtool/lib/report/export.mjs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/manifest-diff.mjs | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/snapshots.mjs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/tool-types.ts | 19+++++++++++++++++++
Aumtool/lib/tools.mjs | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 &amp; 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&rsquo;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&rsquo;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), + }; +}