// Making a report-video project that is not there yet. ONE writer, for the CLI // and the route, so `umtool new` and the "new project" menu cannot produce two // different skeletons. // // Plain ESM: `umtool new` runs from a terminal with no server. import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { BRAND_IDS } from "umtool-report-to-video/brand-ids"; import { GLOBAL_CHANNELS_DIR, MANIFEST_NAME } from "./report.mjs"; export const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; /** * A manifest skeleton, and deliberately an EMPTY timeline. * * It would be easy to derive first-draft clips from a report's citations: the * shape is regular (`> "quote"` then `— [title @ h:mm:ss](…?v=slug%2Fid&t=sec)`). * It is not done, and that is the honest position rather than a missing feature. * A report records ONE second per citation; a window needs a start AND an end * taken from transcript.cues.json, and matching a quote to its cues is the * actual work of authoring a cut. A generated timeline of guessed windows would * look finished and be wrong, and every clip would have to be opened anyway. * * (`--seed chapters` is the one exception, and it invents nothing: the windows * it writes are the digest's own chapter boundaries.) */ export function skeleton(slug, title, provenance, { brand = null } = {}) { const doc = { schemaVersion: 1, slug, title, subtitle: "", generatedOn: new Date().toISOString().slice(0, 10), provenance: { // The field that shipped broken TWICE. It is first, and it is empty rather // than plausible, so `umtool check` blocks until somebody sets it. siteOrigin: "", channelSlug: "", channel: "", ...provenance, }, render: { width: 1920, height: 1080, fps: 30, audioRate: 48000, audioChannels: 2, maxHeightSource: 1080, fontRegular: "/usr/share/fonts/TTF/FiraSans-Regular.ttf", fontBold: "/usr/share/fonts/TTF/FiraSans-Bold.ttf", palette: { bg: "#12100c", fg: "#f6f1e6", muted: "#a2957f", accent: "#c8752a", amber: "#ffc860" }, transition: 0.4, fetchPad: 3, snapWindow: 1.6, silenceMinDur: 0.09, silenceRelDb: 6, headerHeight: 56, footerHeight: 0, crf: 21, preset: "slow", qr: { scale: 4, quiet: 3, ecc: "M", margin: 28 }, }, timelineNodes: [], timeline: [], }; // A brand preset (report-to-video/brand.mjs) owns the palette and the faces // and adds the title lockup, the header mark, the end card and a thumbnail // step. The key is ADDED, never a different skeleton: the palette above is // still written, so dropping `brand` later leaves a manifest that renders. if (brand) doc.render.brand = brand; return doc; } /** * What `--from` is, by its SHAPE. Never guessed: a value that is none of these * is an error, not "probably a report". * * report a path ending in .md * video a viewer share URL `…/?v=%2F&t=`, or a bare * `/` ref * * @returns {{ kind: "report", path: string } | { kind: "video", channel: string, video: string, second: number | null, origin: string | null } | null} */ export function detectFrom(from) { if (!from) return null; const v = String(from).trim(); if (/\.md$/i.test(v)) return { kind: "report", path: v }; const url = v.match(/^(https?:\/\/[^/?#]+)[^?#]*\?(?:[^#]*&)?v=([^&#]+)(?:&(?:[^#]*&)?t=(\d+))?/i); if (url) { const [chan, id] = decodeURIComponent(url[2]).split("/"); if (chan && id) return { kind: "video", channel: chan, video: id, second: url[3] ? Number(url[3]) : null, origin: url[1] }; } const bare = v.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)$/); if (bare) return { kind: "video", channel: bare[1], video: bare[2], second: null, origin: null }; return null; } /** Citations in a sweep report: `?v=%2F&t=` links. */ export function citationsIn(text) { const out = []; for (const m of text.matchAll(/\]\([^)]*[?&]v=([^&)]+)&t=(\d+)/g)) { const [chan, id] = decodeURIComponent(m[1]).split("/"); out.push({ channel: chan, video: id, second: Number(m[2]) }); } return out; } const readJson = (file) => readFile(file, "utf8").then((t) => JSON.parse(t), () => null); /** * The digest's chapters, as windows. The same merge common/lib/digest.ts * performs -- later source wins per id, `enabled: false` suppresses, sorted by * start -- done on the JSON directly because the CLI cannot import TypeScript. * * These are the DIGEST'S boundaries, not invented windows: `start` is the * chapter's start and `end` is the next chapter's start. The last chapter ends * at the video's duration when metadata.info.json has one, else its `end` is * omitted and reported so nobody mistakes a guess for a fact. */ export async function chaptersAsClips({ channelsDir, channel, video }) { const dir = path.join(channelsDir, channel, "data", video); if (!(await stat(dir).then((s) => s.isDirectory(), () => false))) { throw new Error(`no video directory at ${dir}`); } const digest = await readJson(path.join(dir, "ai-digest.json")); if (!digest) throw new Error(`${channel}/${video} has no ai-digest.json — nothing to seed from`); const overrides = await readJson(path.join(dir, "ai-digest.overrides.json")); const meta = await readJson(path.join(dir, "metadata.info.json")); const byId = new Map(); for (const c of digest.sections?.chapters?.items ?? []) byId.set(c.id, c); for (const c of overrides?.chapters ?? []) byId.set(c.id, { ...(byId.get(c.id) ?? {}), ...c }); const chapters = [...byId.values()] .filter((c) => c.enabled !== false && Number.isFinite(Number(c.start))) .sort((a, b) => a.start - b.start || String(a.title).localeCompare(String(b.title))); const duration = Number.isFinite(Number(meta?.duration)) ? Number(meta.duration) : null; const clips = chapters.map((c, i) => { const next = chapters[i + 1]; const end = next ? Number(next.start) : duration; return { type: "clip", id: `c${String(i).padStart(2, "0")}`, channel, video, start: Number(Number(c.start).toFixed(2)), ...(end != null ? { end: Number(Number(end).toFixed(2)) } : {}), cite: Math.floor(Number(c.start)), chapter: String(c.title ?? ""), section: 0, quote: "", }; }); return { clips, noEnd: duration == null && clips.length > 0 }; } /** * Write the project. Refuses an existing directory and a slug that will not * route; everything else it cannot know is written EMPTY so `check` blocks. * * @param {{ root: string, slug: string, title?: string, from?: string | null, siteOrigin?: string | null, seed?: string | null, brand?: string | null, channelsDir?: string }} args */ export async function scaffoldReportVideo({ root, slug, title, from = null, siteOrigin = null, seed = null, brand = null, channelsDir = GLOBAL_CHANNELS_DIR() }) { if (!SLUG_RE.test(String(slug ?? ""))) { throw new Error(`"${slug}" will not route — use lower-case letters, digits and dashes`); } const dir = path.join(root, slug); if (dir !== path.resolve(root, slug) || path.dirname(dir) !== path.resolve(root)) { throw new Error("refusing to create outside the tree"); } if (await stat(dir).then(() => true, () => false)) throw new Error(`${dir} already exists`); const src = detectFrom(from); if (from && !src) { throw new Error( `could not tell what --from is: "${from}" is neither a .md report, a viewer share URL (…/?v=%2F&t=), nor a / ref`, ); } if (seed && seed !== "chapters") throw new Error(`--seed must be "chapters", not "${seed}"`); if (seed && src?.kind !== "video") throw new Error("--seed chapters needs --from to be a video ref"); if (brand && !BRAND_IDS.includes(brand)) { throw new Error(`--brand must be one of ${BRAND_IDS.join(", ")}, not "${brand}"`); } let finalTitle = String(title ?? "").trim() || slug.replace(/-/g, " "); let reportText = null; let citations = []; const provenance = {}; const notes = []; if (src?.kind === "report") { reportText = await readFile(src.path, "utf8").catch(() => null); if (reportText === null) throw new Error(`could not read ${src.path}`); if (!title) finalTitle = reportText.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? finalTitle; citations = citationsIn(reportText); const channels = [...new Set(citations.map((c) => c.channel))]; if (channels.length === 1) provenance.channelSlug = channels[0]; } else if (src?.kind === "video") { provenance.channelSlug = src.channel; citations = [{ channel: src.channel, video: src.video, second: src.second ?? 0 }]; // A share URL carries the origin the citation came from. Recorded only when // nothing more explicit was given; still overridable, never guessed. if (!siteOrigin && src.origin) siteOrigin = src.origin; } if (siteOrigin) provenance.siteOrigin = String(siteOrigin); const doc = skeleton(slug, finalTitle, provenance, { brand }); let seeded = 0; if (seed === "chapters") { const { clips, noEnd } = await chaptersAsClips({ channelsDir, channel: src.channel, video: src.video }); doc.timeline = clips; seeded = clips.length; if (noEnd) notes.push("the last chapter has NO end: metadata.info.json carries no duration — set it by hand"); if (!clips.length) notes.push("the digest has no enabled chapters, so the timeline is empty"); } await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(doc, null, 2) + "\n", "utf8"); if (reportText !== null) await writeFile(path.join(dir, "sweep-report.md"), reportText, "utf8"); const lines = [ `# ${finalTitle}`, "", "What this cut argues, which sources it draws on, and anything cut short on", "purpose (with why — that is what a `lock` in the manifest means).", "", ]; if (seeded) { lines.push( "## Clips seeded from digest chapters — review each", "", "Every window below is a chapter boundary from the archive's digest, not an", "edit. None is locked. Open each in the bench, cut it to the quote that", "matters, and lock what you meant.", "", ...doc.timeline.map( (c) => `- [ ] ${c.id} ${c.channel}/${c.video} ${c.start}–${c.end ?? "?"} ${c.chapter}`, ), ); } else { lines.push( "## Windows still to write", "", citations.length ? "Each of these is ONE second from the source. A clip needs a start AND an" + " end, read from the source's transcript.cues.json — that matching is the work." : "No `?v=` citations were found, so there is nothing to work from yet.", "", ...citations.map( (c, i) => `- [ ] c${String(i).padStart(2, "0")} ${c.channel}/${c.video} @ ${c.second}s`, ), ); } if (notes.length) lines.push("", "## Notes from the scaffold", "", ...notes.map((n) => `- ${n}`)); await writeFile(path.join(dir, "README.md"), lines.join("\n") + "\n", "utf8"); return { dir, id: slug, title: finalTitle, from: src, citations: citations.length, seeded, notes, siteOrigin: provenance.siteOrigin ?? "", ...(brand ? { brand } : {}), }; }