commit 657789f4f24952d148eebdea4bb0df934cd7e1c2
parent ce1b8f401cb24c10f5156aaeae0691836f945489
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 01:36:36 -0400
deliver: the half of the work that starts when the walk ends
The bench answers one question per clip. What a report owes its readers after
that was five shell scripts and a python file in ONE project's directory, run
in an order somebody had to remember: cut the confirmed clips, fold the
rulings back into the prose, rebuild each report variant, package a batch of
mp4s for whoever is writing the piece.
None of it is project-specific except the prose, so it is a surface on the
project page:
counts BY SECTION, folded on the id's letter prefix, because "69 of 163
confirmed" says nothing about the section where eleven of nineteen clips
were thrown out -- and that section is the one whose argument has to change;
"cut N confirmed clips from cache" as ONE JOB WITH ONE STEP PER CLIP, so the
k-of-n and the Stop are the job runner's own (stepIndex, and a cancel that
kills the running step's process group) rather than a counter in the browser
that a reload loses. jobs.ts already refuses a second job, which is also why
a bench fetch and a cut cannot race over the same clips/ directory;
"build share batch" into share-<name>/{orig,std,small}/<Section>/, excluding
every id a previous share-*/LIST.md already shipped and every clip ruled
incorrect. The folders are the record -- a second shared.json would drift
from them;
"apply rulings" RUNS the project's own apply-manifest.py and then umtool
corrections, streaming both logs verbatim. What folding a ruling back into
an argument means is a decision the report's author already wrote down, and
paraphrasing its output here would be this page deciding what the operator
has to read. It is refused while the walk is unfinished unless "apply
partial" is ticked: the script deletes the mp4 of every clip whose window
moved, and running it over a half-walked cut bakes "nobody looked at this
yet" into the deliverable as if it were a verdict;
"rebuild reports" runs build.py once per content*.py present, with build.py's
own stem convention. The variant list comes from the directory, never from
the client, which is what stops it being able to run an arbitrary file.
Section FOLDER names are read out of the content module's own headings, nth
heading to nth letter -- a convention, so it is read defensively and a section
with no heading is named by its letter alone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
7 files changed, 1235 insertions(+), 0 deletions(-)
diff --git a/umtool/app/api/report/deliver/route.ts b/umtool/app/api/report/deliver/route.ts
@@ -0,0 +1,169 @@
+import { cancelJob, getJob, jobView, runningJob, startJob } from "@/lib/jobs";
+import {
+ applyRulingsSteps,
+ cutSteps,
+ rebuildReportSteps,
+ shareBatchSteps,
+} from "@/lib/report/driver.mjs";
+import { deliverStateOf } from "@/lib/report/deliver.mjs";
+import { readClipDetail } from "@/lib/projects/report.mjs";
+import { projectRef } from "@/lib/projects";
+import type { Step } from "@/lib/trim";
+
+export const dynamic = "force-dynamic";
+
+// DELIVERING a walked report: the four things that happen after the last clip
+// is judged.
+//
+// cut the confirmed clips that have no mp4 yet, out of the cache
+// share a batch of those, in three encodes, for whoever is writing
+// apply the project's own apply-manifest.py, then `umtool corrections`
+// rebuild build.py, once per content variant present
+//
+// Each is a JOB, and jobs.ts allows exactly one at a time. That is not a
+// limitation to work around here: every one of these reads or writes the same
+// clips/ directory that a bench fetch writes into, and two of them at once
+// would race over the same file. A second request gets 409 with the name of
+// what is running, in the same words /api/report/build uses.
+//
+// The client sends a project id, an action, and at most a batch name. Never a
+// path and never an argv: the step list is built server-side from the
+// directory's own contents, which is what stops "rebuild" from being able to
+// run an arbitrary python file.
+
+const ACTIONS = ["cut", "share", "apply", "rebuild"] as const;
+type Action = (typeof ACTIONS)[number];
+
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const headers = { "cache-control": "no-store" };
+ const id = url.searchParams.get("job");
+ if (id) {
+ const job = getJob(id);
+ if (!job) return Response.json({ error: "no such job" }, { status: 404, headers });
+ return Response.json(jobView(job, Number(url.searchParams.get("since") ?? 0)), { headers });
+ }
+
+ const projectId = url.searchParams.get("project") ?? "";
+ const project = await projectRef(projectId);
+ if (!project) return Response.json({ error: "no such project" }, { status: 404, headers });
+ const detail = await readClipDetail(project.dir);
+ if (!detail) return Response.json({ error: "no manifest" }, { status: 400, headers });
+ const state = await deliverStateOf(project, {
+ manifest: detail.manifest,
+ entries: detail.entries,
+ });
+ const running = runningJob();
+ return Response.json({ ...state, running: running ? jobView(running) : null }, { headers });
+}
+
+export async function POST(request: Request) {
+ const url = new URL(request.url);
+ const cancel = url.searchParams.get("cancel");
+ // Stop ABANDONS THE REST, it does not undo what finished. Every artefact
+ // here is content-addressed by clip id, so a cancelled cut is a paused one:
+ // pressing the button again skips the clips that already have a file.
+ if (cancel) return Response.json({ cancelled: cancelJob(cancel) });
+
+ const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
+ const action = String(body.action ?? "") as Action;
+ if (!ACTIONS.includes(action)) {
+ return Response.json({ error: `action must be one of ${ACTIONS.join(", ")}` }, { status: 400 });
+ }
+
+ const project = await projectRef(String(body.project ?? ""));
+ if (!project) return Response.json({ error: "no such project" }, { status: 404 });
+ const detail = await readClipDetail(project.dir);
+ if (!detail) return Response.json({ error: "no manifest" }, { status: 400 });
+ const state = await deliverStateOf(project, {
+ manifest: detail.manifest,
+ entries: detail.entries,
+ });
+ if (!state) return Response.json({ error: "no manifest" }, { status: 400 });
+
+ let steps: Step[] = [];
+ let kind = "";
+
+ if (action === "cut") {
+ // The list is the SERVER's: confirmed, no mp4, and a window on this disk
+ // that holds it. A client-supplied list could name a clip nobody judged.
+ const ids = state.needCut.map((c: { id: string }) => c.id);
+ if (!ids.length) {
+ return Response.json(
+ { error: "every confirmed clip already has a file — nothing to cut", ok: false },
+ { status: 400 },
+ );
+ }
+ steps = cutSteps(project, ids);
+ kind = `cut ${project.id} (${ids.length} clips)`;
+ } else if (action === "share") {
+ const name = String(body.name ?? state.nextName);
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
+ return Response.json(
+ { error: "a batch name is letters, digits, dot, dash and underscore" },
+ { status: 400 },
+ );
+ }
+ if (!state.candidates.length) {
+ return Response.json(
+ { error: "nothing to ship: every confirmed clip is already shared, or not cut yet" },
+ { status: 400 },
+ );
+ }
+ steps = shareBatchSteps(project, name);
+ kind = `share-${name} ${project.id} (${state.candidates.length} clips)`;
+ } else if (action === "apply") {
+ if (!state.hasApplyScript) {
+ return Response.json(
+ { error: "this project has no apply-manifest.py — there is nothing to fold back into" },
+ { status: 400 },
+ );
+ }
+ // REFUSED WHILE THE WALK IS UNFINISHED, and this is the one guard here
+ // that is about judgement rather than about files. apply-manifest.py syncs
+ // clips.json from the manifest and deletes the mp4 of every clip whose
+ // window moved; running it over a half-walked cut bakes "nobody has looked
+ // at this yet" into the deliverable as though it were a verdict. `partial`
+ // is how somebody says they meant it.
+ if (state.review.unreviewed > 0 && !body.partial) {
+ return Response.json(
+ {
+ error:
+ `${state.review.unreviewed} of ${state.review.total} clips have not been judged — ` +
+ "finish the walk, or tick “apply partial” to fold back what there is",
+ unreviewed: state.review.unreviewed,
+ needsPartial: true,
+ },
+ { status: 409 },
+ );
+ }
+ steps = applyRulingsSteps(project);
+ kind = `apply rulings ${project.id}`;
+ } else {
+ if (!state.hasBuildScript || !state.variants.length) {
+ return Response.json(
+ { error: "this project has no build.py and content*.py to render" },
+ { status: 400 },
+ );
+ }
+ steps = rebuildReportSteps(project, state.variants);
+ kind = `rebuild ${project.id} (${state.variants.length} variants)`;
+ }
+
+ 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(kind, steps, { project: project.id });
+ return Response.json(
+ { ok: true, action, 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/bin/share-batch.mjs b/umtool/bin/share-batch.mjs
@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+// Package the confirmed clips nobody has been sent yet.
+//
+// node bin/share-batch.mjs --project <id> --name <batch name>
+//
+// Spawned by the Deliver panel through lib/jobs.ts, for the reason every other
+// long step is spawned: a 53-clip batch is two encodes per clip, its ffmpeg
+// children have to be killable as a group, and its progress has to be a log
+// somebody can read while it runs.
+//
+// The work is lib/report/deliver.mjs's, so `umtool` and the button cannot
+// produce different batches.
+import process from "node:process";
+import { resolveProject } from "../lib/projects/core.mjs";
+import { buildShareBatch } from "../lib/report/deliver.mjs";
+
+const argv = process.argv.slice(2);
+const val = (flag) => {
+ const i = argv.indexOf(flag);
+ return i < 0 ? null : argv[i + 1];
+};
+
+const projectArg = val("--project");
+const name = val("--name");
+if (!projectArg || !name) {
+ console.error("usage: share-batch.mjs --project <id> --name <batch name>");
+ process.exit(2);
+}
+if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
+ console.error(`"${name}" is not a batch name: letters, digits, dot, dash and underscore`);
+ process.exit(2);
+}
+
+const r = await resolveProject(projectArg);
+if (!r.project) {
+ console.error(`no project matches "${projectArg}"`);
+ process.exit(2);
+}
+
+try {
+ await buildShareBatch(r.project, name, { log: (l) => console.log(l) });
+} catch (e) {
+ console.error(e instanceof Error ? e.message : String(e));
+ process.exit(1);
+}
diff --git a/umtool/components/projects/DeliverActions.tsx b/umtool/components/projects/DeliverActions.tsx
@@ -0,0 +1,244 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { buttonVariants } from "@/components/ui/button";
+
+// ---------------------------------------------------------------------------
+// The four things that happen after the last clip is judged.
+//
+// ONE JOB AT A TIME, and every button here says so rather than queueing: the
+// server refuses a second one with the name of what is running (409), and the
+// same rule is why a bench fetch and a cut cannot overlap. They write the same
+// clips/ directory.
+//
+// `k of n` IS THE JOB'S OWN COUNT. The cut is one step per clip, so stepIndex
+// and steps.length are the progress -- no second counter kept in the browser
+// that a reload would lose, and a Stop that kills the running step's process
+// group rather than a promise nobody can interrupt.
+//
+// STOP ABANDONS THE REST, it does not undo. Every file here is named for its
+// clip, so pressing the button again resumes: the clips that have a file are
+// not in the server's list any more.
+// ---------------------------------------------------------------------------
+
+type StepView = { label: string; argv: string[] };
+type JobView = {
+ id: string;
+ kind: string;
+ state: "running" | "done" | "failed";
+ stepIndex: number;
+ steps: StepView[];
+ error: string | null;
+ log: string[];
+ next: number;
+};
+
+type Variant = { module: string; out: string };
+
+export default function DeliverActions({
+ project,
+ needCut,
+ notFetched,
+ candidates,
+ nextName,
+ unreviewed,
+ hasApply,
+ hasBuild,
+ variants,
+}: {
+ project: string;
+ /** Confirmed, no mp4, and a window on this disk that holds it. */
+ needCut: number;
+ /** Confirmed, no mp4, and nothing cached — a download, not a cut. */
+ notFetched: number;
+ /** What a batch would ship: confirmed, cut, and not already shared. */
+ candidates: number;
+ nextName: string;
+ unreviewed: number;
+ hasApply: boolean;
+ hasBuild: boolean;
+ variants: Variant[];
+}) {
+ const [job, setJob] = useState<JobView | null>(null);
+ const [error, setError] = useState<string | null>(null);
+ const [busy, setBusy] = useState(false);
+ const [partial, setPartial] = useState(false);
+ const [name, setName] = useState(nextName);
+ const since = useRef(0);
+
+ // Adopt a job already running, so a reload does not lose one. Also how a
+ // second tab sees the first tab's cut.
+ useEffect(() => {
+ void fetch(`/api/report/deliver?project=${encodeURIComponent(project)}`, { cache: "no-store" })
+ .then((r) => r.json())
+ .then((j) => {
+ if (j.running) {
+ setJob(j.running as JobView);
+ since.current = (j.running as JobView).log.length;
+ }
+ })
+ .catch(() => {});
+ }, [project]);
+
+ useEffect(() => {
+ if (!job || job.state !== "running") return;
+ const t = setInterval(async () => {
+ const r = await fetch(`/api/report/deliver?job=${job.id}&since=${since.current}`, {
+ cache: "no-store",
+ });
+ if (!r.ok) return;
+ const j = (await r.json()) as JobView;
+ since.current = j.next;
+ setJob((prev) => (prev ? { ...j, log: [...prev.log, ...j.log] } : j));
+ }, 700);
+ return () => clearInterval(t);
+ }, [job]);
+
+ const post = useCallback(
+ async (action: string, extra: Record<string, unknown> = {}) => {
+ setBusy(true);
+ setError(null);
+ const r = await fetch("/api/report/deliver", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project, action, ...extra }),
+ });
+ const j = (await r.json().catch(() => ({}))) as Record<string, unknown>;
+ setBusy(false);
+ if (!r.ok) {
+ setError(String(j.error ?? `HTTP ${r.status}`));
+ return;
+ }
+ since.current = 0;
+ setJob(j.job as JobView);
+ },
+ [project],
+ );
+
+ const running = job?.state === "running";
+ const n = job?.steps.length ?? 0;
+ const k = Math.min((job?.stepIndex ?? 0) + 1, n);
+
+ return (
+ <div data-deliver-actions="" className="space-y-2">
+ <div className="flex flex-wrap items-center gap-2">
+ <button
+ type="button"
+ data-action="deliver-cut"
+ disabled={busy || running || !needCut}
+ className={buttonVariants({ size: "sm" })}
+ onClick={() => void post("cut")}
+ >
+ cut {needCut} confirmed clip{needCut === 1 ? "" : "s"} from cache
+ </button>
+
+ <span className="flex items-center gap-1">
+ <input
+ data-batch-name=""
+ aria-label="batch name"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ className="w-44 rounded border border-[var(--color-line)] bg-[var(--color-bg)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text)]"
+ />
+ <button
+ type="button"
+ data-action="deliver-share"
+ disabled={busy || running || !candidates}
+ className={buttonVariants({ variant: "ghost", size: "sm" })}
+ onClick={() => void post("share", { name })}
+ >
+ build share batch ({candidates})
+ </button>
+ </span>
+
+ {hasApply && (
+ <span className="flex items-center gap-1">
+ <button
+ type="button"
+ data-action="deliver-apply"
+ disabled={busy || running}
+ className={buttonVariants({ variant: "ghost", size: "sm" })}
+ onClick={() => void post("apply", partial ? { partial: true } : {})}
+ >
+ apply rulings
+ </button>
+ {unreviewed > 0 && (
+ <label className="flex items-center gap-1 text-[11px] text-[var(--color-dim)]">
+ <input
+ type="checkbox"
+ data-action="apply-partial"
+ checked={partial}
+ onChange={(e) => setPartial(e.target.checked)}
+ />
+ apply partial ({unreviewed} unjudged)
+ </label>
+ )}
+ </span>
+ )}
+
+ {hasBuild && variants.length > 0 && (
+ <button
+ type="button"
+ data-action="deliver-rebuild"
+ disabled={busy || running}
+ className={buttonVariants({ variant: "ghost", size: "sm" })}
+ onClick={() => void post("rebuild")}
+ >
+ rebuild {variants.length} report{variants.length === 1 ? "" : "s"}
+ </button>
+ )}
+
+ {running && (
+ <button
+ type="button"
+ data-action="deliver-stop"
+ className={buttonVariants({ variant: "ghost", size: "sm" })}
+ onClick={() =>
+ void fetch(`/api/report/deliver?cancel=${job!.id}`, { method: "POST" })
+ }
+ >
+ stop
+ </button>
+ )}
+ </div>
+
+ {notFetched > 0 && (
+ <p className="text-[11px] text-[var(--color-dim)]">
+ {notFetched} confirmed clip{notFetched === 1 ? " has" : "s have"} nothing cached to cut
+ from — those are a download, listed below.
+ </p>
+ )}
+
+ {error && (
+ <p data-deliver-error="" className="text-[11px] text-[var(--color-bad)]">
+ {error}
+ </p>
+ )}
+
+ {job && (
+ <div data-deliver-job={job.id} data-deliver-state={job.state} className="space-y-1">
+ <div className="flex flex-wrap items-baseline gap-2 text-[11px]">
+ <span className="font-mono text-[var(--color-text)]">{job.kind}</span>
+ <span data-deliver-progress="" className="num text-[var(--color-dim)]">
+ {job.state === "running" ? `${k} of ${n}` : `${job.state} · ${n} step${n === 1 ? "" : "s"}`}
+ </span>
+ {job.steps[job.stepIndex] && job.state === "running" && (
+ <span className="text-[var(--color-dim)]">{job.steps[job.stepIndex].label}</span>
+ )}
+ {job.error && <span className="text-[var(--color-bad)]">{job.error}</span>}
+ </div>
+ {/* The log VERBATIM. apply-manifest.py prints the prose lines that
+ cite each clip ruled incorrect, and that listing is the whole
+ point of running it — paraphrasing it here would be this page
+ deciding what the operator has to read. */}
+ <pre
+ data-deliver-log=""
+ className="max-h-64 overflow-auto rounded border border-[var(--color-line)] bg-[var(--color-bg)] px-2 py-1 font-mono text-[10px] leading-tight text-[var(--color-dim)]"
+ >
+ {job.log.join("\n")}
+ </pre>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/umtool/components/projects/DeliverSection.tsx b/umtool/components/projects/DeliverSection.tsx
@@ -0,0 +1,263 @@
+import Link from "next/link";
+import { deliverStateOf } from "@/lib/report/deliver.mjs";
+import { badgeVariants } from "@/components/ui/badge";
+import type { ProjectRef } from "@/lib/project-types";
+import DeliverActions from "./DeliverActions";
+
+// ---------------------------------------------------------------------------
+// DELIVER: the half of the work that starts when the walk ends.
+//
+// The bench answers one question per clip. What the report owes its readers is
+// a different list -- which sections were actually confirmed, which confirmed
+// clips have no file yet, which prose still cites a clip the walk threw out --
+// and until now that list lived in one project's shell scripts and in whoever
+// remembered to run them in order.
+//
+// COUNTED BY SECTION, because that is how the report is read and how it falls
+// apart: "69 of 163 confirmed" says nothing about a section where eleven of
+// nineteen clips were ruled wrong, and that section is the one whose argument
+// has to change.
+// ---------------------------------------------------------------------------
+
+type Section = {
+ letter: string;
+ heading: string | null;
+ folder: string;
+ total: number;
+ confirmed: number;
+ incorrect: number;
+ unreviewed: number;
+ cut: number;
+};
+
+type State = {
+ sections: Section[];
+ review: { total: number; confirmed: number; incorrect: number; unreviewed: number };
+ cut: number;
+ needCut: { id: string; seconds: number }[];
+ notFetched: string[];
+ batches: { name: string; label: string; count: number; hasList: boolean }[];
+ excluded: { shared: string[]; incorrect: string[] };
+ candidates: { id: string; section: string; file: string }[];
+ nextName: string;
+ variants: { file: string; module: string; out: string }[];
+ incorrect: { id: string; correction: string; hits: { file: string; line: number; text: string }[] }[];
+ hasApplyScript: boolean;
+ hasBuildScript: boolean;
+};
+
+export default async function DeliverSection({
+ project,
+ manifest,
+ entries,
+}: {
+ project: ProjectRef;
+ manifest: unknown;
+ /** readClipDetail's entries, so `fetched` is the bench's own answer. */
+ entries: unknown[];
+}) {
+ const state = (await deliverStateOf(project, { manifest, entries })) as State | null;
+ if (!state) return null;
+ const { review } = state;
+
+ return (
+ <section
+ data-deliver=""
+ data-deliver-confirmed={review.confirmed}
+ data-deliver-incorrect={review.incorrect}
+ data-deliver-unreviewed={review.unreviewed}
+ data-deliver-need-cut={state.needCut.length}
+ className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2"
+ >
+ <div className="mb-1.5 flex flex-wrap items-baseline gap-3">
+ <h2 className="micro">deliver</h2>
+ <span className="num text-[11px] text-[var(--color-dim)]">
+ {review.confirmed} confirmed · {review.incorrect} incorrect · {review.unreviewed} not yet
+ judged · {state.cut} of {review.confirmed} cut
+ </span>
+ </div>
+
+ {/* --- by section ------------------------------------------------- */}
+ <div className="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">section</th>
+ <th className="px-1.5 py-0.5">clips</th>
+ <th className="px-1.5 py-0.5">confirmed</th>
+ <th className="px-1.5 py-0.5">incorrect</th>
+ <th className="px-1.5 py-0.5">unjudged</th>
+ <th className="px-1.5 py-0.5">cut</th>
+ </tr>
+ </thead>
+ <tbody>
+ {state.sections.map((s) => (
+ <tr
+ key={s.letter}
+ data-section={s.letter}
+ data-section-confirmed={s.confirmed}
+ data-section-incorrect={s.incorrect}
+ data-section-unreviewed={s.unreviewed}
+ className="border-t border-[var(--color-line)] align-top"
+ >
+ <td className="px-1.5 py-1">
+ <span className="font-mono text-[var(--color-sel)]">{s.letter}</span>{" "}
+ <span className="text-[var(--color-dim)]">{s.heading ?? "—"}</span>
+ </td>
+ <td className="num px-1.5 py-1">{s.total}</td>
+ <td className="num px-1.5 py-1 text-[var(--color-good)]">{s.confirmed}</td>
+ <td className="num px-1.5 py-1">
+ {s.incorrect ? (
+ <span className="text-[var(--color-bad)]">{s.incorrect}</span>
+ ) : (
+ <span className="text-[var(--color-dim)]">—</span>
+ )}
+ </td>
+ <td className="num px-1.5 py-1 text-[var(--color-dim)]">{s.unreviewed || "—"}</td>
+ <td className="num px-1.5 py-1 text-[var(--color-dim)]">
+ {s.cut} / {s.confirmed}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+
+ {/* --- the actions ------------------------------------------------ */}
+ <div className="mt-2">
+ <DeliverActions
+ project={project.id}
+ needCut={state.needCut.length}
+ notFetched={state.notFetched.length}
+ candidates={state.candidates.length}
+ nextName={state.nextName}
+ unreviewed={review.unreviewed}
+ hasApply={state.hasApplyScript}
+ hasBuild={state.hasBuildScript}
+ variants={state.variants.map((v) => ({ module: v.module, out: v.out }))}
+ />
+ </div>
+
+ {/* --- confirmed, but no file yet ---------------------------------- */}
+ {state.needCut.length > 0 && (
+ <p data-need-cut="" className="mt-2 text-[11px] text-[var(--color-dim)]">
+ <span className="micro">no file yet — </span>
+ {state.needCut.map((c, i) => (
+ <span key={c.id}>
+ {i > 0 && " "}
+ <Link
+ href={`/browse/${project.id}/clip/${c.id}`}
+ data-need-cut-id={c.id}
+ className="font-mono text-[var(--color-sel)] hover:underline"
+ >
+ {c.id}
+ </Link>
+ </span>
+ ))}
+ </p>
+ )}
+
+ {/* --- confirmed, and nothing cached to cut from -------------------- */}
+ {/*
+ A DIFFERENT PROBLEM, and it needs a different button. Nothing here
+ fetches: the clip's window is a download, which the bench already has a
+ managed path for (the editor's fetch, with its cookie policy and its
+ per-platform sleeps). So this lists them and links to the clip page
+ where that button lives.
+ */}
+ {state.notFetched.length > 0 && (
+ <p data-not-fetched="" className="mt-1 text-[11px]">
+ <span className="micro">not fetched — </span>
+ {state.notFetched.map((id, i) => (
+ <span key={id}>
+ {i > 0 && " "}
+ <Link
+ href={`/browse/${project.id}/clip/${id}`}
+ data-not-fetched-id={id}
+ className="font-mono text-[var(--color-dirty)] hover:underline"
+ >
+ {id}
+ </Link>
+ </span>
+ ))}
+ <span className="ml-1 text-[var(--color-dim)]">
+ — open one and fetch its window through the editor, then cut.
+ </span>
+ </p>
+ )}
+
+ {/* --- prose that still cites a clip the walk threw out -------------- */}
+ {state.incorrect.length > 0 && (
+ <div data-incorrect-citations="" className="mt-2 space-y-1.5">
+ <h3 className="micro">
+ prose citing an incorrect clip — {state.incorrect.length}
+ </h3>
+ {state.incorrect.map((c) => (
+ <div key={c.id} data-incorrect={c.id} className="text-[11px] leading-snug">
+ <Link
+ href={`/browse/${project.id}/clip/${c.id}`}
+ className="font-mono text-[var(--color-sel)] hover:underline"
+ >
+ {c.id}
+ </Link>{" "}
+ <span className="text-[var(--color-dim)]">{c.correction}</span>
+ {c.hits.length === 0 ? (
+ <div className="text-[var(--color-dim)]">(no reference found in the prose)</div>
+ ) : (
+ <ul className="mt-0.5 space-y-0.5">
+ {c.hits.map((h) => (
+ <li key={`${h.file}:${h.line}`} className="font-mono text-[10px] text-[var(--color-dim)]">
+ <span className="text-[var(--color-text)]">
+ {h.file}:{h.line}
+ </span>{" "}
+ {h.text}
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ ))}
+ <p className="text-[11px] text-[var(--color-dim)]">
+ Nothing here rewrites prose: what a wrong clip does to an argument is a judgement about
+ the argument. Fix the lines, then rebuild.
+ </p>
+ </div>
+ )}
+
+ {/* --- batches already sent ---------------------------------------- */}
+ <p className="mt-2 text-[11px] text-[var(--color-dim)]">
+ {state.batches.length ? (
+ <>
+ <span className="micro">batches — </span>
+ {state.batches.map((b, i) => (
+ <span key={b.name} data-batch={b.name}>
+ {i > 0 && " · "}
+ <code className="font-mono">{b.name}</code> ({b.count})
+ </span>
+ ))}
+ {". "}
+ </>
+ ) : (
+ "no batch has been packaged yet. "
+ )}
+ The next one ships {state.candidates.length} clip
+ {state.candidates.length === 1 ? "" : "s"}: every confirmed clip with a file, minus the{" "}
+ {state.excluded.shared.length} already shared and the {state.excluded.incorrect.length}{" "}
+ ruled incorrect.
+ </p>
+
+ {state.variants.length > 0 && (
+ <p className="mt-1 text-[11px] text-[var(--color-dim)]">
+ <span className="micro">reports — </span>
+ {state.variants.map((v, i) => (
+ <span key={v.module}>
+ {i > 0 && " · "}
+ <code className="font-mono">{v.file}</code> →{" "}
+ <span className={badgeVariants({ variant: "info", size: "sm" })}>{v.out}</span>
+ </span>
+ ))}
+ </p>
+ )}
+ </section>
+ );
+}
diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx
@@ -17,6 +17,7 @@ import {
import { EXPORT_FORMATS, exportableVariants } from "@/lib/report/export.mjs";
import { diffManifests, formatChange } from "@/lib/report/manifest-diff.mjs";
import { listSnapshots, readSnapshot } from "@/lib/report/snapshots.mjs";
+import DeliverSection from "./DeliverSection";
import FetchUnfetchedButton from "./FetchUnfetchedButton";
import ReportBuildChain from "./ReportBuildChain";
import SnapshotButton from "./SnapshotButton";
@@ -401,6 +402,12 @@ export default async function ReportProject({
entries={entries.map((e) => ({ id: e.id, kind: e.kind }))}
/>
+ {/* --- delivering it --------------------------------------------- */}
+ {/* After the build chain, because that is the order the work happens
+ in: the video is one deliverable and the written report with its
+ own clip files is the other, and both wait on the same walk. */}
+ <DeliverSection project={project} manifest={m} entries={entries} />
+
{/* --- the timeline --------------------------------------------- */}
<section>
<h2 className="micro mb-1.5">
diff --git a/umtool/lib/report/deliver.mjs b/umtool/lib/report/deliver.mjs
@@ -0,0 +1,396 @@
+// DELIVERY: what a walked report owes the people who will read it.
+//
+// The bench answers one question per clip. What happens after the last one is
+// a second job entirely, and until now it lived as five shell scripts and a
+// python file in ONE project's directory (~/reports/elfpire-eva): cut the
+// confirmed clips, fold the rulings back into the prose, rebuild every report
+// variant, and package a batch of mp4s for whoever is writing the piece.
+//
+// None of that is project-specific except the prose. So it is here, as a
+// surface: the counts by section, the clips still missing a file, the batch,
+// and the two child processes (apply-manifest.py, build.py) that are the
+// project's own and are RUN rather than reimplemented.
+//
+// Plain ESM with no Next imports, because the batch builder is also spawned as
+// a script by lib/jobs.ts -- one implementation, whether a button or a terminal
+// asked for it.
+import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
+import { execFile } from "node:child_process";
+import path from "node:path";
+import { promisify } from "node:util";
+import { FFMPEG_BIN } from "umtool-report-to-video/build-video";
+import { citeUrlFor, clipVerdict, clipsOf, readManifest } from "../projects/report.mjs";
+import { SHARE_PROFILES } from "./encode.mjs";
+import { CLIPS_DIR } from "./cut.mjs";
+
+const execFileP = promisify(execFile);
+
+/** `share-<name>/` is the batch directory, and the prefix is how they are found. */
+export const SHARE_PREFIX = "share-";
+
+/**
+ * The SECTION a clip belongs to, read off its own id.
+ *
+ * Ids in a sectioned report are letter-prefixed by section -- a01…a08, b01…b06
+ * -- and that prefix is the only thing that ties a clip to a section in the
+ * MANIFEST, which carries no sections at all. (clips.json carries `section`,
+ * but the manifest is the source of truth once a walk starts.) So the prefix is
+ * the grouping key, and a clip with no letter prefix lands in "?" rather than
+ * being dropped.
+ */
+export const sectionOf = (id) => (/^([A-Za-z]+)/.exec(String(id ?? "")) ?? [, "?"])[1].toUpperCase();
+
+/** A-Z by position: the first section is A, which is how the ids were assigned. */
+const letterAt = (i) => (i < 26 ? String.fromCharCode(65 + i) : `Z${i - 25}`);
+
+const slug = (s, max) =>
+ String(s ?? "")
+ .normalize("NFKD")
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, max)
+ .replace(/-+$/g, "");
+
+/**
+ * The section headings, read out of the report's own content module.
+ *
+ * A report's prose is a python file the build renders; its SECTIONS carry the
+ * headings a reader sees, in order, and the nth of them is the nth letter. That
+ * is a convention rather than a schema, so it is read defensively: no content
+ * file, or no headings in it, and a section is named by its letter alone. A
+ * folder called `C` is worse than `C-Family-law-for-a-year-then-quitting-the-`
+ * and better than a wrong name.
+ */
+export async function sectionHeadings(projectDir, moduleName = "content") {
+ const text = await readFile(path.join(projectDir, `${moduleName}.py`), "utf8").catch(() => null);
+ if (!text) return new Map();
+ const out = new Map();
+ const re = /["']heading["']\s*:\s*(["'])((?:\\.|(?!\1)[^\\])*)\1/g;
+ let m;
+ let i = 0;
+ while ((m = re.exec(text))) {
+ // The number the writer put in front of the heading is the section's
+ // position, which the letter already says.
+ out.set(letterAt(i), m[2].replace(/^\s*\d+[.)]\s*/, "").replace(/\\(.)/g, "$1"));
+ i += 1;
+ }
+ return out;
+}
+
+/** `<Letter>-<slugged heading>`, the folder a batch sorts a clip into. */
+export const sectionFolder = (letter, heading) =>
+ heading ? `${letter}-${slug(heading, 40)}` : letter;
+
+/** `<id>_<date>_<title>.mp4` — the name the first batch shipped under. */
+export const clipFileName = (clip) =>
+ [clip.id, clip.date ?? "undated", slug(clip.title ?? "untitled", 50) || "untitled"].join("_") +
+ ".mp4";
+
+/** Every id a batch directory already shipped. */
+export async function sharedIdsIn(dir) {
+ const ids = new Set();
+ // The LIST.md is the batch's own manifest, and the only one a hand-made
+ // batch is guaranteed to have. Ids are read out of the file NAMES it lists,
+ // which is the one part of its prose that cannot drift from the files.
+ const list = await readFile(path.join(dir, "LIST.md"), "utf8").catch(() => null);
+ if (list) {
+ for (const m of list.matchAll(/\b([A-Za-z]{1,3}\d{1,3})_[^\s`*]*\.mp4\b/g)) ids.add(m[1]);
+ const marked = /<!--\s*shared-ids:\s*([^>]*?)\s*-->/.exec(list);
+ if (marked) for (const id of marked[1].split(/[\s,]+/).filter(Boolean)) ids.add(id);
+ }
+ // And the files themselves, for a batch assembled before anyone wrote a list.
+ const walk = async (d) => {
+ for (const ent of await readdir(d, { withFileTypes: true }).catch(() => [])) {
+ if (ent.isDirectory()) await walk(path.join(d, ent.name));
+ else {
+ const m = /^([A-Za-z]{1,3}\d{1,3})_.*\.mp4$/.exec(ent.name);
+ if (m) ids.add(m[1]);
+ }
+ }
+ };
+ await walk(dir);
+ return ids;
+}
+
+/** The batches already in this project, newest name last. */
+export async function listBatches(projectDir) {
+ const names = (await readdir(projectDir, { withFileTypes: true }).catch(() => []))
+ .filter((e) => e.isDirectory() && e.name.startsWith(SHARE_PREFIX))
+ .map((e) => e.name)
+ .sort();
+ return Promise.all(
+ names.map(async (name) => {
+ const dir = path.join(projectDir, name);
+ const ids = [...(await sharedIdsIn(dir))].sort();
+ return {
+ name,
+ label: name.slice(SHARE_PREFIX.length),
+ dir,
+ ids,
+ hasList: !!(await readFile(path.join(dir, "LIST.md"), "utf8").catch(() => null)),
+ };
+ }),
+ );
+}
+
+/** The content modules this project can render, and the flags build.py needs. */
+export async function contentVariants(projectDir) {
+ const names = (await readdir(projectDir).catch(() => []))
+ .filter((n) => /^content(_[A-Za-z0-9_]+)?\.py$/.test(n))
+ .sort();
+ return names.map((file) => {
+ const mod = file.replace(/\.py$/, "");
+ // build.py's own defaults: `content` renders to `report`, and every other
+ // module renders to `report-<suffix>` so two variants cannot overwrite each
+ // other's html.
+ const out = mod === "content" ? "report" : `report-${mod.slice("content_".length)}`;
+ return {
+ file,
+ module: mod,
+ out,
+ argv: mod === "content" ? [] : ["--content", mod, "--out", out],
+ };
+ });
+}
+
+/**
+ * The lines of prose that cite a clip the walk ruled INCORRECT.
+ *
+ * Not rewritten, and deliberately: what a wrong clip does to an argument is a
+ * judgement about the argument. apply-manifest.py says the same thing in the
+ * terminal; this says it on the page the operator is already looking at, so
+ * "which paragraphs do I have to touch" is not a second command.
+ */
+export async function incorrectCitations(projectDir, manifest, variants) {
+ const wrong = clipsOf(manifest).filter((e) => clipVerdict(e) === "incorrect");
+ if (!wrong.length) return [];
+ const files = await Promise.all(
+ variants.map(async (v) => ({
+ file: v.file,
+ lines: (await readFile(path.join(projectDir, v.file), "utf8").catch(() => "")).split("\n"),
+ })),
+ );
+ return wrong.map((e) => {
+ const id = e.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const re = new RegExp(`\\[clip:${id}\\]|["']${id}["']`);
+ const hits = [];
+ for (const f of files) {
+ f.lines.forEach((line, i) => {
+ if (re.test(line)) hits.push({ file: f.file, line: i + 1, text: line.trim().slice(0, 240) });
+ });
+ }
+ return { id, correction: String(e.correction ?? "").trim(), hits };
+ });
+}
+
+/**
+ * Everything the Deliver panel shows, computed server-side in one pass.
+ *
+ * `entries` is readClipDetail's, so `fetched` (is there a window holding this
+ * clip) is the bench's own answer rather than a second one computed here.
+ *
+ * @param {{ id: string, dir: string }} project
+ * @param {{ manifest?: any, entries?: any[] }} [opts]
+ */
+export async function deliverStateOf(project, { manifest = null, entries = null } = {}) {
+ const m = manifest ?? (await readManifest(project.dir));
+ if (!m) return null;
+ const clips = clipsOf(m);
+ const fetchedOf = new Map(
+ (entries ?? []).filter((e) => (e.kind ?? e.type) === "clip").map((e) => [e.id, !!e.fetched]),
+ );
+ const have = new Set(
+ (await readdir(path.join(project.dir, CLIPS_DIR)).catch(() => []))
+ .filter((n) => n.endsWith(".mp4"))
+ .map((n) => n.slice(0, -4)),
+ );
+
+ const headings = await sectionHeadings(project.dir);
+ const batches = await listBatches(project.dir);
+ const shared = new Set(batches.flatMap((b) => b.ids));
+ const variants = await contentVariants(project.dir);
+
+ const rows = clips.map((e) => ({
+ id: e.id,
+ section: sectionOf(e.id),
+ verdict: clipVerdict(e),
+ date: e.date ?? null,
+ title: e.title ?? null,
+ seconds: Number((Number(e.end) - Number(e.start)).toFixed(1)),
+ cut: have.has(e.id),
+ // A clip nobody has fetched cannot be cut, and saying "cut failed" about it
+ // would send somebody looking for a bug instead of pressing fetch.
+ fetched: fetchedOf.get(e.id) ?? null,
+ shared: shared.has(e.id),
+ file: clipFileName(e),
+ href: citeUrlFor(m, e),
+ quote: String(e.quote ?? "").trim(),
+ }));
+
+ const byLetter = new Map();
+ for (const r of rows) {
+ if (!byLetter.has(r.section)) byLetter.set(r.section, []);
+ byLetter.get(r.section).push(r);
+ }
+ const sections = [...byLetter.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([letter, list]) => ({
+ letter,
+ heading: headings.get(letter) ?? null,
+ folder: sectionFolder(letter, headings.get(letter)),
+ total: list.length,
+ confirmed: list.filter((r) => r.verdict === "confirmed").length,
+ incorrect: list.filter((r) => r.verdict === "incorrect").length,
+ unreviewed: list.filter((r) => r.verdict === "unreviewed").length,
+ cut: list.filter((r) => r.verdict === "confirmed" && r.cut).length,
+ ids: list.map((r) => r.id),
+ }));
+
+ const confirmed = rows.filter((r) => r.verdict === "confirmed");
+ const needCut = confirmed.filter((r) => !r.cut);
+ const candidates = confirmed.filter((r) => r.cut && !r.shared);
+
+ return {
+ project: project.id,
+ dir: project.dir,
+ sections,
+ review: {
+ total: rows.length,
+ confirmed: confirmed.length,
+ incorrect: rows.filter((r) => r.verdict === "incorrect").length,
+ unreviewed: rows.filter((r) => r.verdict === "unreviewed").length,
+ },
+ cut: confirmed.length - needCut.length,
+ // Cuttable now: a window is already on this disk.
+ needCut: needCut.filter((r) => r.fetched !== false).map((r) => ({ id: r.id, seconds: r.seconds })),
+ // And the ones that first need a download, which is a different button.
+ notFetched: needCut.filter((r) => r.fetched === false).map((r) => r.id),
+ batches: batches.map((b) => ({ name: b.name, label: b.label, count: b.ids.length, hasList: b.hasList })),
+ excluded: {
+ shared: [...shared].sort(),
+ incorrect: rows.filter((r) => r.verdict === "incorrect").map((r) => r.id),
+ },
+ candidates: candidates.map((r) => ({ id: r.id, section: r.section, file: r.file })),
+ nextName: `batch-${new Date().toISOString().slice(0, 10)}`,
+ variants,
+ incorrect: await incorrectCitations(project.dir, m, variants),
+ hasApplyScript: await exists(path.join(project.dir, "apply-manifest.py")),
+ hasBuildScript: await exists(path.join(project.dir, "build.py")),
+ };
+}
+
+const exists = (p) => readFile(p).then(() => true, () => false);
+
+/** The batch's own manifest, in the shape the first one shipped. */
+export function renderListMd(project, name, sections, { excluded }) {
+ const total = sections.reduce((n, s) => n + s.rows.length, 0);
+ const lines = [
+ `# ${project.id} — share batch \`${name}\``,
+ "",
+ `${total} clip${total === 1 ? "" : "s"}: every clip the umtool bench CONFIRMED, minus ` +
+ `${excluded.shared.length} already shared${excluded.shared.length ? ` (${excluded.shared.join(" ")})` : ""}` +
+ ` and ${excluded.incorrect.length} ruled incorrect` +
+ `${excluded.incorrect.length ? ` (${excluded.incorrect.join(" ")})` : ""}.`,
+ "",
+ "Folders: `orig/` (the cut as fetched), " +
+ Object.entries(SHARE_PROFILES).map(([k, p]) => `\`${k}/\` (${p.label})`).join(", ") +
+ ". Files are `<clipId>_<date>_<title>.mp4`.",
+ "",
+ // Machine-readable, so the NEXT batch's exclusions are a read rather than a
+ // parse of the prose above.
+ `<!-- shared-ids: ${sections.flatMap((s) => s.rows.map((r) => r.id)).join(" ")} -->`,
+ "",
+ ];
+ for (const s of sections) {
+ lines.push(`## ${s.heading ?? `Section ${s.letter}`} (\`${s.folder}/\`)`, "");
+ for (const r of s.rows) {
+ lines.push(
+ `- **${r.file}** — ${r.date ?? "undated"} · ${Math.round(r.seconds)}s · ` +
+ `[${r.title ?? r.id}](${r.href})`,
+ );
+ if (r.quote) lines.push(` "${r.quote}"`);
+ }
+ lines.push("");
+ }
+ return lines.join("\n");
+}
+
+/**
+ * Build `share-<name>/{orig,std,small}/<Section>/<file>.mp4` + LIST.md.
+ *
+ * Every encode is SKIPPED when its output is already there, like reencode.py's
+ * own cache: a batch interrupted at clip 40 of 53 resumes rather than restarts.
+ * Progress is one line per file so lib/jobs.ts's log reads as work.
+ *
+ * @param {{ id: string, dir: string }} project
+ * @param {string} name
+ */
+export async function buildShareBatch(project, name, { log = console.log } = {}) {
+ const state = await deliverStateOf(project);
+ if (!state) throw new Error("no manifest");
+ const m = await readManifest(project.dir);
+ const clips = new Map(clipsOf(m).map((e) => [e.id, e]));
+ const headings = await sectionHeadings(project.dir);
+ const chosen = state.candidates.map((c) => c.id);
+ if (!chosen.length) throw new Error("nothing to ship: every confirmed clip is already shared, or not cut yet");
+
+ const root = path.join(project.dir, `${SHARE_PREFIX}${name}`);
+ const bySection = new Map();
+ for (const id of chosen) {
+ const e = clips.get(id);
+ const letter = sectionOf(id);
+ if (!bySection.has(letter)) bySection.set(letter, []);
+ bySection.get(letter).push({
+ id,
+ file: clipFileName(e),
+ date: e.date ?? null,
+ title: e.title ?? null,
+ seconds: Number(e.end) - Number(e.start),
+ href: citeUrlFor(m, e),
+ quote: String(e.quote ?? "").trim(),
+ });
+ }
+ const sections = [...bySection.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([letter, rows]) => ({
+ letter,
+ heading: headings.get(letter) ?? null,
+ folder: sectionFolder(letter, headings.get(letter)),
+ rows,
+ }));
+
+ log(`BATCH-START ${chosen.length} clips into ${path.basename(root)}`);
+ for (const s of sections) {
+ for (const r of s.rows) {
+ const src = path.join(project.dir, CLIPS_DIR, `${r.id}.mp4`);
+ const orig = path.join(root, "orig", s.folder, r.file);
+ await mkdir(path.dirname(orig), { recursive: true });
+ if (await exists(orig)) log(`ORIG-CACHED ${r.id}`);
+ else {
+ await copyFile(src, orig);
+ log(`ORIG-OK ${r.id}`);
+ }
+ for (const [tag, profile] of Object.entries(SHARE_PROFILES)) {
+ const out = path.join(root, tag, s.folder, r.file);
+ await mkdir(path.dirname(out), { recursive: true });
+ if (await exists(out)) {
+ log(`${tag.toUpperCase()}-CACHED ${r.id}`);
+ continue;
+ }
+ await execFileP(
+ FFMPEG_BIN,
+ ["-nostdin", "-v", "error", "-y", "-i", orig, ...profile.args, out],
+ { maxBuffer: 1 << 24, timeout: 10 * 60_000 },
+ );
+ log(`${tag.toUpperCase()}-OK ${r.id}`);
+ }
+ }
+ }
+ await writeFile(
+ path.join(root, "LIST.md"),
+ renderListMd(project, name, sections, { excluded: state.excluded }),
+ "utf8",
+ );
+ log(`BATCH-DONE ${chosen.length} clips · ${path.basename(root)}/LIST.md`);
+ return { root, count: chosen.length, sections: sections.length };
+}
diff --git a/umtool/lib/report/driver.mjs b/umtool/lib/report/driver.mjs
@@ -263,3 +263,114 @@ export const localFetch = () => process.env.UMTOOL_LOCAL_FETCH === "1";
export function checkSourcesSteps(projects, env = {}) {
return projects.map((p) => availabilityStep(p, env));
}
+
+// ---------------------------------------------------------------------------
+// DELIVERY: the steps that come after the walk.
+//
+// Same contract as every other chain here: the client sends a project and, at
+// most, a name. Never a path, never an argv. What is different is that two of
+// these run the PROJECT'S OWN python -- apply-manifest.py and build.py, which
+// live beside the prose they rewrite and differ per report. They are RUN, not
+// reimplemented: what folding a ruling back into an argument means is a
+// decision the report's author already wrote down.
+// ---------------------------------------------------------------------------
+
+/** This package's own root. bin/ lives here, and so does report-to-video/. */
+export const UMTOOL_DIR = path.resolve(process.cwd());
+
+const tool = (name) => path.join(UMTOOL_DIR, "bin", name);
+
+/** The interpreter a project's own scripts are run with. */
+export const PYTHON = process.env.PYTHON_BIN ?? "python3";
+
+/**
+ * Cut every named clip out of the cache: ONE STEP PER CLIP.
+ *
+ * Which is what makes the panel's `k of n` and its Stop real rather than
+ * decorative -- jobs.ts runs steps strictly in order, reports the index, and
+ * cancels by killing the running step's process group. A single step looping
+ * over the ids would have had none of that, and a loop of POSTs in the browser
+ * would have had to fight the one-job-at-a-time rule for every clip.
+ *
+ * @param {{ id: string, dir: string }} project
+ * @param {string[]} clipIds
+ * @returns {import("../trim").Step[]}
+ */
+export function cutSteps(project, clipIds) {
+ return clipIds.map((id) => ({
+ cwd: UMTOOL_DIR,
+ env: {},
+ label: `cut ${id} from the cached window`,
+ argv: ["node", tool("cut-from-cache.mjs"), "--project", project.id, "--clip", id],
+ timeoutMs: 10 * 60_000,
+ }));
+}
+
+/**
+ * Package a share batch. One step, because its own log is per file.
+ * @param {{ id: string }} project
+ * @param {string} name
+ */
+export function shareBatchSteps(project, name) {
+ return [
+ {
+ cwd: UMTOOL_DIR,
+ env: {},
+ label: `package share-${name}`,
+ argv: ["node", tool("share-batch.mjs"), "--project", project.id, "--name", name],
+ // Two encodes per clip over a batch that can be fifty of them.
+ timeoutMs: 60 * 60_000,
+ },
+ ];
+}
+
+/**
+ * Fold the bench's rulings back into the report's sources.
+ *
+ * Step 1 is the project's own apply-manifest.py: it syncs clips.json from the
+ * manifest, deletes the mp4 of every clip whose window MOVED (so the cut list
+ * refills and those clips are re-cut), and prints the prose lines that cite
+ * each clip ruled incorrect. Step 2 prints the corrections as markdown, which
+ * is the form the next sweep's prompt wants.
+ *
+ * Nothing rewrites prose. That is the point.
+ */
+export function applyRulingsSteps(project) {
+ return [
+ {
+ cwd: project.dir,
+ env: {},
+ label: "apply-manifest.py — sync clips.json, drop the mp4s of moved windows",
+ argv: [PYTHON, path.join(project.dir, "apply-manifest.py")],
+ timeoutMs: 10 * 60_000,
+ },
+ {
+ cwd: UMTOOL_DIR,
+ env: {},
+ label: `umtool corrections ${project.id}`,
+ argv: ["node", tool("umtool.mjs"), "corrections", project.id],
+ timeoutMs: 5 * 60_000,
+ },
+ ];
+}
+
+/**
+ * Re-render every report variant the project carries.
+ *
+ * One step per content module, with build.py's own flags: bare `content`
+ * renders to the default stem, and `content_<x>` renders to `report-<x>` so two
+ * variants cannot overwrite each other's html. The list comes from the
+ * directory (contentVariants), never from the client.
+ *
+ * @param {{ dir: string }} project
+ * @param {{ module: string, out: string, argv: string[] }[]} variants
+ */
+export function rebuildReportSteps(project, variants) {
+ return variants.map((v) => ({
+ cwd: project.dir,
+ env: {},
+ label: `build.py → ${v.out}.{html,bbcode,md}`,
+ argv: [PYTHON, path.join(project.dir, "build.py"), ...v.argv],
+ timeoutMs: 15 * 60_000,
+ }));
+}