Archilyzer · Source

archilyzer

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

commit 8a955cde3e0169e4de307391227b6ec7c87e4059
parent bd4bbe8ec9fec7e33741450000c1190ed8e31a57
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 21 Sep 2026 02:02:30 -0400

deliver: prove Stop abandons the rest, and read a wrapped exclusion sentence

Two from review.

STOP HAD NO TEST, and it is the whole argument for one step per clip: the
cancel kills the running ffmpeg's process group and abandons the REST, and the
next press picks up the clips with no file rather than re-cutting the ones that
have one. Both halves are now asserted on disk -- a count after the stop, then
the MTIMES of the files the first job wrote, which must not move.

Made deterministic rather than racy. deliver-stop-fixture is six confirmed,
uncut clips of its own (stopping halfway through deliver-fixture's two would
leave the batch tests a state they do not expect), and bin/cut-from-cache.mjs
honours UMTOOL_CUT_DELAY_MS -- test-only, set by the fixture's server, two
seconds per step. The click waits for the first CUT-OK, so six seconds of work
provably cannot have happened when Stop lands.

And `sharedIdsIn` read the "already shared (…)" sentence line-scoped, so a
hand-wrapped list silently dropped its exclusions -- which is how that sentence
is actually written: the phrase ends one line and the parenthesis opens the
next. It now spans up to 120 characters, which reads a wrap and still will not
let "already shared" in one paragraph claim a parenthetical three paragraphs
down. Verified on a wrapped copy of the real sentence: all six ids come back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mumtool/bin/cut-from-cache.mjs | 8++++++++
Mumtool/docs/clip-bench.md | 6++++--
Mumtool/e2e/deliver.spec.ts | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/e2e/fixtures/make-fixture.mjs | 33+++++++++++++++++++++++++++++++++
Mumtool/lib/report/deliver.mjs | 14++++++++++----
Mumtool/playwright.config.ts | 4++++
6 files changed, 128 insertions(+), 7 deletions(-)

diff --git a/umtool/bin/cut-from-cache.mjs b/umtool/bin/cut-from-cache.mjs @@ -40,6 +40,14 @@ const reencode = argv.includes("--reencode") ? "never" : "auto"; +// TEST-ONLY PACING, and the only reason it exists: a cut of a cached window is +// about a second of ffmpeg, which is too fast for a spec to press Stop in the +// middle of -- and "Stop abandons the REST, and the next run resumes rather +// than re-cutting" is the whole argument for one step per clip. The e2e +// fixture's server sets this; nothing else ever should. +const delay = Number(process.env.UMTOOL_CUT_DELAY_MS ?? 0); +if (delay > 0) await new Promise((r) => setTimeout(r, delay)); + const res = await cutClipFromCache(r.project, clipId, { reencode }); if (!res.ok) { console.error(`${clipId}: ${res.error}`); diff --git a/umtool/docs/clip-bench.md b/umtool/docs/clip-bench.md @@ -360,7 +360,7 @@ that is a different list, and it lives on the **project page** as `Deliver` | | | |---|---| | **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 is the section whose argument has to change. Section HEADINGS are read out of the report's own `content.py`, nth heading to nth letter. | -| **cut N confirmed clips from cache** | one job, **one step per clip**, so `k of n` and Stop are the job runner's own (`stepIndex`, and a cancel that kills the step's process group). | +| **cut N confirmed clips from cache** | one job, **one step per clip**, so `k of n` and Stop are the job runner's own (`stepIndex`, and a cancel that kills the step's process group). Stop abandons the REST; the next press is the server's list again — "confirmed, no file" — so it resumes without re-cutting. | | **not fetched** | a confirmed clip with nothing cached that holds it. Listed, never downloaded here — the managed fetch is the editor's, on the clip page. | | **build share batch** | `share-<name>/{orig,std,small}/<Section>/<id>_<date>_<title>.mp4` + `LIST.md`. | | **apply rulings** | runs the project's own `apply-manifest.py`, then `umtool corrections`. | @@ -392,7 +392,9 @@ existing `share-*/LIST.md` already shipped and every clip ruled `incorrect`. A id counts as shipped, in order of how much they can be trusted: the file NAMES a list gives, the `<!-- shared-ids: … -->` marker this writes, and — narrowly — the phrase `already shared (…)`, taking only the tokens shaped like a clip id. -The last one is not decoration: six ElfpireEva clips went out in an earlier set +(the parenthesis has to follow the phrase within ~120 characters, so one line +wrap is fine and a paragraph away is not). The last one is not decoration: six +ElfpireEva clips went out in an earlier set under DIFFERENT ids (`em01`, `ie01` — a separate cut of the same moments), and the sentence the next batch wrote down is the only thing on disk tying the two. Without it, the next batch re-ships all six. A batch this writes phrases it the diff --git a/umtool/e2e/deliver.spec.ts b/umtool/e2e/deliver.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -24,6 +24,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = path.join(HERE, "..", ".e2e-song"); const PROJECT = "reports/deliver-fixture"; const DIR = path.join(FIXTURE, "reports", "deliver-fixture"); +const STOP_DIR = path.join(FIXTURE, "reports", "deliver-stop-fixture"); const seconds = (file: string): number => Number( @@ -224,3 +225,70 @@ test("rebuild runs build.py once per content variant present", async ({ page }) "built from content_lawyer", ); }); + + +// --------------------------------------------------------------------------- +// STOP, and what "resume" means. +// +// One step per clip is not a presentation choice: it is what makes Stop kill +// the running ffmpeg's process group and abandon the REST, and what makes the +// next press pick up the clips that have no file rather than re-cutting the +// ones that do. Both halves are asserted on DISK -- a count, and then the +// mtimes of the files the first job wrote, which must not have moved. +// +// Deterministic rather than racy: the fixture's server sets +// UMTOOL_CUT_DELAY_MS=2000 (playwright.config.ts), the job is six clips long, +// and the click waits for the first CUT-OK. Three clips of work -- six +// seconds -- therefore cannot have happened by the time Stop lands. +// --------------------------------------------------------------------------- + +const stopCuts = () => + readdirSync(path.join(STOP_DIR, "clips")) + .filter((n) => /^s\d+\.mp4$/.test(n)) + .sort(); + +test("Stop abandons the rest of the cut, and the next one resumes without re-cutting", async ({ + page, +}) => { + test.setTimeout(240_000); + await page.goto("/browse/reports/deliver-stop-fixture"); + await expect(page.locator("[data-deliver]")).toHaveAttribute("data-deliver-need-cut", "6"); + expect(stopCuts()).toHaveLength(0); + + await page.locator('[data-action="deliver-cut"]').click(); + await expect(page.locator("[data-deliver-progress]")).toContainText(/of 6/); + // WAIT FOR REAL WORK. Stopping before anything finished would prove only + // that a job can be cancelled, not that what it had already done survives. + await expect(page.locator("[data-deliver-log]")).toContainText("CUT-OK", { timeout: 90_000 }); + await page.locator('[data-action="deliver-stop"]').click(); + + const stopped = await waitForJob(page); + // A cancelled step is a failed one, and the log says who asked. + expect(stopped.state, stopped.log).toBe("failed"); + expect(stopped.log).toContain("cancel"); + + const done = stopCuts(); + expect(done.length).toBeGreaterThanOrEqual(1); + expect(done.length).toBeLessThan(6); + const stamps = new Map( + done.map((n) => [n, statSync(path.join(STOP_DIR, "clips", n)).mtimeMs]), + ); + + // THE SERVER'S LIST IS THE RESUME. It is "confirmed, no file", so the second + // job is exactly the remainder -- nothing in the browser had to remember + // where the first one stopped. + await page.reload(); + await expect(page.locator("[data-deliver]")).toHaveAttribute( + "data-deliver-need-cut", + String(6 - done.length), + ); + await page.locator('[data-action="deliver-cut"]').click(); + const second = await waitForJob(page); + expect(second.state, second.log).toBe("done"); + expect(stopCuts()).toHaveLength(6); + + for (const [name, ms] of stamps) { + expect(statSync(path.join(STOP_DIR, "clips", name)).mtimeMs, `${name} was re-cut`).toBe(ms); + expect(second.log, `${name} was re-cut`).not.toContain(`CUT-OK ${name.replace(".mp4", "")}`); + } +}); diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -1126,6 +1126,38 @@ ff([ path.join(DELIVER, "clips", "b01.mp4"), ]); +// A SECOND deliver project, for STOP. +// +// Its own, because the cut is one job over every clip that needs one: a spec +// that stopped halfway through deliver-fixture's two would leave that project +// in a state the batch tests do not expect, and a six-clip job is what makes +// "stopped after k of n" a measurement rather than a race. +// +// Six confirmed clips, none of them cut, all inside the same cached window. +// With UMTOOL_CUT_DELAY_MS set (playwright.config.ts) each step takes about +// two seconds, so the spec can see the first CUT-OK, press Stop, and know that +// at least three clips could not possibly have been reached. +const STOP = writeProject( + "deliver-stop-fixture", + manifest("deliver-stop-fixture", "The Deliver Stop Fixture", { siteOrigin: "https://archive.example" }, [ + { type: "clip", id: "s01", video: "vid6", start: 0.0, end: 2.0, cite: 0, section: 0, lock: true, verdict: "confirmed", date: "2025-02-01", title: "A Fixture Stream", quote: "The deliver fixture opens." }, + { type: "clip", id: "s02", video: "vid6", start: 2.0, end: 4.0, cite: 2, section: 0, lock: true, verdict: "confirmed", date: "2025-02-02", title: "A Fixture Stream", quote: "The first clip is confirmed." }, + { type: "clip", id: "s03", video: "vid6", start: 4.0, end: 6.0, cite: 4, section: 0, lock: true, verdict: "confirmed", date: "2025-02-03", title: "A Fixture Stream", quote: "And so is the second." }, + { type: "clip", id: "s04", video: "vid6", start: 6.0, end: 8.0, cite: 6, section: 0, lock: true, verdict: "confirmed", date: "2025-02-04", title: "A Fixture Stream", quote: "And so is the third." }, + { type: "clip", id: "s05", video: "vid6", start: 8.0, end: 10.0, cite: 8, section: 0, lock: true, verdict: "confirmed", date: "2025-02-05", title: "A Fixture Stream", quote: "Which does not start on a keyframe." }, + { type: "clip", id: "s06", video: "vid6", start: 10.0, end: 12.0, cite: 10, section: 0, lock: true, verdict: "confirmed", date: "2025-02-06", title: "A Fixture Stream", quote: "Nor does this one." }, + ]), +); +mkdirSync(path.join(STOP, "out", "clips-raw"), { recursive: true }); +copyFileSync( + path.join(DELIVER, "out", "clips-raw", "vid6_0.00-24.00.mp4"), + path.join(STOP, "out", "clips-raw", "vid6_0.00-24.00.mp4"), +); +// An EMPTY clips/, so the spec can count files without first asking whether +// the directory exists -- and so "nothing has been cut yet" is a state the +// fixture states rather than one it leaves to chance. +mkdirSync(path.join(STOP, "clips"), { recursive: true }); + // -- STUB BINARIES, so a build is offline and deterministic -------------------- // // The pipeline shells out to yt-dlp for the availability preflight and for every @@ -1409,4 +1441,5 @@ console.log(` walk-fixture (read-only: w01/w04 walkable, w02 unfetche console.log(` editor-fetch-{,many-,reuse-}fixture (nothing cached — the editor fetch's subjects),`); console.log(` longform-fixture (cue gap, legacy .bak, ffmeta), longform-edit-fixture, dash-fixture`); console.log(` deliver-fixture (writable: a01/a02 to cut, a03 unfetched, b01 shared, b02 incorrect, b03 unjudged)`); +console.log(` deliver-stop-fixture (writable: six confirmed clips to cut, for Stop and resume)`); console.log(` ${taken} candidate files copied, 2 mix tracks synthesised`); diff --git a/umtool/lib/report/deliver.mjs b/umtool/lib/report/deliver.mjs @@ -107,10 +107,16 @@ export async function sharedIdsIn(dir) { // h02)". Without this the next batch re-ships all six. // // Prose, and read as narrowly as prose can be: the phrase, then a - // parenthesis, then only the tokens that are shaped like a clip id. The - // batches this writes phrase it the same way, so a generated list round - // trips through here unchanged. - for (const m of list.matchAll(/already shared[^(\n]*\(([^)]*)\)/gi)) { + // parenthesis within the next 120 characters, then only the tokens that + // are shaped like a clip id. The batches this writes phrase it the same + // way, so a generated list round trips through here unchanged. + // + // The 120 is what lets a HAND-WRAPPED list still be read -- the phrase and + // its parenthesis routinely end up on two lines -- while stopping "already + // shared" in one paragraph from claiming a parenthetical three paragraphs + // down. Ids inside the parenthesis are still filtered by shape, so a + // wrongly-claimed one contributes nothing unless it reads like a clip id. + for (const m of list.matchAll(/already shared[^(]{0,120}\(([^)]*)\)/gi)) { for (const tok of m[1].split(/[\s,]+/)) { if (/^[A-Za-z]{1,3}\d{1,3}$/.test(tok)) ids.add(tok); } diff --git a/umtool/playwright.config.ts b/umtool/playwright.config.ts @@ -71,6 +71,10 @@ export default defineConfig({ // with. Set here rather than in a step's env: jobView() echoes a step's // env back to the browser, and this is a token. `ARCHILYZER_EDITOR_URL=http://127.0.0.1:${STUB_PORT} WORKER_TOKEN=umtool-e2e-token ` + + // Two seconds of pacing per CUT, so the deliver spec can press Stop in + // the middle of a job and prove that cancelling abandons the rest while + // the next run resumes. Read only by bin/cut-from-cache.mjs. + `UMTOOL_CUT_DELAY_MS=2000 ` + `NEXT_DIST_DIR=.next-e2e pnpm exec next dev --port ${PORT}`, port: PORT, reuseExistingServer: false,