Archilyzer · Source

archilyzer

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

commit ce1b8f401cb24c10f5156aaeae0691836f945489
parent 45a37f16b987efe990b0ab460a84ff0ab1039931
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 21 Sep 2026 01:36:21 -0400

cut: a confirmed clip is already on this disk, so stop fetching it again

The written report ships clips/<id>.mp4 and the first 69 of them were fetched
one per clip with yt-dlp --download-sections. Every one of those seconds was
already paid for: nobody can judge a clip until its window is cached, so a
CONFIRMED clip is by definition one whose frames are on this disk.

So cutClipFromCache() asks the project cache for the tightest window holding
the clip and cuts it out. A clip with no containing window is not an error and
is not fetched here -- it is the bench's own "not fetched" state, which has a
managed download behind it (the editor's, with the cookie policy and the
per-platform sleeps), and this module must never grow a second one.

The ffmpeg cut is build-video's, not a second spelling of it: cutArgs() is
lifted out of buildClipSegment and exported, so the render's segment pass and
this share the one piece of arithmetic that turns a clip's absolute seconds
into an offset into a cached file. FFMPEG_BIN/FFPROBE_BIN go with it, so a
fixture's stub binaries are wired in one place.

Stream copy first, then MEASURE it. A copy can only begin on a keyframe: a
window fetched with --force-keyframes-at-cuts has one exactly where the clip
starts and the copy is exact and free, while a window fetched any other way --
or a clip whose edges moved on the bench after the fetch -- begins seconds
early, which is the wrong sentence rather than a rounding error. Probing the
result answers that in one number, where probing the window's keyframes would
only have predicted it.

The two share-batch encode profiles come off ~/reports/elfpire-eva's
reencode.py, ported into encode.mjs so there is ONE place holding them rather
than a python file beside one project's deliverables.

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

Diffstat:
Aumtool/bin/cut-from-cache.mjs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/cut.mjs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/encode.mjs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/build-video.mjs | 31+++++++++++++++++++++++++++++--
4 files changed, 317 insertions(+), 2 deletions(-)

diff --git a/umtool/bin/cut-from-cache.mjs b/umtool/bin/cut-from-cache.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Cut ONE clip out of the window already on disk. +// +// node bin/cut-from-cache.mjs --project <id> --clip <id> [--reencode|--no-reencode] +// +// A CLI so the Deliver panel's "cut the confirmed clips" can be ONE job with +// one step per clip: lib/jobs.ts then owns the sequencing, the `k of n` the UI +// reads off stepIndex, the timeout, and a Stop that kills the process group +// rather than a promise nobody can interrupt. A loop inside a request handler +// would have had to reinvent all four. +// +// Never fetches. A clip with no containing window exits 3 and says so, which +// is a fact about the cache and not a failure of the cut. +import process from "node:process"; +import { resolveProject } from "../lib/projects/core.mjs"; +import { cutClipFromCache } from "../lib/report/cut.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 clipId = val("--clip"); +if (!projectArg || !clipId) { + console.error("usage: cut-from-cache.mjs --project <id> --clip <id> [--reencode|--no-reencode]"); + process.exit(2); +} + +const r = await resolveProject(projectArg); +if (!r.project) { + console.error(`no project matches "${projectArg}"`); + process.exit(2); +} + +const reencode = argv.includes("--reencode") + ? "always" + : argv.includes("--no-reencode") + ? "never" + : "auto"; + +const res = await cutClipFromCache(r.project, clipId, { reencode }); +if (!res.ok) { + console.error(`${clipId}: ${res.error}`); + // 3 is "nothing on disk holds this clip", which the panel already lists + // under its own heading with a fetch link. Every other failure is a 1. + process.exit(res.reason === "not-fetched" ? 3 : 1); +} +console.log( + `CUT-OK ${res.id} ${res.seconds?.toFixed(2)}s (wanted ${res.want.toFixed(2)}s) ` + + `${res.mode} from ${res.window.name}`, +); diff --git a/umtool/lib/report/cut.mjs b/umtool/lib/report/cut.mjs @@ -0,0 +1,152 @@ +// Cutting ONE clip out of the window already on disk. +// +// The deliverable a written report ships is `clips/<id>.mp4` -- the players in +// report.html read exactly that -- and the first batch of them was fetched one +// per clip with `yt-dlp --download-sections`. That is a second download of +// seconds already paid for: the bench fetches a generous window per clip into +// `out/clips-raw` (or, now, into the corpus) before anybody can judge it, and +// every confirmed clip therefore already has its own frames on this disk. +// +// So this cuts from the cache and never touches the network. A clip with no +// containing window is NOT cut here and is not an error either -- it is a clip +// nobody has fetched yet, which is the bench's own "not fetched" state and has +// its own button. +// +// The ffmpeg cut itself is build-video's: `cutArgs()` is the render's segment +// pass, exported rather than copied, so the seconds this writes and the seconds +// the video renders are the same arithmetic. +import { execFile } from "node:child_process"; +import { mkdir, rename, rm } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { FFMPEG_BIN, cutArgs } from "umtool-report-to-video/build-video"; +import { clipsOf, readManifest } from "../projects/report.mjs"; +import { ACCURATE_CUT_ARGS, probeSeconds } from "./encode.mjs"; +import { projectCache } from "./serve.mjs"; + +const execFileP = promisify(execFile); + +/** Where the report's own players look. Not configurable: build.py hardcodes it. */ +export const CLIPS_DIR = "clips"; + +/** + * How far out a stream-copied cut may land before it is re-encoded. + * + * A copy can only start on a keyframe. A window fetched with + * `--force-keyframes-at-cuts` has one exactly where the clip begins, so the + * copy is exact and free; a window fetched any other way -- or a clip whose + * edges were MOVED on the bench after the fetch -- has one wherever the encoder + * put it, and the copy silently begins seconds early. That is not a rounding + * error to tolerate: it is the wrong sentence. + */ +export const CUT_TOLERANCE = 0.05; + +/** ffmpeg gets a generous cap; a cut of a cached window is seconds of work. */ +const CUT_TIMEOUT_MS = 5 * 60_000; + +/** + * Cut `clipId` of `project` out of the cached window that contains it. + * + * @param {{ id: string, dir: string }} project + * @param {string} clipId + * @param {{ reencode?: "auto" | "always" | "never", manifest?: object | null, + * cache?: Awaited<ReturnType<typeof projectCache>> | null }} [opts] + * `reencode` is the operator's override of the keyframe test above: + * "always" for a window whose copy is known to be wrong, "never" for a + * re-cut that must not lose a generation. + * @returns {Promise<{ ok: boolean, id: string, reason?: string, error?: string, + * path?: string, rel?: string, seconds?: number, want?: number, + * mode?: "copy" | "reencode", window?: { name: string, from: number, to: number } }>} + */ +export async function cutClipFromCache( + project, + clipId, + { reencode = "auto", manifest = null, cache = null } = {}, +) { + const m = manifest ?? (await readManifest(project.dir)); + if (!m) return { ok: false, id: clipId, reason: "no-manifest", error: "no manifest" }; + const clip = clipsOf(m).find((e) => e.id === clipId); + if (!clip) return { ok: false, id: clipId, reason: "no-clip", error: "no such clip" }; + + const start = Number(clip.start); + const end = Number(clip.end); + if (!Number.isFinite(start) || !Number.isFinite(end) || !(end > start)) { + return { ok: false, id: clipId, reason: "no-window", error: "this clip has no window" }; + } + + // THE EXTENT, not the cut-to-quote. `cutStart`/`cutEnd` is what the VIDEO + // plays; a written report's player is the reviewed extent, which is what + // clips.json carries and what the caption under it describes. + const c = cache ?? (await projectCache(project, m)); + const win = c.containing(clip.video, start, end); + if (!win) { + // Not an error. Nobody has fetched this one yet, and the bench has a + // button for exactly that. + return { + ok: false, + id: clipId, + reason: "not-fetched", + error: "no cached window holds this clip end to end", + }; + } + + const want = end - start; + const a = Math.max(0, start - win.from); + const b = a + want; + const dir = path.join(project.dir, CLIPS_DIR); + await mkdir(dir, { recursive: true }); + const out = path.join(dir, `${clipId}.mp4`); + const tmp = path.join(dir, `.${clipId}.cutting.mp4`); + + const run = async (args) => { + await rm(tmp, { force: true }); + await execFileP( + FFMPEG_BIN, + ["-nostdin", "-v", "error", "-y", ...cutArgs(win.path, a, b), ...args, tmp], + { maxBuffer: 1 << 24, timeout: CUT_TIMEOUT_MS }, + ); + return probeSeconds(tmp); + }; + + let mode = reencode === "always" ? "reencode" : "copy"; + let got = null; + try { + if (mode === "copy") { + // `-avoid_negative_ts make_zero` so the copied packets' timestamps start + // at zero: without it a copy that began on an earlier keyframe carries + // the window's own clock into the file, and every player disagrees about + // how long it is. + got = await run(["-c", "copy", "-avoid_negative_ts", "make_zero", "-movflags", "+faststart"]); + const off = got == null ? Infinity : Math.abs(got - want); + if (off > CUT_TOLERANCE && reencode !== "never") { + // The window's keyframes are not where this clip's edges are, so the + // copy is the wrong seconds. Pay for one generation and get the cut + // that was asked for. + mode = "reencode"; + got = await run(ACCURATE_CUT_ARGS); + } + } else { + got = await run(ACCURATE_CUT_ARGS); + } + } catch (e) { + await rm(tmp, { force: true }); + return { + ok: false, + id: clipId, + reason: "ffmpeg", + error: e instanceof Error ? e.message : String(e), + }; + } + + await rename(tmp, out); + return { + ok: true, + id: clipId, + path: out, + rel: path.posix.join(CLIPS_DIR, `${clipId}.mp4`), + seconds: got == null ? null : Number(got.toFixed(3)), + want: Number(want.toFixed(3)), + mode, + window: { name: win.name, from: win.from, to: win.to }, + }; +} diff --git a/umtool/lib/report/encode.mjs b/umtool/lib/report/encode.mjs @@ -0,0 +1,83 @@ +// The encode profiles, in ONE place. +// +// These came off `share-report-clips/reencode.py` in ~/reports/elfpire-eva, +// which is where the first share batch was actually encoded and therefore the +// only honest source for "what the last batch looked like". Ported rather than +// spawned: a python file living beside one project's deliverables is not a +// profile the next project can reach, and two copies of an x264 command line +// is exactly the drift that makes batch 2 not match batch 1. +// +// `std` and `small` are that script's two profiles, argument for argument. +// `accurate` is neither: it is the re-encode a CUT falls back to when the cut +// cannot be stream-copied, and it deliberately keeps the source's geometry -- +// an `orig/` file is "the window as fetched", and rescaling it here would make +// the folder's own description untrue. +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { FFPROBE_BIN } from "umtool-report-to-video/build-video"; + +const execFileP = promisify(execFile); + +/** Scale-and-pad to an exact frame, the shape both share profiles use. */ +const box = (w, h) => + `scale=w=${w}:h=${h}:force_original_aspect_ratio=decrease,` + + `pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2,format=yuv420p`; + +export const SHARE_PROFILES = { + // Exactly 1280x720, faststart, a level every phone and every forum player + // will take. This is the one people download. + std: { + label: "1280x720 · crf 23", + args: [ + "-vf", box(1280, 720), + "-c:v", "libx264", "-profile:v", "high", "-level", "4.0", + "-preset", "medium", "-crf", "23", "-r", "30", "-g", "60", + "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2", + "-movflags", "+faststart", + ], + }, + // 640x360 under a 300k ceiling: the copy that survives an upload limit. + small: { + label: "640x360 · crf 28 · 300k cap", + args: [ + "-vf", box(640, 360), + "-c:v", "libx264", "-profile:v", "main", "-level", "3.1", + "-preset", "medium", "-crf", "28", "-maxrate", "300k", "-bufsize", "600k", + "-r", "30", "-g", "60", + "-c:a", "aac", "-b:a", "64k", "-ar", "44100", "-ac", "2", + "-movflags", "+faststart", + ], + }, +}; + +/** The encode names a batch produces, beside the untouched `orig`. */ +export const SHARE_VARIANTS = ["orig", ...Object.keys(SHARE_PROFILES)]; + +/** + * The re-encode a cut falls back to when the window's keyframes are in the + * wrong places. No scaling and no frame-rate change: the point of this file is + * that its first and last frames are the seconds that were asked for. + */ +export const ACCURATE_CUT_ARGS = [ + "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "160k", + "-movflags", "+faststart", +]; + +/** + * A file's duration in seconds, from the container. + * + * build-video's probeDuration() counts VIDEO FRAMES, because a rendered + * segment's length has to agree with the timeline it is concatenated into. + * Nothing here is concatenated: the question is "did this cut come out the + * length it was asked for", and the container's own answer is the one a + * downloader will see. + */ +export async function probeSeconds(file) { + const { stdout } = await execFileP(FFPROBE_BIN, [ + "-v", "error", "-show_entries", "format=duration", + "-of", "default=nw=1:nk=1", file, + ]); + const n = Number(String(stdout).trim()); + return Number.isFinite(n) ? n : null; +} diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -77,6 +77,12 @@ const FFMPEG = process.env.FFMPEG_BIN ?? "ffmpeg"; const FFPROBE = process.env.FFPROBE_BIN ?? "ffprobe"; const QRENCODE = process.env.QRENCODE_BIN ?? "qrencode"; +// The binaries, under the names the rest of umtool calls them by. A second +// module reading FFMPEG_BIN for itself would be a second place a fixture's +// stub has to be wired in, and the one that forgot would shell out to the real +// ffmpeg in the middle of a test. +export { FFMPEG as FFMPEG_BIN, FFPROBE as FFPROBE_BIN }; + // Cue windows and per-video metadata come from a local corpus when there is one // and from the published archive otherwise, so this runs in a clone with no // `transcripts/` directory. Built once main() has the manifest (it carries the @@ -252,7 +258,7 @@ async function videoMeta(videoId, channelSlug, hints = {}) { // The video stream's frame COUNT is the number the timeline actually runs on, // so derive the duration from it. nb_frames is absent on some demuxers; fall // back to the container rather than failing a build over a probe. -async function probeDuration(file, fps) { +export async function probeDuration(file, fps) { if (fps) { const { stdout } = await execFileP(FFPROBE, [ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_frames", @@ -345,6 +351,27 @@ export async function findContainingWindow(rawDir, video, from, to) { return tightestContaining(await cachedWindowsFor(rawDir, video), from, to); } +/** + * WHERE A CLIP IS CUT OUT OF A CACHED WINDOW, as ffmpeg input arguments. + * + * `-ss`/`-to` BEFORE `-i`, and both matter. Before the input, ffmpeg seeks the + * demuxer rather than decoding and discarding, which is the difference between + * seconds and minutes on a 45-minute window; `-to` before the input is then + * measured on the same clock as the `-ss`, i.e. in the SOURCE FILE's seconds, + * which is what an offset into a cached window is. + * + * Exported because two things cut a clip out of a window now -- the render's + * segment pass and the bench's "cut confirmed clips from cache" -- and a second + * spelling of this is a second set of seconds that can drift from the first. + * + * @param {string} raw the cached window file + * @param {number} a seconds INTO that file where the clip starts + * @param {number} b seconds into it where the clip ends + */ +export function cutArgs(raw, a, b) { + return ["-ss", a.toFixed(3), "-to", b.toFixed(3), "-i", raw]; +} + async function fetchClip(entry, meta, render, rawDir, opts) { // Deliberately over-fetch: the snapping pass below needs room on both sides to // find a silence, and a clip that has no slack can only be cut where the cue @@ -687,7 +714,7 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, // eof_action=repeat holds the strip's last frame, which is the parked bar. const barT = Math.max(0.2, cutB - cutA - 0.25); - const inputs = ["-ss", cutA.toFixed(3), "-to", cutB.toFixed(3), "-i", raw]; + const inputs = cutArgs(raw, cutA, cutB); let nextIdx = 1; let footerIdx, markerIdx, barIdx, qrIdx; if (hasFooter) {