Archilyzer · Source

archilyzer

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

commit a01e3604dca7fb7b146fb5aebdf4519a69989052
parent f79be94fbc0ff58d6203f456c318a9f2031cda2d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 21:11:53 -0400

umtool: e2e never takes the shell's media root; doctor ok = exit status; move skips a building project

The e2e app and the projects, report-longform and dashboard CLI envs pin
UMTOOL_MEDIA_DIR empty (and dashboard's doctor the fixture cache). doctor
--json: ok is the exit status's verdict, toolsOk the tools' alone. umtool
storage move-out/move-back skip a project a running pipeline script names on
its command line. The walk skips *.moved-* and *.incoming; the mix picker
follows only an `out` link; the paths.mjs comment says what READ_ROOTS
membership enables; folders.md: the media root is a directory inside the
drive, never the mountpoint; the changelog bullet says where to set it.

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

Diffstat:
Meditor/CHANGELOG.md | 2+-
Mumtool/bin/umtool.mjs | 43++++++++++++++++++++++++++++++++++++++++++-
Mumtool/docs/folders.md | 7+++++++
Mumtool/e2e/dashboard.spec.ts | 9++++++++-
Mumtool/e2e/projects.spec.ts | 2++
Mumtool/e2e/report-longform.spec.ts | 2++
Mumtool/lib/media.ts | 5++++-
Mumtool/lib/paths.mjs | 8+++++---
Mumtool/lib/projects/kinds.mjs | 9++++++++-
Mumtool/playwright.config.ts | 5+++++
10 files changed, 84 insertions(+), 8 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## [Unreleased] -- **umtool can keep each report's render folder on a media drive.** With `UMTOOL_MEDIA_DIR` set to a directory on 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 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'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/bin/umtool.mjs b/umtool/bin/umtool.mjs @@ -35,6 +35,7 @@ // umtool export <project> --format toc-bbcode|toc-markdown|description|chapters [--variant V] // umtool check-sources [<project>…] prints the re-check chain import process from "node:process"; +import { readdirSync, readFileSync } from "node:fs"; import { PROJECT_KINDS, REPORTS_ROOT, @@ -371,7 +372,9 @@ async function cmdDoctor() { const r = await probeTools(); const roots = await rootsReport(); if (json) { - out({ ...r, roots }); + // `ok` is what the exit status says (a script gates on either); the tools' + // own verdict stays readable as toolsOk. + out({ ...r, ok: r.ok && roots.ok, toolsOk: r.ok, roots }); } else { for (const t of r.tools) { const mark = t.present ? "ok " : t.required ? "MISSING" : "absent"; @@ -420,6 +423,35 @@ async function cmdDoctor() { // this cannot see them -- the verify refuses when the tree keeps changing, but // a write in the last instant before the swap would be lost with the parked copy. // --------------------------------------------------------------------------- +/** + * The pids of report-pipeline processes whose command line names this project + * (its manifest, its out/, or the directory itself). Linux /proc; elsewhere, + * none. Cheap and coarse: it sees the pipeline's scripts, not the app's + * in-process deck previews. + */ +function pipelineProcessesFor(projectDir) { + const SCRIPTS = /(build-video|check-availability|render-cards|compose-chrome|verify-build|fetch-via-editor|resolve-windows|cut-from-cache|share-batch)\.mjs/; + const pids = []; + let entries = []; + try { + entries = readdirSync("/proc").filter((n) => /^\d+$/.test(n)); + } catch { + return pids; + } + for (const pid of entries) { + if (Number(pid) === process.pid) continue; + let args; + try { + args = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0"); + } catch { + continue; + } + if (!args.some((a) => SCRIPTS.test(a))) continue; + if (args.some((a) => a === projectDir || a.startsWith(projectDir + "/"))) pids.push(Number(pid)); + } + return pids; +} + async function cmdStorage() { const sub = positional[0]; const dryRun = has("--dry-run"); @@ -451,12 +483,21 @@ async function cmdStorage() { const results = []; let failed = 0; for (const p of refs) { + // The app's jobs live in its memory, but the pipeline runs as processes: + // one whose command line names this project is building it now. + const busy = pipelineProcessesFor(p.dir); + if (busy.length) { + results.push({ id: p.id, state: "busy", pids: busy }); + if (!json) console.log(`${"busy".padEnd(12)} ${p.id} — a pipeline process is writing it (pid ${busy.join(", ")}); skipped`); + continue; + } try { const r = await move(p.dir, "out", { dryRun, log }); results.push({ id: p.id, ...r }); if (!json && r.state !== "absent") { const size = r.bytes !== undefined ? ` ${r.files} file(s), ${mb(r.bytes)}` : ""; console.log(`${r.state.padEnd(12)} ${p.id}${size}`); + if (r.mediaCopyLeft) console.log(` left in place: ${r.mediaCopyLeft} (not deleted; remove it by hand once checked)`); } } catch (e) { failed += 1; diff --git a/umtool/docs/folders.md b/umtool/docs/folders.md @@ -21,6 +21,13 @@ first writer (`lib/report/storage.mjs` `ensureOutDir`) or by - It must already exist, outside `REPORTS_ROOT`: umtool never creates it, so an unmounted drive is a loud refusal ("is the media drive mounted?"), never a new tree on the main disk. A dangling `out` link refuses the same way. +- Make it a directory **inside** the drive (`<mount>/umtool`), never the + mountpoint itself: a mountpoint that stays behind as an empty directory when + the drive is unmounted passes the check, and the first build of a new project + would make its tree on the main disk. +- A cut move leaves `out.moved-<stamp>` or `out.incoming` beside the project's + `out`. While one exists, no writer makes a new `out/` and both moves refuse, + naming it: run the move it names again to finish it. - It is a READ root (a realpath through the link lands under it), never a write root. - The walk skips `out`, `clips` and `share-*`, so it never stats a link into a drive that is not there. diff --git a/umtool/e2e/dashboard.spec.ts b/umtool/e2e/dashboard.spec.ts @@ -51,7 +51,14 @@ test("umtool doctor reports a deliberately bad path as absent, and exits 1", () stdout = execFileSync("node", ["bin/umtool.mjs", "doctor", "--json"], { cwd: UMTOOL, encoding: "utf8", - env: { ...process.env, YTDLP_BIN: path.join(FIXTURE, "bin", "definitely-not-here"), QRENCODE_BIN: path.join(FIXTURE, "bin", "qrencode") }, + env: { + ...process.env, + YTDLP_BIN: path.join(FIXTURE, "bin", "definitely-not-here"), + QRENCODE_BIN: path.join(FIXTURE, "bin", "qrencode"), + // The fixture's roots, never this shell's media root or cache (empty = unset). + UMTOOL_MEDIA_DIR: "", + UMTOOL_CACHE_DIR: path.join(FIXTURE, "cache"), + }, }); } catch (e) { const err = e as { status: number; stdout: string }; diff --git a/umtool/e2e/projects.spec.ts b/umtool/e2e/projects.spec.ts @@ -320,6 +320,8 @@ const cliEnv = { CHANNELS_DIR: path.join(FIXTURE, "channels"), // The fixture's cache, as playwright.config.ts gives the app (never ~/.cache). UMTOOL_CACHE_DIR: path.join(FIXTURE, "cache"), + // Never the real media root, whatever this shell exports (empty = unset). + UMTOOL_MEDIA_DIR: "", }; const umtool = (args: string[]) => execFileSync("node", ["bin/umtool.mjs", ...args], { cwd: UMTOOL, encoding: "utf8", env: cliEnv }); diff --git a/umtool/e2e/report-longform.spec.ts b/umtool/e2e/report-longform.spec.ts @@ -23,6 +23,8 @@ const cliEnv = { CHANNELS_DIR: path.join(FIXTURE, "channels"), // The fixture's cache, as playwright.config.ts gives the app (never ~/.cache). UMTOOL_CACHE_DIR: path.join(FIXTURE, "cache"), + // Never the real media root, whatever this shell exports (empty = unset). + UMTOOL_MEDIA_DIR: "", YTDLP_BIN: path.join(FIXTURE, "bin", "yt-dlp"), }; const umtool = (args: string[]) => diff --git a/umtool/lib/media.ts b/umtool/lib/media.ts @@ -85,8 +85,11 @@ const MIN_INTERESTING = 256 * 1024; * picker under its project. Only a link INTO the media root: every other link * stays unfollowed, as it always was (SONG_DATA's 39 GB are links). */ +/** The project directories that may be links to the media root (U2 adds deliverables). */ +const MEDIA_LINKS = new Set(["out"]); + async function isMediaLink(e: { isSymbolicLink(): boolean }, abs: string): Promise<boolean> { - if (!MEDIA_TIERED || !e.isSymbolicLink()) return false; + if (!MEDIA_TIERED || !e.isSymbolicLink() || !MEDIA_LINKS.has(e.name)) return false; const real = await realpath(abs).catch(() => null); if (!real || !inside(MEDIA_ROOT, real)) return false; return stat(real).then((s) => s.isDirectory(), () => false); diff --git a/umtool/lib/paths.mjs b/umtool/lib/paths.mjs @@ -94,9 +94,11 @@ const dedupe = (list) => [...new Set(list.map((p) => path.resolve(p)))]; // UNSET, MEDIA_ROOT is REPORTS_ROOT, MEDIA_TIERED is false, and nothing changes: // `out/` is a plain directory in the project, as it always was. // -// MEDIA_ROOT is READABLE (a realpath taken through a project's `out` link lands -// under it -- the deck preview's confinement check is one) and never WRITABLE by -// a client-named path: what a render may write to is still WRITE_ROOTS. +// MEDIA_ROOT is READABLE -- so a client may name a media file by its real path +// (one taken through a project's `out` link lands under it) to the mix bench's +// /api/mix/{media,track} -- and never WRITABLE by a client-named path: what a +// render may write to is still WRITE_ROOTS, judged lexically, so a write to +// `<project>/out/...` is judged by the project's place, never the link's target. // --------------------------------------------------------------------------- export const MEDIA_ROOT = path.resolve( /* turbopackIgnore: true */ diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs @@ -61,8 +61,15 @@ export const SKIP_DIRS = new Set([ */ export const SKIP_PREFIXES = ["share-"]; +/** + * What a cut move leaves beside the directory it moved (lib/report/storage.mjs): + * `out.moved-<stamp>`, `out.incoming`. Never a project, and possibly large. + */ +const MOVE_LEFTOVER = /\.(moved-[^/]*|incoming)$/; + /** Whether the walk skips a directory entry by its name. */ -export const skipsDir = (name) => SKIP_DIRS.has(name) || SKIP_PREFIXES.some((p) => name.startsWith(p)); +export const skipsDir = (name) => + SKIP_DIRS.has(name) || SKIP_PREFIXES.some((p) => name.startsWith(p)) || MOVE_LEFTOVER.test(name); const has = (names, n) => names.has(n); const someMatch = (names, re) => [...names].some((n) => re.test(n)); diff --git a/umtool/playwright.config.ts b/umtool/playwright.config.ts @@ -80,6 +80,11 @@ export default defineConfig({ // SONG_DIR (release 17): its default is the user's ~/.cache, which a // suite must never write. The fixture's own, rebuilt with it every run. `UMTOOL_CACHE_DIR=${FIXTURE}/cache ` + + // And never the media root: Playwright hands the app this shell's whole + // environment, so a shell that exports UMTOOL_MEDIA_DIR would put every + // fixture build's out/ on the real media drive. Empty is unset + // (lib/paths.mjs reads it with ||); storage.spec.ts gives its CLI its own. + `UMTOOL_MEDIA_DIR= ` + // Stub binaries, so a build spec is offline and deterministic. The // pipeline already reads both as overrides; the fixture writes them. `YTDLP_BIN=${FIXTURE}/bin/yt-dlp QRENCODE_BIN=${FIXTURE}/bin/qrencode ` +