// 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, stat, 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"; import { deliverableDir, deliverablesProblems, deliverablesState, isDeliverableName } from "./storage.mjs"; const execFileP = promisify(execFile); /** `share-/` 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; } /** `-`, the folder a batch sorts a clip into. */ export const sectionFolder = (letter, heading) => heading ? `${letter}-${slug(heading, 40)}` : letter; /** `__.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 IDS A LIST NAMES AS SHARED SOMEWHERE ELSE. // // Measured on the real project: six clips went out in an earlier set under // DIFFERENT ids (em01, ie01 -- a separate cut of the same moments), and // nothing on disk ties those files to b02/c08/f01/f05/h01/h02. What does // tie them is the sentence the batch that followed wrote down: "minus the // 6 already shared in the emancipation/Ireland set (b02 c08 f01 f05 h01 // h02)". Without this the next batch re-ships all six. // // Prose, and read as narrowly as prose can be: the phrase, then a // 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); } } } // And the files themselves, for a batch assembled before anyone wrote a list. // // A link to a directory is walked like one (release 17: a batch moved to the // media root is a link, and so may be anything a person linked in), but only // a few levels down -- a batch is `<variant>/<section>/<file>`, and a link // that loops must not hang the panel. const walk = async (d, depth = 0) => { if (depth > 6) return; for (const ent of await readdir(d, { withFileTypes: true }).catch(() => [])) { const p = path.join(d, ent.name); const isDir = ent.isDirectory() || (ent.isSymbolicLink() && (await stat(p).then((st) => st.isDirectory(), () => false))); if (isDir) await walk(p, depth + 1); 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. * * A batch may be a LINK to the media root (release 17: `umtool storage * deliverables <p> --to media`), and is listed like the directory it replaced. * A link whose drive is not there is listed too, as `dangling` with no ids: * leaving it out would read as "never shipped" and the next batch would ship * its clips again -- so a batch refuses while one dangles (buildShareBatch). * A cut move's leftover (`share-x.moved-<ts>`, `share-x.incoming`) is not a * batch. */ export async function listBatches(projectDir) { const entries = (await readdir(projectDir, { withFileTypes: true }).catch(() => [])).filter( (e) => (e.isDirectory() || e.isSymbolicLink()) && e.name.startsWith(SHARE_PREFIX) && isDeliverableName(e.name), ); const found = await Promise.all( entries.map(async (e) => { const dir = path.join(projectDir, e.name); const isDir = e.isDirectory() || (await stat(dir).then((st) => st.isDirectory(), () => false)); // A symlink to a FILE named share-x is nothing of ours; a dangling one is. if (!isDir && (await stat(dir).then(() => true, () => false))) return null; return { name: e.name, dir, dangling: !isDir }; }), ); const batches = found.filter((b) => b !== null).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); return Promise.all( batches.map(async ({ name, dir, dangling }) => { const ids = dangling ? [] : [...(await sharedIdsIn(dir))].sort(); return { name, label: name.slice(SHARE_PREFIX.length), dir, ids, dangling, hasList: !dangling && !!(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); // Where the deliverables live, and what stops a cut or a batch being // written now (release 17). A dangling clips/ reads as "nothing cut" above; // these sentences are what keep that from turning into a re-cut of // everything on the wrong drive. const storage = await deliverablesState(project.dir); const nextName = `batch-${new Date().toISOString().slice(0, 10)}`; 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, 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")), storage: { ...storage, // The reasons a cut (into clips/) or a new batch would be refused. cutBlocked: deliverablesProblems(storage, CLIPS_DIR), shareBlocked: deliverablesProblems(storage, `${SHARE_PREFIX}${nextName}`), }, }; } /** Is there a file here? `stat`, not a read: an `orig/` mp4 is megabytes. */ const exists = (p) => stat(p).then((st) => st.isFile(), () => 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); // Refused while a deliverable cannot be read or written (release 17): a // batch that cannot see an earlier batch would ship its clips again, and one // that cannot see clips/ would ship nothing. const blocked = deliverablesProblems(state.storage, `${SHARE_PREFIX}${name}`); if (blocked.length) throw new Error(`share-${name} not built: ${blocked.join("; ")}`); 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"); // Made where the project's deliverables switch says: a directory, or a link // to the media root. Everything below is made under it, through the link. const root = await deliverableDir(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 }; }