// A moment on a rendered cut -> the entry playing there. // // A timed note is a second on a FILE (`out/sourced/.mp4`, a take's // `takes//preview.mp4`). What makes it worth an agent's time is what that // second resolves to: the entry on screen, its onscreen title and quote, and // the source second with a link into the archive. That join is the build's // `schedule.json` (build-video.mjs writeChromeSchedule: `out//`, or a // take's own `takes//out//`), which says where each entry starts // in the cut. // // THE SCHEDULE MATCHES THE BUILD'S OUTPUT, and a take's preview.mp4 is that // output copied: measured on every take of candace/polemic-israel, the // preview's duration equals the schedule's `total` to the millisecond. So a // mark is exact when the file it was made on is as long as the schedule says, // and APPROXIMATE (`approx: true`) when it is not -- a different preset, a // trimmed preview -- or when the schedule is an estimate, or the second falls // in a held frame past the clip's own source. // // The resolution is written INTO the note at write time (`anchor.resolved`): // a later rebuild moves entries around, and the agent reading the note must // see what was on screen when the operator pressed the key. import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { DEFAULT_VARIANT } from "umtool-report-to-video/build-video"; import { channelFor } from "../projects/report.mjs"; const TAKE_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; const APPROX_TOLERANCE = 0.25; /** * Where a moment's file sits: in a take (`takes//…`) and/or a variant's * output dir (`…out//…`). Pure. * * @param {string} rel project-relative, `/`-separated */ export function momentFileInfo(rel) { const parts = String(rel ?? "").split("/"); let take = null; let i = 0; if (parts[0] === "takes" && TAKE_RE.test(parts[1] ?? "")) { take = parts[1]; i = 2; } const variant = parts[i] === "out" && parts.length > i + 2 && TAKE_RE.test(parts[i + 1]) ? parts[i + 1] : null; return { take, variant }; } async function readJson(file) { try { return JSON.parse(await readFile(/* turbopackIgnore: true */ file, "utf8")); } catch { return null; } } /** * The schedule and manifest a moment on `rel` resolves against, or null when * there is no schedule. A take's own manifest wins over the project's. * * @param {string} projectDir * @param {string} rel */ export async function scheduleForFile(projectDir, rel) { const { take, variant } = momentFileInfo(rel); const base = take ? path.join(/* turbopackIgnore: true */ projectDir, "takes", take) : projectDir; let variants = []; if (variant) variants = [variant]; else { const dirs = await readdir(/* turbopackIgnore: true */ path.join(/* turbopackIgnore: true */ base, "out"), { withFileTypes: true }).catch(() => []); variants = dirs.filter((d) => d.isDirectory() || d.isSymbolicLink()).map((d) => d.name); // A deliverable names its cut (`-full.mp4`; the default cut is // `.mp4`): that variant's schedule first, then the default's. const named = (v) => path.basename(String(rel)).endsWith(`-${v}.mp4`); const rank = (v) => (named(v) ? 0 : v === DEFAULT_VARIANT ? 1 : 2); variants.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); } for (const v of variants) { const file = path.join(/* turbopackIgnore: true */ base, "out", v, "schedule.json"); const schedule = await readJson(file); if (!schedule || !Array.isArray(schedule.segments)) continue; const manifest = (take ? await readJson(path.join(/* turbopackIgnore: true */ base, "video.manifest.json")) : null) ?? (await readJson(path.join(/* turbopackIgnore: true */ projectDir, "video.manifest.json"))); const st = await stat(/* turbopackIgnore: true */ file).catch(() => null); return { schedule, manifest, variant: v, take, scheduleRel: path.relative(/* turbopackIgnore: true */ projectDir, file).split(path.sep).join("/"), scheduleMtimeMs: st ? Math.round(st.mtimeMs) : null, }; } return null; } /** * The entry playing at `t` seconds of a cut. Pure. * * During a crossfade both segments are on screen; the incoming one is taken * from half-way through it. `duration` is the file's own (the player knows it): * when it differs from the schedule's total the result is `approx`. * * @param {{ schedule: any, manifest: any, variant?: string | null, t: number, duration?: number | null }} args * @returns {{ entry: string | null, title?: string, quote?: string, channel?: string, video?: string, sourceT?: number, url?: string, approx?: boolean }} */ export function resolveMoment({ schedule, manifest, variant = null, t, duration = null }) { const segs = Array.isArray(schedule?.segments) ? schedule.segments : []; if (!segs.length || !Number.isFinite(t)) return { entry: null }; const D = Number(schedule.transition) || 0; let i = 0; for (let k = 0; k < segs.length; k += 1) if (Number(segs[k].start) <= t - D / 2) i = k; const seg = segs[i]; const out = { entry: String(seg.id) }; let approx = schedule.estimated === true; if (Number.isFinite(duration) && Number.isFinite(Number(schedule.total)) && Math.abs(duration - Number(schedule.total)) > APPROX_TOLERANCE) { approx = true; } const e = (manifest?.timeline ?? []).find((x) => x?.id === seg.id && (!x.variant || !variant || x.variant === variant)) ?? null; const title = seg.title ?? e?.onscreen?.title ?? e?.title ?? e?.heading ?? null; if (title) out.title = String(title); if (e?.quote) out.quote = String(e.quote); if (e?.type === "clip") { const from = Number(e.cutStart ?? e.start); const to = Number(e.cutEnd ?? e.end); const into = Math.max(0, t - Number(seg.start)); if (Number.isFinite(from) && Number.isFinite(to)) { if (from + into > to + 0.05) approx = true; // a held frame past the clip's own source out.sourceT = Number(Math.min(to, from + into).toFixed(2)); const channel = channelFor(manifest, e); if (channel) out.channel = channel; out.video = String(e.video); const origin = manifest?.provenance?.siteOrigin; if (origin && channel) { out.url = `${origin}/?v=${encodeURIComponent(`${channel}/${e.video}`)}&t=${Math.floor(out.sourceT)}`; } } } if (approx) out.approx = true; return out; } /** * What a moment on `rel` at `t` resolves to, or `{ entry: null }` with no schedule. * * @param {string} projectDir * @param {string} rel * @param {number} t * @param {number | null} [duration] */ export async function resolveMomentOnFile(projectDir, rel, t, duration = null) { const s = await scheduleForFile(projectDir, rel); if (!s) return { entry: null, schedule: null }; return { ...resolveMoment({ ...s, t, duration }), schedule: s.scheduleRel }; }