commit 6ccf2d78fb2d05c46893f6dc9703cf29bcb4cda8
parent f653182813db446dfb88c882a734651e4dd507f9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 23:18:49 -0400
umtool: Move deliverables on the bench; deliverables.spec; docs and changelog
The deliver panel says where the deliverables are (data-deliverables =
local | media | invalid, one data-deliverable per clips/ or share-* with its
state) and gains "Move deliverables to media|local" (data-action
deliver-move, data-move-to): a job (driver.mjs moveDeliverablesSteps runs
`umtool storage deliverables <project> --to …`), so the one-job-at-a-time
rule keeps every cut and batch out while it moves. Disabled, with the reason
in data-move-reason, while a job of the project runs, while a deliverable
link dangles, and toward media when the app has no UMTOOL_MEDIA_DIR. The
route's move action refuses the same; cut and share refuse (409) with
deliverablesProblems' sentences, and the panel disables them with the
reason in data-deliver-blocked. A batch name shaped like a move's leftover
is refused.
e2e: deliverables-fixture (two cuttable clips, one cut and shipped in
share-first) and deliverables.spec.ts (5): the CLI move, dry run first,
again -> already, check silent; the app cuts through the clips/ link with
the move disabled while it runs; the app refuses a new batch it could only
make on the wrong drive, the CLI makes share-second as a link and reads
share-first through its own; unplugged -> check blocks on all three links,
the panel and the route refuse, nothing made, the root not recreated; the
bench moves everything home and then offers no move to media.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
9 files changed, 492 insertions(+), 8 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -2,6 +2,7 @@
## [Unreleased]
- **umtool can keep each report's render folder on a media drive.** With `UMTOOL_MEDIA_DIR` set, in umtool's environment (restart umtool after setting it), to a directory inside that drive, a report project's `out/` (its fetched windows, segments and finished video) is a link to the same path under that directory: a project's first build makes it there, and `umtool storage move-out <project>` (or `--all`) moves an existing one, copying it, checking the copy and only then leaving the link; `--dry-run` says how much would move, and `umtool storage move-back` brings one home. The manifest, its revisions, notes and sources stay where they are, and nothing in umtool reads a project differently. When the drive is not mounted, a build or source check refuses and says so instead of starting a new folder on the main disk; umtool never creates the media directory itself. `umtool storage` lists where each project's `out/` is. With `UMTOOL_MEDIA_DIR` unset nothing changes.
+- **umtool can move a report's cut clips and share batches to the media drive, one project at a time.** The **Move deliverables** button in a report's deliver panel, or `umtool storage deliverables <project> --to media` (`--to local` to bring them back, `--dry-run` to see what would move), moves the project's `clips/` folder and every `share-…` batch folder to the same path under `UMTOOL_MEDIA_DIR`, checks each copy, leaves a link in its place, and then records the choice in the report's `video.manifest.json` as `"storage": { "deliverables": "media" }`. From then on a cut or a new batch is made on the media drive, and everything that opens `clips/<id>.mp4` keeps working through the link. The panel shows where the deliverables are, and the button is disabled, with the reason beside it, while a cut or a batch is running, while the drive is not mounted, or (to the media drive) when `UMTOOL_MEDIA_DIR` is not set. When the drive is not mounted, a cut or a batch refuses and says so instead of starting again on the main disk, and `umtool check` reports it as blocking, for the render folder too. Nothing moves until you ask: without the switch, a report's deliverables stay in the project.
- **umtool's cache moves to `~/.cache/archilyzer/umtool`** (`$XDG_CACHE_HOME/archilyzer/umtool` when that is set, or `UMTOOL_CACHE_DIR`). It was inside the song project's data folder, so it followed that folder onto whatever drive it was on. Run `umtool index` once after updating to rebuild the project index in its new place; umtool works without it, only slower, and the rest of the cache is remade as it is needed. `umtool doctor` now also shows the reports, media and cache folders, and the old cache folder while it is still there; it can be deleted.
- **umtool's report videos keep every clip's sound on its picture.** In a crossfaded cut each clip's audio was placed by the audio's own length and its picture by the picture's, and an encoded clip's audio is routinely a few to twenty milliseconds shorter or longer than its video, so the sound drifted further ahead clip by clip: by the end of a seventeen-clip cut it was a third of a second early, and two seconds on one with title and sources cards. Each clip's sound is now padded or trimmed to exactly its picture's length before the crossfade. Every crossfaded report video changes when it is rebuilt, and is in sync; a hard-cut video was not affected.
- **umtool's report videos can wear an on-screen deck: one panel under the footage for the whole cut, with a pip timeline, a title per clip, its source and date, and its QR.** A report manifest whose `render` says `"chrome": { "engine": "hyperframes", "layout": "deck" }` scales the footage into a box above a 190 px panel (both sizes are settings) and draws, over the whole cut, one unlabelled pip per clip on a track that fills as the cut plays, the clip's own title from `onscreen.title`, a subtitle naming the recording and its date (the channel too when the cut spans more than one; `onscreen.subtitle` replaces it), and the clip's QR. At each clip change the marker travels to the next pip and the title, subtitle and QR hand over; over a card the panel slides away and comes back after. The citation header, the corner QR and the section footer are not drawn on such a cut, and chapters take the clip's on-screen title. Every setting (sizes, spacing, date format, what the subtitle names, whether cards keep the panel, the motion's timings) is in `render.chrome.deck` and checked when it is saved; an unknown or out-of-range one is refused with a sentence saying why. The panel is rendered once per cut by HyperFrames (pinned to 0.8.24; `HYPERFRAMES_PKG` or `HYPERFRAMES_BIN` override it) and reused until its text or settings change. `build-video.mjs --chrome-only` redraws it over the built segments without rebuilding or fetching anything, `--no-chrome` builds the framed cut without it, and `--chrome-preview <at> <dur>` renders a short window. In umtool, the report page has an **On-screen** section — a switch, the settings, a table of every entry's title and subtitle with the automatic subtitle as its placeholder and a character counter, a live preview with a scrubber, a true still, **Re-render on-screen** and the built video — and the clip bench has on-screen title and subtitle fields with the panel previewed over the clip. The deck changes nothing, byte for byte, in a cut whose manifest has no `render.chrome`.
diff --git a/umtool/app/api/report/deliver/route.ts b/umtool/app/api/report/deliver/route.ts
@@ -2,10 +2,12 @@ import { cancelJob, getJob, jobView, runningJob, startJob } from "@/lib/jobs";
import {
applyRulingsSteps,
cutSteps,
+ moveDeliverablesSteps,
rebuildReportSteps,
shareBatchSteps,
} from "@/lib/report/driver.mjs";
-import { deliverStateOf } from "@/lib/report/deliver.mjs";
+import { SHARE_PREFIX, deliverStateOf } from "@/lib/report/deliver.mjs";
+import { deliverablesProblems, isDeliverableName } from "@/lib/report/storage.mjs";
import { readClipDetail } from "@/lib/projects/report.mjs";
import { projectRef } from "@/lib/projects";
import type { Step } from "@/lib/trim";
@@ -19,6 +21,9 @@ export const dynamic = "force-dynamic";
// 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
+// move the deliverables (clips/, every share-*) to the media root or
+// back, then the manifest's storage.deliverables (release 17) --
+// a job like the others, so nothing cuts or shares while it moves
//
// 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
@@ -31,7 +36,7 @@ export const dynamic = "force-dynamic";
// 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;
+const ACTIONS = ["cut", "share", "apply", "rebuild", "move"] as const;
type Action = (typeof ACTIONS)[number];
export async function GET(request: Request) {
@@ -96,6 +101,11 @@ export async function POST(request: Request) {
let kind = "";
if (action === "cut") {
+ // A clips/ on a drive that is not there reads as "nothing cut", and the
+ // list below would be every confirmed clip: refuse with the reason instead.
+ if (state.storage.cutBlocked.length) {
+ return Response.json({ error: state.storage.cutBlocked.join("; "), ok: false }, { status: 409 });
+ }
// 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);
@@ -109,12 +119,16 @@ export async function POST(request: Request) {
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)) {
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || !isDeliverableName(`${SHARE_PREFIX}${name}`)) {
return Response.json(
- { error: "a batch name is letters, digits, dot, dash and underscore" },
+ { error: "a batch name is letters, digits, dot, dash and underscore, not ending .incoming or .moved-…" },
{ status: 400 },
);
}
+ const blocked = deliverablesProblems(state.storage, `${SHARE_PREFIX}${name}`);
+ if (blocked.length) {
+ return Response.json({ error: blocked.join("; "), ok: false }, { status: 409 });
+ }
if (!state.candidates.length) {
return Response.json(
{ error: "nothing to ship: every confirmed clip is already shared, or not cut yet" },
@@ -150,6 +164,19 @@ export async function POST(request: Request) {
}
steps = applyRulingsSteps(project);
kind = `apply rulings ${project.id}`;
+ } else if (action === "move") {
+ const to = String(body.to ?? "");
+ if (to !== "media" && to !== "local") {
+ return Response.json({ error: "move needs `to`: media or local" }, { status: 400 });
+ }
+ if (to === "media" && !state.storage.tiered) {
+ return Response.json(
+ { error: "UMTOOL_MEDIA_DIR is not set in umtool's environment — there is no media root to move to" },
+ { status: 400 },
+ );
+ }
+ steps = moveDeliverablesSteps(project, to);
+ kind = `move deliverables ${project.id} to ${to}`;
} else {
if (!state.hasBuildScript || !state.variants.length) {
return Response.json(
diff --git a/umtool/components/projects/DeliverActions.tsx b/umtool/components/projects/DeliverActions.tsx
@@ -37,6 +37,17 @@ type JobView = {
type Variant = { module: string; out: string };
+/** Where the deliverables are, and what would refuse a cut, a batch or a move (release 17). */
+type StorageView = {
+ mode: "local" | "media" | null;
+ /** UMTOOL_MEDIA_DIR is set in the app's environment. */
+ tiered: boolean;
+ /** Sentences that stop a move: a link whose drive is not there. */
+ blocked: string[];
+ cutBlocked: string[];
+ shareBlocked: string[];
+};
+
export default function DeliverActions({
project,
needCut,
@@ -47,6 +58,7 @@ export default function DeliverActions({
hasApply,
hasBuild,
variants,
+ storage,
}: {
project: string;
/** Confirmed, no mp4, and a window on this disk that holds it. */
@@ -60,6 +72,7 @@ export default function DeliverActions({
hasApply: boolean;
hasBuild: boolean;
variants: Variant[];
+ storage: StorageView;
}) {
const [job, setJob] = useState<JobView | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -132,13 +145,27 @@ export default function DeliverActions({
const n = job?.steps.length ?? 0;
const k = Math.min((job?.stepIndex ?? 0) + 1, n);
+ // MOVE DELIVERABLES: the switch, as a job (release 17). Its direction is the
+ // other side of where they are; the reason it cannot run is said beside it
+ // rather than left to a 409 -- above all while a cut or a batch is writing
+ // into the very directories it would move.
+ const moveTo = storage.mode === "media" ? "local" : "media";
+ const moveReason = running
+ ? `${job!.kind} is running — move when it has finished`
+ : storage.blocked.length
+ ? storage.blocked.join("; ")
+ : moveTo === "media" && !storage.tiered
+ ? "UMTOOL_MEDIA_DIR is not set in umtool's environment — there is no media root to move to"
+ : null;
+
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}
+ disabled={busy || running || !needCut || storage.cutBlocked.length > 0}
+ title={storage.cutBlocked.join("; ") || undefined}
className={buttonVariants({ size: "sm" })}
onClick={() => void post("cut")}
>
@@ -156,7 +183,8 @@ export default function DeliverActions({
<button
type="button"
data-action="deliver-share"
- disabled={busy || running || !candidates}
+ disabled={busy || running || !candidates || storage.shareBlocked.length > 0}
+ title={storage.shareBlocked.join("; ") || undefined}
className={buttonVariants({ variant: "ghost", size: "sm" })}
onClick={() => void post("share", { name })}
>
@@ -201,6 +229,18 @@ export default function DeliverActions({
</button>
)}
+ <button
+ type="button"
+ data-action="deliver-move"
+ data-move-to={moveTo}
+ disabled={busy || moveReason !== null}
+ title={moveReason ?? undefined}
+ className={buttonVariants({ variant: "ghost", size: "sm" })}
+ onClick={() => void post("move", { to: moveTo })}
+ >
+ Move deliverables to {moveTo}
+ </button>
+
{running && (
<button
type="button"
@@ -215,6 +255,18 @@ export default function DeliverActions({
)}
</div>
+ {moveReason && (
+ <p data-move-reason="" className="text-[11px] text-[var(--color-dim)]">
+ Move deliverables: {moveReason}
+ </p>
+ )}
+
+ {(storage.cutBlocked.length > 0 || storage.shareBlocked.length > 0) && (
+ <p data-deliver-blocked="" className="text-[11px] text-[var(--color-bad)]">
+ {[...new Set([...storage.cutBlocked, ...storage.shareBlocked])].join("; ")}
+ </p>
+ )}
+
{notFetched > 0 && (
<p className="text-[11px] text-[var(--color-dim)]">
{notFetched} confirmed clip{notFetched === 1 ? " has" : "s have"} nothing cached to cut
diff --git a/umtool/components/projects/DeliverSection.tsx b/umtool/components/projects/DeliverSection.tsx
@@ -44,6 +44,19 @@ type State = {
incorrect: { id: string; correction: string; hits: { file: string; line: number; text: string }[] }[];
hasApplyScript: boolean;
hasBuildScript: boolean;
+ storage: Storage;
+};
+
+/** Where the deliverables live (lib/report/storage.mjs deliverablesState, release 17). */
+type Storage = {
+ mode: "local" | "media" | null;
+ value: unknown;
+ error?: string;
+ tiered: boolean;
+ mediaRoot: string | null;
+ dirs: { name: string; state: string; target?: string; leftovers: string[] }[];
+ cutBlocked: string[];
+ shareBlocked: string[];
};
export default async function DeliverSection({
@@ -137,9 +150,68 @@ export default async function DeliverSection({
hasApply={state.hasApplyScript}
hasBuild={state.hasBuildScript}
variants={state.variants.map((v) => ({ module: v.module, out: v.out }))}
+ storage={{
+ mode: state.storage.mode,
+ tiered: state.storage.tiered,
+ // What would stop a move: a link whose drive is not there, or a cut
+ // move's leftovers (a move refuses over both, and says so).
+ blocked: state.storage.dirs.flatMap((d) =>
+ d.state === "dangling"
+ ? [`${d.name}/ is a link to ${d.target}, which is not there — is the media drive mounted?`]
+ : [],
+ ),
+ cutBlocked: state.storage.cutBlocked,
+ shareBlocked: state.storage.shareBlocked,
+ }}
/>
</div>
+ {/* --- where the deliverables live (release 17) --------------------- */}
+ {/*
+ clips/ and every share-* live in the project, or on the media root
+ behind a link, as the manifest's storage.deliverables says. A reader
+ opens them by path either way; this line is the one place that says
+ which, and what each directory is right now.
+ */}
+ <p
+ data-deliverables={state.storage.mode ?? "invalid"}
+ className="mt-2 text-[11px] text-[var(--color-dim)]"
+ >
+ <span className="micro">deliverables — </span>
+ {state.storage.mode === "media"
+ ? "on the media root"
+ : state.storage.mode === "local"
+ ? "in the project"
+ : `storage.deliverables is not "local" or "media"`}
+ {state.storage.mode === "media" && state.storage.mediaRoot && (
+ <>
+ {" "}
+ (<code className="font-mono">{state.storage.mediaRoot}</code>)
+ </>
+ )}
+ {state.storage.dirs.map((d) => (
+ <span key={d.name} data-deliverable={d.name} data-deliverable-state={d.state}>
+ {" · "}
+ <code className="font-mono">{d.name}/</code>{" "}
+ <span
+ className={
+ d.state === "dangling" || d.leftovers.length ? "text-[var(--color-bad)]" : undefined
+ }
+ >
+ {d.state === "dangling"
+ ? "link, NOT THERE"
+ : d.state === "dir"
+ ? "here"
+ : d.state === "absent"
+ ? "moving"
+ : d.state}
+ {d.leftovers.length ? ` (a cut move left ${d.leftovers.join(", ")})` : ""}
+ </span>
+ </span>
+ ))}
+ {state.storage.dirs.length === 0 && " · nothing cut or shared yet"}
+ </p>
+
{/* --- confirmed, but no file yet ---------------------------------- */}
{state.needCut.length > 0 && (
<p data-need-cut="" className="mt-2 text-[11px] text-[var(--color-dim)]">
diff --git a/umtool/docs/cli.md b/umtool/docs/cli.md
@@ -18,7 +18,7 @@ decisions inbox cannot disagree about what is wrong with one.
|---|---|
| `ls [--kind --template --state --open --blocking --q --sort --json]` | the index, as text or JSON |
| `show <project> [--json]` | one project: summary, the cut, per-clip status, decisions |
-| `check [<project>] [--json]` | **exit 1 on anything blocking** |
+| `check [<project>] [--json]` | **exit 1 on anything blocking** — including an `out/`, `clips/` or `share-*/` link whose drive is not there (`storage-unreachable`) |
| `decisions [--json]` | the inbox |
| `folders [--json]` · `kinds [--json]` | the tree, the registry |
| `window <project> <clip> [--start S] [--end E] [--lock] [--lock-end] [--no-lock-end] [--note …]` | edit a window |
@@ -26,8 +26,9 @@ decisions inbox cannot disagree about what is wrong with one.
| `index [--rebuild] [--prune] [--since MS] [--json]` | the cache |
| `new <slug> [--from <report.md>\|<share URL>\|<channel>/<id>] [--site-origin URL] [--seed chapters]` | scaffold |
| `doctor [--json]` | which tools are on this machine and where the roots are; **exit 1** if the report pipeline is missing one, or `UMTOOL_MEDIA_DIR` is set and not there |
-| `storage [<project>] [--json]` | where each project's `out/` lives: dir, link, DANGLING, none |
+| `storage [<project>] [--json]` | where each project's `out/` lives: dir, link, DANGLING, none — and a report's deliverables (`clips/`, `share-*/`) and its switch |
| `storage move-out\|move-back <project>\|--all [--dry-run] [--json]` | `out/` to the media root (a link left behind) and back — copied, mirrored, verified first; run when nothing is building |
+| `storage deliverables <project> --to media\|local [--dry-run] [--json]` | `clips/` and every `share-*/` to the media root and back, then `storage.deliverables` in the manifest; refused while a cut, a batch or the report's own scripts run in the project; **exit 1** on any failure (the switch is then left as it was) |
| `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 |
diff --git a/umtool/docs/folders.md b/umtool/docs/folders.md
@@ -37,6 +37,45 @@ first writer (`lib/report/storage.mjs` `ensureOutDir`) or by
- `umtool storage` lists every project's `out` (dir, link, DANGLING, none);
`umtool doctor` names the roots and exits 1 when this one is set and missing.
+### Deliverables: `clips/` and every `share-*/` (a switch per project)
+
+A report's deliverables do not follow `UMTOOL_MEDIA_DIR` on their own. They move
+per project, by a switch in `video.manifest.json`:
+
+```json
+"storage": { "deliverables": "media" }
+```
+
+`"local"` (or no `storage` key) keeps them in the project; `"media"` puts them
+under the same project-relative path on the media root, behind a link, as `out`
+is. Only `lib/report/manifest.mjs`'s `updateStorage` writes the value, and only
+`umtool storage deliverables` (or the bench's **Move deliverables** button,
+which runs it as a job) calls that — after every directory has moved:
+
+- `umtool storage deliverables <project> --to media|local [--dry-run]` moves
+ `clips/` and each `share-*/` with the same copy-mirror-verify-swap movers, then
+ sets the switch. Run again, it finds each one `already` there and rewrites
+ nothing; a cut move is finished by running it again. It refuses while a
+ pipeline process works in the project — a cut, a share batch, the report's own
+ `apply-manifest.py` or `build.py`. It cannot see the app's in-process work
+ (the bench runs the move as a job, and the one-job-at-a-time rule keeps cuts
+ and batches out), a hand-run command, or another machine.
+- A directory that does not exist yet is made where the switch says, by the
+ writer: a cut makes `clips/`, a batch its `share-<name>/`
+ (`storage.mjs` `deliverableDir`). Under `"media"` that is a link to a new
+ directory on the media root; the root itself is never created. An existing
+ directory or link is used as it is — a move is what changes where it lives.
+- Under `"media"`, a process with no `UMTOOL_MEDIA_DIR` refuses to make a new
+ one rather than make it in the project: a batch on the wrong drive is a split
+ nobody chose. Existing links keep working there.
+- A link whose drive is not there refuses every cut and batch (a batch that
+ cannot read an earlier batch would ship its clips again), and `umtool check`
+ reports it as `storage-unreachable`, blocking — for `out` too. A directory
+ that disagrees with the switch, or a cut move, is `storage-mismatch`, open.
+- References stay relative: `clips/<id>.mp4` resolves through the link.
+- Manifests, `revisions/`, the caches and the cue cache never move. Final mp4s
+ are in `out/` and travel with it.
+
## `CACHE_DIR`
`UMTOOL_CACHE_DIR`, else `$XDG_CACHE_HOME/archilyzer/umtool`, else
diff --git a/umtool/e2e/deliverables.spec.ts b/umtool/e2e/deliverables.spec.ts
@@ -0,0 +1,237 @@
+import { test, expect } from "@playwright/test";
+import { execFileSync, spawnSync } from "node:child_process";
+import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, renameSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// ---------------------------------------------------------------------------
+// A report's deliverables behind the per-project switch (release 17, slice U2).
+//
+// `storage.deliverables` in the manifest says where clips/ and every share-*/
+// live: in the project ("local", the default) or on the media root behind a
+// link ("media"). `umtool storage deliverables` moves them and then sets it;
+// a cut or a batch makes a missing directory where it says; a reader opens
+// `clips/<id>.mp4` by path either way.
+//
+// Only THIS spec's CLI is given UMTOOL_MEDIA_DIR (make-fixture's media root,
+// shared with storage.spec.ts and reset every run). The app is not -- which is
+// what this spec leans on: it cuts THROUGH a linked clips/ without knowing a
+// media root exists, refuses a NEW batch it could only make on the wrong
+// drive, and brings everything home with the bench's own button.
+//
+// The tests run in order and hand the project's state on.
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const UMTOOL = path.join(HERE, "..");
+const FIXTURE = path.join(UMTOOL, ".e2e-song");
+const MEDIA = `${FIXTURE}-media`;
+const UNPLUGGED = `${MEDIA}.unplugged`;
+const PROJECT = "reports/deliverables-fixture";
+const DIR = path.join(FIXTURE, PROJECT);
+const MIRROR = path.join(MEDIA, PROJECT);
+
+const env = {
+ ...process.env,
+ SONG_REPORTS_DIR: path.join(FIXTURE, "reports"),
+ SONG_DIR: path.join(FIXTURE, "data"),
+ CHANNELS_DIR: path.join(FIXTURE, "channels"),
+ UMTOOL_CACHE_DIR: path.join(FIXTURE, "cache"),
+ YTDLP_BIN: path.join(FIXTURE, "bin", "yt-dlp"),
+ UMTOOL_MEDIA_DIR: MEDIA,
+};
+const umtool = (args: string[]) =>
+ spawnSync("node", ["bin/umtool.mjs", ...args, "--json"], { cwd: UMTOOL, encoding: "utf8", env });
+const umtoolJson = (args: string[]) => {
+ const r = umtool(args);
+ expect(r.status, r.stderr).toBe(0);
+ return JSON.parse(r.stdout);
+};
+
+const isLink = (p: string) => lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink() === true;
+const isRealDir = (p: string) => lstatSync(p, { throwIfNoEntry: false })?.isDirectory() === true;
+const manifestStorage = () =>
+ JSON.parse(readFileSync(path.join(DIR, "video.manifest.json"), "utf8")).storage as
+ | { deliverables?: string }
+ | undefined;
+const seconds = (file: string): number =>
+ Number(
+ execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", file])
+ .toString()
+ .trim(),
+ );
+
+async function waitForJob(page: import("@playwright/test").Page, timeout = 120_000) {
+ await expect(page.locator("[data-deliver-job]")).toHaveAttribute("data-deliver-state", /done|failed/, { timeout });
+ const state = await page.locator("[data-deliver-job]").getAttribute("data-deliver-state");
+ const log = (await page.locator("[data-deliver-log]").textContent()) ?? "";
+ return { state, log };
+}
+
+test.describe.configure({ mode: "serial" });
+
+test.afterAll(() => {
+ if (existsSync(UNPLUGGED) && !existsSync(MEDIA)) renameSync(UNPLUGGED, MEDIA);
+});
+
+test("the switch moves clips/ and every share-* to the media root, then says media", () => {
+ expect(isRealDir(path.join(DIR, "clips"))).toBe(true);
+ expect(manifestStorage()).toBeUndefined();
+
+ // A dry run measures and changes nothing -- not even the switch.
+ const dry = umtoolJson(["storage", "deliverables", PROJECT, "--to", "media", "--dry-run"]);
+ expect(dry.results.map((r: { name: string; state: string }) => [r.name, r.state])).toEqual([
+ ["clips", "would-move"],
+ ["share-first", "would-move"],
+ ]);
+ expect(dry.written).toBe(false);
+ expect(isRealDir(path.join(DIR, "clips"))).toBe(true);
+ expect(manifestStorage()).toBeUndefined();
+
+ const moved = umtoolJson(["storage", "deliverables", PROJECT, "--to", "media"]);
+ expect(moved.ok).toBe(true);
+ expect(moved.written).toBe(true);
+ for (const name of ["clips", "share-first"]) {
+ expect(isLink(path.join(DIR, name)), name).toBe(true);
+ expect(readlinkSync(path.join(DIR, name))).toBe(path.join(MIRROR, name));
+ }
+ expect(existsSync(path.join(MIRROR, "clips", "d03.mp4"))).toBe(true);
+ expect(manifestStorage()).toEqual({ deliverables: "media" });
+
+ // Again: nothing to do, and the manifest is not rewritten.
+ const again = umtoolJson(["storage", "deliverables", PROJECT, "--to", "media"]);
+ expect(again.results.map((r: { state: string }) => r.state)).toEqual(["already", "already"]);
+ expect(again.written).toBe(false);
+
+ // `umtool storage` names them; `umtool check` has nothing to say.
+ const status = umtoolJson(["storage", PROJECT]);
+ expect(status.projects[0].deliverables.mode).toBe("media");
+ expect(status.projects[0].deliverables.dirs.map((d: { state: string }) => d.state)).toEqual(["link", "link"]);
+ const check = JSON.parse(umtool(["check", PROJECT]).stdout);
+ expect(check.decisions.filter((d: { kind: string }) => d.kind.startsWith("storage-"))).toEqual([]);
+});
+
+test("the app cuts through the linked clips/, and the move waits for the cut", async ({ page }) => {
+ test.setTimeout(180_000);
+ await page.goto(`/browse/${PROJECT}`);
+ await expect(page.locator("[data-deliverables]")).toHaveAttribute("data-deliverables", "media");
+ await expect(page.locator('[data-deliverable="clips"]')).toHaveAttribute("data-deliverable-state", "link");
+ const move = page.locator('[data-action="deliver-move"]');
+ await expect(move).toHaveAttribute("data-move-to", "local");
+ await expect(move).toBeEnabled();
+
+ await page.locator('[data-action="deliver-cut"]').click();
+ await expect(page.locator("[data-deliver-progress]")).toContainText(/of 2|2 steps/);
+ // While the cut writes into clips/, the move that would carry clips/ away
+ // is disabled, and says why.
+ await expect(move).toBeDisabled();
+ await expect(page.locator("[data-move-reason]")).toContainText("is running — move when it has finished");
+
+ const { state, log } = await waitForJob(page);
+ expect(state, log).toBe("done");
+ // Written through the link: the files are on the media root, clips/ is still a link.
+ expect(isLink(path.join(DIR, "clips"))).toBe(true);
+ for (const id of ["d01", "d02"]) {
+ expect(seconds(path.join(MIRROR, "clips", `${id}.mp4`))).toBeCloseTo(3.0, 1);
+ }
+ expect(readdirSync(path.join(MIRROR, "clips")).filter((n) => n.startsWith("."))).toEqual([]);
+ await page.reload();
+ await expect(page.locator("[data-deliver]")).toHaveAttribute("data-deliver-need-cut", "0");
+ await expect(move).toBeEnabled();
+});
+
+test("a new batch: refused by an app with no media root, made as a link by a CLI with one", async ({ page, request }) => {
+ test.setTimeout(180_000);
+ await page.goto(`/browse/${PROJECT}`);
+ // The app could only make share-<x>/ in the project, which is not where this
+ // project's deliverables live: it says so rather than splitting them.
+ await expect(page.locator('[data-action="deliver-share"]')).toBeDisabled();
+ await expect(page.locator("[data-deliver-blocked]")).toContainText("UMTOOL_MEDIA_DIR is not set in umtool's environment");
+ const refused = await request.post("/api/report/deliver", { data: { project: PROJECT, action: "share", name: "second" } });
+ expect(refused.status()).toBe(409);
+ expect(existsSync(path.join(DIR, "share-second"))).toBe(false);
+
+ // The batch's CLI, with the media root: share-second is made as a link, and
+ // share-first is read THROUGH its link, so d03 is not shipped again.
+ const r = spawnSync("node", ["bin/share-batch.mjs", "--project", PROJECT, "--name", "second"], {
+ cwd: UMTOOL,
+ encoding: "utf8",
+ env,
+ });
+ expect(r.status, r.stderr).toBe(0);
+ const root = path.join(DIR, "share-second");
+ expect(isLink(root)).toBe(true);
+ expect(readlinkSync(root)).toBe(path.join(MIRROR, "share-second"));
+ const list = readFileSync(path.join(MIRROR, "share-second", "LIST.md"), "utf8");
+ expect(list).toContain("d01_2025-03-01");
+ expect(list).toContain("d02_2025-03-02");
+ expect(list).not.toContain("d03_");
+ expect(list).toContain("1 already shared (d03)");
+
+ // And the panel lists it, through its link.
+ await page.reload();
+ await expect(page.locator('[data-batch="share-second"]')).toContainText("(2)");
+ await expect(page.locator('[data-deliverable="share-second"]')).toHaveAttribute("data-deliverable-state", "link");
+});
+
+test("an unplugged media root: check blocks, the panel refuses, nothing is made", async ({ page, request }) => {
+ renameSync(MEDIA, UNPLUGGED);
+ try {
+ const check = umtool(["check", PROJECT]);
+ expect(check.status).toBe(1);
+ const unreachable = JSON.parse(check.stdout)
+ .decisions.filter((d: { kind: string }) => d.kind === "storage-unreachable")
+ .map((d: { target: string; severity: string; why: string }) => [d.target, d.severity, d.why.includes("is the media drive mounted?")]);
+ expect(unreachable).toEqual([
+ ["clips", "blocking", true],
+ ["share-first", "blocking", true],
+ ["share-second", "blocking", true],
+ ]);
+
+ await page.goto(`/browse/${PROJECT}`);
+ await expect(page.locator('[data-deliverable="clips"]')).toHaveAttribute("data-deliverable-state", "dangling");
+ await expect(page.locator('[data-action="deliver-move"]')).toBeDisabled();
+ await expect(page.locator("[data-move-reason]")).toContainText("is the media drive mounted?");
+ await expect(page.locator('[data-action="deliver-cut"]')).toBeDisabled();
+ await expect(page.locator("[data-deliver-blocked]")).toContainText("is the media drive mounted?");
+ // The route says the same to a caller that is not the panel.
+ const cut = await request.post("/api/report/deliver", { data: { project: PROJECT, action: "cut" } });
+ expect(cut.status()).toBe(409);
+ expect(((await cut.json()) as { error: string }).error).toContain("is the media drive mounted?");
+
+ // Nothing was made in the links' place, and the root was not recreated.
+ expect(isLink(path.join(DIR, "clips"))).toBe(true);
+ expect(existsSync(MEDIA)).toBe(false);
+ } finally {
+ renameSync(UNPLUGGED, MEDIA);
+ }
+});
+
+test("the bench moves the deliverables home, and offers no move to media without a media root", async ({ page }) => {
+ test.setTimeout(180_000);
+ await page.goto(`/browse/${PROJECT}`);
+ const move = page.locator('[data-action="deliver-move"]');
+ await expect(move).toHaveText("Move deliverables to local");
+ await move.click();
+ const { state, log } = await waitForJob(page);
+ expect(state, log).toBe("done");
+ // The app has no media root, so it brings each copy home and leaves the
+ // media side where it is, saying so -- it cannot tell it is the project's own.
+ expect(log).toContain("left in place");
+ expect(log).toContain("storage.deliverables: media -> local");
+
+ for (const name of ["clips", "share-first", "share-second"]) {
+ expect(isRealDir(path.join(DIR, name)), name).toBe(true);
+ }
+ expect(readdirSync(path.join(DIR, "clips")).sort()).toEqual(["d01.mp4", "d02.mp4", "d03.mp4"]);
+ expect(manifestStorage()).toEqual({ deliverables: "local" });
+
+ await page.reload();
+ await expect(page.locator("[data-deliverables]")).toHaveAttribute("data-deliverables", "local");
+ await expect(move).toHaveText("Move deliverables to media");
+ await expect(move).toBeDisabled();
+ await expect(page.locator("[data-move-reason]")).toContainText("UMTOOL_MEDIA_DIR is not set in umtool's environment");
+ // Local again: a batch is the app's to make.
+ await expect(page.locator('[data-action="deliver-share"]')).toBeDisabled(); // nothing left to ship
+ await expect(page.locator("[data-deliver-blocked]")).toHaveCount(0);
+});
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -1711,6 +1711,38 @@ for (const slug of ["storage-fixture", "storage-fresh-fixture"]) {
);
}
+// deliverables.spec.ts (release 17, slice U2): a report's deliverables behind
+// the per-project switch. Its CLI moves clips/ and share-first/ to the media
+// root above; the app (no UMTOOL_MEDIA_DIR) then cuts THROUGH the clips/ link,
+// and is refused a new batch; the CLI's share batch makes share-second as a
+// link and reads share-first through its own; the app moves them back.
+// d01 d02 confirmed, cached in the window, no file yet -> the app's cut
+// d03 confirmed, has a file, and shipped in share-first -> excluded
+const DELIVERABLES = writeProject(
+ "deliverables-fixture",
+ manifest("deliverables-fixture", "The Deliverables Fixture", { siteOrigin: "https://archive.example" }, [
+ { type: "clip", id: "d01", video: "vid6", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, verdict: "confirmed", date: "2025-03-01", title: "A Fixture Stream", quote: "The first clip is confirmed." },
+ { type: "clip", id: "d02", video: "vid6", start: 6.0, end: 9.0, cite: 6, section: 0, lock: true, verdict: "confirmed", date: "2025-03-02", title: "A Fixture Stream", quote: "So is the second." },
+ { type: "clip", id: "d03", video: "vid6", start: 0.0, end: 3.0, cite: 0, section: 0, lock: true, verdict: "confirmed", date: "2025-03-03", title: "A Fixture Stream", quote: "The deliver fixture opens." },
+ ]),
+);
+mkdirSync(path.join(DELIVERABLES, "out", "clips-raw"), { recursive: true });
+copyFileSync(
+ path.join(DELIVER, "out", "clips-raw", "vid6_0.00-24.00.mp4"),
+ path.join(DELIVERABLES, "out", "clips-raw", "vid6_0.00-24.00.mp4"),
+);
+mkdirSync(path.join(DELIVERABLES, "clips"), { recursive: true });
+copyFileSync(path.join(DELIVER, "clips", "b01.mp4"), path.join(DELIVERABLES, "clips", "d03.mp4"));
+mkdirSync(path.join(DELIVERABLES, "share-first", "orig", "D"), { recursive: true });
+copyFileSync(
+ path.join(DELIVER, "clips", "b01.mp4"),
+ path.join(DELIVERABLES, "share-first", "orig", "D", "d03_2025-03-03_A-Fixture-Stream.mp4"),
+);
+writeFileSync(
+ path.join(DELIVERABLES, "share-first", "LIST.md"),
+ "# deliverables-fixture — share batch `first`\n\n- **d03_2025-03-03_A-Fixture-Stream.mp4**\n",
+);
+
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)`);
@@ -1727,6 +1759,7 @@ console.log(` SONG_DIR=${path.join(dest, "data")}`);
console.log(` SONG_REPORTS_DIR=${reports}`);
console.log(` UMTOOL_CACHE_DIR=${path.join(dest, "cache")} (removed with the fixture; never ~/.cache)`);
console.log(` storage spec media root: ${MEDIA} (UMTOOL_MEDIA_DIR on its CLI only; reset here)`);
+console.log(` deliverables-fixture: clips/ (d03) + share-first/, d01/d02 cuttable (deliverables.spec)`);
console.log(` YTDLP_BIN=${path.join(BIN, "yt-dlp")} QRENCODE_BIN=${path.join(BIN, "qrencode")} HYPERFRAMES_BIN=${path.join(BIN, "hyperframes")}`);
console.log(` CHANNELS_DIR=${CHANNELS} (testchan/vid1 punctuated, vid2 not; vid3/vid4/vid5 for the editor fetch)`);
console.log(` projects: report-fixture (4 clips, 1 mid-sentence), no-origin-fixture,`);
diff --git a/umtool/lib/report/driver.mjs b/umtool/lib/report/driver.mjs
@@ -382,6 +382,28 @@ export function shareBatchSteps(project, name) {
}
/**
+ * Move a project's deliverables (`clips/`, every `share-*` directory) to the media root
+ * or back, then set `storage.deliverables` (release 17). One step: the CLI's
+ * own log is per directory. Run as a JOB so the app's one-job-at-a-time rule
+ * keeps every cut and batch out while it moves; the CLI's process scan covers
+ * what runs outside the app.
+ * @param {{ id: string }} project
+ * @param {"media" | "local"} to
+ */
+export function moveDeliverablesSteps(project, to) {
+ return [
+ {
+ cwd: UMTOOL_DIR,
+ env: {},
+ label: `move deliverables to ${to}`,
+ argv: ["node", tool("umtool.mjs"), "storage", "deliverables", project.id, "--to", to],
+ // A copy, a mirror and a verify of what can be gigabytes of mp4.
+ 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