// The posting artifacts, derived rather than hand-made. // // Three real projects carry a hand-built `time · clip · source-link` table // beside the manifest (quartering-gout/toc.bbcode.txt, hasan-bike's .bbcode.txt // and .html, quartering-employee-count/report.html). Each is a pure derivation // of the manifest plus the build's chapter offsets, so this derives them. // // OFFSETS COME FROM THE BUILD, never from the windows. A window is source // seconds; a chapter start is deliverable seconds, after snapping, padding and // crossfades. `out//chapters.ffmeta` (or the pre-variant // `out/chapters.ffmeta`) is what the build wrote, so it is read first. When it // is absent, segmentOffsets() -- the pipeline's own function -- is run over the // segments on disk. When neither exists there is nothing honest to say, and // the export refuses with the reason. // // Plain ESM, so `umtool export` runs from a terminal. import { readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { DEFAULT_VARIANT, selectVariant, segmentOffsets } from "umtool-report-to-video/build-video"; import { citeUrlFor, channelFor, readAvailability, readManifest } from "../projects/report.mjs"; import { teaserTitle } from "umtool-report-to-video/deck"; import { outDirState } from "./storage.mjs"; export const EXPORT_FORMATS = ["toc-bbcode", "toc-markdown", "description", "chapters"]; /** `m:ss` past the hour as `h:mm:ss`, floored -- the shape toc.bbcode.txt uses. */ export const mmss = (t) => { const s = Math.max(0, Math.floor(Number(t) || 0)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = String(s % 60).padStart(2, "0"); return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`; }; /** YouTube's chapter form: `00:00`, and `1:02:03` past the hour. */ export const ytTime = (t) => { const s = Math.max(0, Math.floor(Number(t) || 0)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = String(s % 60).padStart(2, "0"); return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${String(m).padStart(2, "0")}:${sec}`; }; /** ffmetadata back into numbers. The build wrote `START=` per chapter. */ export function parseFfmeta(text) { const out = []; let cur = null; for (const raw of String(text).split("\n")) { const line = raw.trim(); if (line === "[CHAPTER]") { cur = { start: null, end: null, title: "" }; out.push(cur); continue; } if (!cur) continue; const m = line.match(/^(START|END|title)=(.*)$/); if (!m) continue; if (m[1] === "title") cur.title = m[2].replace(/\\([=;#\\])/g, "$1"); else cur[m[1] === "START" ? "start" : "end"] = Number(m[2]) / 1000; } return out; } /** The chapter title the build would print: `chapter`, else the entry's own words. */ const titleOf = (e, i) => e.chapter ?? (e.type === "clip" ? `${i + 1}. ${e.video}` : e.type === "teaser" ? teaserTitle(e) : e.title ?? e.heading ?? `Card ${i + 1}`); /** * Deliverable-second offsets for every entry of the chosen variant. * @returns {Promise<{ starts: number[], source: "ffmeta"|"schedule"|"segments", file: string, note?: string } | { error: string }>} */ export async function chapterOffsets(dir, manifest, variant) { const entries = manifest.timeline ?? []; const outDir = path.join(dir, "out"); // Read through, never made: an export writes nothing under out/. But a link // to a media root that is not mounted would read below as "no build", which // sends somebody off to rebuild a cut that is sitting on an unplugged drive. const out = await outDirState(dir); if (out.state === "dangling") { return { error: `out/ is a link to ${out.target}, which is not there — is the media drive mounted?` }; } const ffmetaCandidates = [path.join(outDir, variant, "chapters.ffmeta"), path.join(outDir, "chapters.ffmeta")]; for (const file of ffmetaCandidates) { const text = await readFile(file, "utf8").catch(() => null); if (text === null) continue; const chapters = parseFfmeta(text); if (chapters.length !== entries.length) { return { error: `${path.relative(dir, file)} has ${chapters.length} chapter(s) for ${entries.length} timeline entr(ies) — ` + "it describes a different cut; rebuild (a chapters-only build is enough)", }; } return { starts: chapters.map((c) => c.start), source: "ffmeta", file }; } // No ffmeta, but a deck schedule for this cut: its starts are the cut's own, // holds included -- the segment files alone do not know a clip is held. const sched = await readFile(path.join(outDir, variant, "schedule.json"), "utf8") .then((t) => JSON.parse(t), () => null); if (sched?.kind === "deck" && Array.isArray(sched.segments) && sched.segments.map((x) => x.id).join("\n") === entries.map((e) => e.id).join("\n")) { const D = sched.transition ?? 0; return { starts: sched.segments.map((x, i) => (i === 0 ? 0 : x.start + D)), source: "schedule", file: path.join(outDir, variant, "schedule.json"), note: "no chapters.ffmeta; offsets from the deck's schedule.json", }; } // No ffmeta: the pipeline's own offset arithmetic over the segments on disk. const segDirs = [path.join(outDir, variant, "segments"), path.join(outDir, "segments")]; for (const segDir of segDirs) { const names = new Set(await readdir(segDir).catch(() => [])); if (!names.size) continue; const missing = entries.filter((e) => !names.has(`${e.id}.mp4`)).map((e) => e.id); if (missing.length) { return { error: `${path.relative(dir, segDir)} is missing ${missing.join(", ")} — the cut was never fully built` }; } const render = manifest.render ?? {}; const D = render.transition ?? 0.5; const { starts } = await segmentOffsets(entries.map((e) => path.join(segDir, `${e.id}.mp4`)), D, render.fps); // Land just past the crossfade, as muxChapters does. return { starts: starts.map((s, i) => (i === 0 ? 0 : s + D)), source: "segments", file: segDir, note: `no chapters.ffmeta; offsets computed from the segments assuming a ${D}s crossfade — a hard-cut build would be earlier by that much per entry`, }; } return { error: "nothing built: no chapters.ffmeta and no segments under out/ — build first" }; } /** * @param {string} dir * @param {string} format one of EXPORT_FORMATS * @param {{ variant?: string }} [opts] * @returns {Promise<{ text: string, format: string, variant: string, offsets: { source: string, file: string, note?: string } }>} */ export async function exportProject(dir, format, { variant = DEFAULT_VARIANT } = {}) { if (!EXPORT_FORMATS.includes(format)) throw new Error(`format must be one of ${EXPORT_FORMATS.join(", ")}`); const whole = await readManifest(dir); if (!whole) throw new Error("no manifest"); const m = selectVariant(whole, variant); const off = await chapterOffsets(dir, m, variant); if (off.error) throw new Error(off.error); const entries = m.timeline ?? []; const rows = entries.map((e, i) => ({ e, i, at: off.starts[i], title: titleOf(e, i), cite: e.type === "clip" ? citeUrlFor(m, e) : null, })); const clipRows = rows.filter((r) => r.e.type === "clip"); let text; if (format === "toc-bbcode") { text = [ `[b]${m.title}${m.subtitle ? ` — ${m.subtitle}` : ""}[/b]`, "", "[table]", "[tr][th]Time[/th][th]Clip[/th][th]Source[/th][/tr]", ...clipRows.map((r) => `[tr][td]${mmss(r.at)}[/td][td]${r.title}[/td][td][url=${r.cite}]open[/url][/td][/tr]`), "[/table]", "", ].join("\n"); } else if (format === "toc-markdown") { text = [ `**${m.title}**${m.subtitle ? ` — ${m.subtitle}` : ""}`, "", "| Time | Clip | Source |", "|---|---|---|", ...clipRows.map((r) => `| ${mmss(r.at)} | ${r.title.replace(/\|/g, "\\|")} | [open](${r.cite}) |`), "", ].join("\n"); } else if (format === "chapters") { // YouTube needs the first at 00:00 and every entry present, cards included. text = rows.map((r) => `${ytTime(r.i === 0 ? 0 : r.at)} ${r.title}`).join("\n") + "\n"; } else { const avail = await readAvailability(dir); const titles = new Map((avail?.sources ?? []).map((s) => [s.key, s.title])); const seen = new Map(); for (const r of clipRows) { const key = `${channelFor(m, r.e)}/${r.e.video}`; if (!seen.has(key)) seen.set(key, { key, title: titles.get(key) ?? null, cite: r.cite }); } text = [ m.title, ...(m.subtitle ? [m.subtitle] : []), "", ...clipRows.map((r) => `${mmss(r.at)} — ${r.title} — ${r.cite}`), "", "Sources:", ...[...seen.values()].map((s) => `- ${s.title ? `${s.title} (${s.key})` : s.key} — ${s.cite}`), "", ].join("\n"); } return { text, format, variant, offsets: { source: off.source, file: off.file, ...(off.note ? { note: off.note } : {}) } }; } /** Which variants have anything on disk to export from, for the UI's row. */ export async function exportableVariants(dir) { const out = []; for (const v of ["sourced", "full"]) { const has = await Promise.all([ stat(path.join(dir, "out", v, "chapters.ffmeta")).then(() => true, () => false), stat(path.join(dir, "out", v, "segments")).then((s) => s.isDirectory(), () => false), ]); if (has.some(Boolean)) out.push(v); } const legacy = await Promise.all([ stat(path.join(dir, "out", "chapters.ffmeta")).then(() => true, () => false), stat(path.join(dir, "out", "segments")).then((s) => s.isDirectory(), () => false), ]); if (legacy.some(Boolean) && !out.includes("sourced")) out.unshift("sourced"); return out; }