// The deck's preview and stills, as umtool asks for them. // // Everything about WHAT the deck says and WHERE it sits is deck.mjs's; how it // is DRAWN is compose-chrome's. This module only decides which schedule a // preview is drawn from and hands it over: // // - the BUILD's schedule (out//schedule.json, `kind: "deck"`) when // there is one and it still lists this cut's entries in this order -- its // durations are probed, so a handover lands where the render puts it; // - else an ESTIMATE from the manifest alone (`estimated: true`), so the // section previews before anything has been built. // // Either way a request's DRAFT -- the On-screen table's unsaved rows -- is // overlaid, so the preview shows what a save would build. // // The preview never renders and never touches the build's project or cache: // compose-chrome's `preview: true` writes out//chrome/deck-preview/. import { readFile, rm } from "node:fs/promises"; import path from "node:path"; import { composeChrome } from "umtool-report-to-video/compose-chrome"; import { selectVariant } from "umtool-report-to-video/build-video"; import { createPostChannelResolver, resolvePostLinks } from "umtool-report-to-video/post-links"; import { attachPosts, clipDay, deckGeometry, deckText, estimateSchedule, footageMoves, postHolds, postSchedule, postWindows, resolveDeck, roundPosts, } from "umtool-report-to-video/deck"; import { claimOf, roundStamps, stampSchedule } from "umtool-report-to-video/factcheck"; import { channelsDirFor, cuePathFor, hasShadowChannels, manifestPath, readCues, } from "../projects/report.mjs"; import { normalizePostPatches, splitOnscreenRow } from "./manifest.mjs"; import { deckPreviewDir, feedPreviewDir, postsPreviewDir, stampPreviewDir } from "./serve.mjs"; import { ensureWriteDir } from "./storage.mjs"; /** The schedule document deck.mjs defines, built or estimated. */ /** @typedef {ReturnType} DeckSchedule */ /** * A draft from the client, normalised: entry id → `{title?, subtitle?}` or * null. Same rule as updateOnscreen -- a row REPLACES the entry's whole * `onscreen` -- so the preview of a draft is the build of its save. Throws, * naming the id, on a value the writer would refuse. A row's `claim` is not * text: it is read by normalizeClaimsDraft, and dropped here. * * @param {unknown} draft * @returns {Map} */ export function normalizeDraft(draft) { const out = new Map(); for (const [id, row] of draftRows(draft)) out.set(id, row.onscreen); return out; } /** * The fact-check claims a draft's rows carry, normalised as updateOnscreen * stores them: entry id → `{ id, verdict }`, or null to remove it. A row * without a `claim` key is not in the map -- its entry keeps the claim it has. * * @param {unknown} draft * @returns {Map} */ export function normalizeClaimsDraft(draft) { const out = new Map(); for (const [id, row] of draftRows(draft)) if (row.claim !== undefined) out.set(id, row.claim); return out; } /** A draft's rows through the writer's own split, each refusal naming its id. */ function draftRows(draft) { /** @type {Array<[string, ReturnType]>} */ const rows = []; if (draft === undefined || draft === null) return rows; if (typeof draft !== "object" || Array.isArray(draft)) { throw new Error("draft must be an object of entry id → { title, subtitle } or null"); } for (const [id, v] of Object.entries(draft)) { try { rows.push([id, splitOnscreenRow(v)]); } catch (e) { throw new Error(`${id}: ${e instanceof Error ? e.message : String(e)}`); } } return rows; } /** * Each entry's source metadata, aligned with the cut's timeline: what the * auto subtitle needs (`{ title, uploadDate, channel }`) or null. * * From the archive's cue files -- the same documents, through the same * memoised reader, that give the clip bench its source title and upload date. * The build reads the fetched file's own metadata instead, which is why an * estimate's subtitle can differ from the built one where the two disagree. * * @param {string} dir the project directory * @param {Record} manifest the WHOLE manifest (channels, provenance) * @param {Array>} entries the cut's timeline */ export async function deckMetas(dir, manifest, entries) { const channelsDir = channelsDirFor(dir, manifest, { shadowExists: await hasShadowChannels(dir) }); const reads = new Map(); return Promise.all( entries.map(async (e) => { if (e.type !== "clip" || !e.video) return null; const file = cuePathFor(manifest, e, channelsDir); if (!file) return null; if (!reads.has(file)) reads.set(file, readCues(file).catch(() => null)); const doc = await reads.get(file); return doc ? { title: doc.title ?? null, uploadDate: doc.uploadDate ?? null, channel: doc.channel ?? null, // What the deck's QR links under `qr.links: "original"`. webpageUrl: doc.webpageUrl ?? null, } : null; }), ); } /** * Is this build schedule still the cut's? Same entries, same order. A clip * added, dropped or moved since the build makes every later start wrong, and * then the estimate is the better answer. */ export function scheduleMatches(schedule, entries) { if (schedule?.kind !== "deck" || !Array.isArray(schedule.segments)) return false; if (schedule.segments.length !== entries.length) return false; return schedule.segments.every((s, i) => s.id === entries[i].id); } /** * The schedule a preview draws. Pure: the caller reads the files. * * With a build schedule, its timings and QRs are kept and every segment's * title and subtitle are re-derived with deckText -- the function the build * used -- from the manifest as it is NOW with the draft applied. Not only the * drafted rows: a row saved since the build would otherwise preview as the * text the build drew. One exception, toward the build: a clip with no * subtitle override and no cue file to read keeps the build's auto subtitle, * which came from the fetched file's own metadata. * * The posts are placed again the same way, with postSchedule over the * build's segments: an override or a hide saved since the build moves them, * and the build's `posts` would show where they were. * * So are the HOLDS (`posts.hold` on a clip that carries posts) and the * footage's moves: a hold is part of its segment's length in the cut, so a * post moved to another clip, a hide, a changed `posts.hold`, or a build that * predates holds all move every later start. The build's probed length of a * segment is its `duration` less the hold it was built with; each segment * gains the difference between the hold it has now and that one, and every * later start (and the total) moves by the sum before it. A build whose holds * are still the manifest's is kept to the millisecond. * * Without a build schedule the draft is applied to the entries and the whole * cut is estimated. * * `postsDraft` (post id → `{attachTo?, hide?}`, the shape PUT * /api/report/posts takes) is applied to the manifest's posts in both cases. * * `claimsDraft` (entry id → `{id, verdict}` or null, normalizeClaimsDraft's) * is applied to the entries' fact-check claims in both cases, and the build's * stamps are placed again from them over its segments (factcheck.mjs * stampSchedule), as the posts are: a claim saved or drafted since the build * moves the stamps and the tally. * * @param {{ variantManifest: Record, built: Record | null, * draft: Map, * metas: Array | null>, * postsDraft?: Record, * claimsDraft?: Map }} args * @returns {DeckSchedule} */ export function previewSchedule({ variantManifest, built, draft, metas, postsDraft = {}, claimsDraft = new Map() }) { const entries = variantManifest.timeline ?? []; const posts = applyPostsDraft(variantManifest.posts ?? [], postsDraft); const patched = (e) => { let out = e; if (draft.has(e.id)) { const v = draft.get(e.id); const { onscreen: _o, ...rest } = out; out = v ? { ...rest, onscreen: v } : rest; } if (claimsDraft.has(e.id)) { const c = claimsDraft.get(e.id); const { claim: _c, ...rest } = out; out = c ? { ...rest, claim: c } : rest; } return out; }; if (built && scheduleMatches(built, entries)) { const render = variantManifest.render ?? {}; const deck = resolveDeck(render); const provenance = variantManifest.provenance ?? {}; const patchedEntries = entries.map(patched); const { posts: _builtPosts, moves: _builtMoves, layout: _builtLayout, factcheck: _builtFactcheck, ...rest } = built; const round = (v) => Math.round(v * 1000) / 1000; const holds = deck.posts.show ? postHolds({ posts, entries: patchedEntries, metas, render }) : new Map(); let shift = 0; const segments = built.segments.map((s) => { const hold = holds.get(s.id) ?? 0; const delta = hold - (s.hold ?? 0); const start = s.start + shift; const duration = s.duration + delta; shift += delta; const { hold: _h, ...bare } = s; return { ...bare, start: round(start), duration: round(duration), end: round(start + duration), ...(hold > 0 ? { hold: round(hold) } : {}), }; }); const total = round(built.total + shift); const placed = deck.posts.show ? postSchedule({ posts, entries: patchedEntries, metas, segments, D: built.transition, total, render, provenance }) : []; // The layout as it is NOW: a feed (no holds, no moves) only with posts to draw. const feed = placed.length > 0 && deck.posts.layout === "feed"; const moves = placed.length && !feed ? footageMoves({ posts: placed, segments, render }) : []; // The claims as they are NOW, stamped over the build's segments. const claimed = segments.map((s, i) => { const { claim: _c, ...bare } = s; const c = claimOf(patchedEntries[i]); return c ? { ...bare, claim: c } : bare; }); const stamps = stampSchedule({ segments: claimed, D: built.transition, total, render }); return { ...rest, ...(feed ? { layout: "feed" } : {}), total, segments: claimed.map((s, i) => { const e = patchedEntries[i]; const meta = metas[i] ?? null; const { title, subtitle } = deckText(e, meta, provenance, deck, built.multiChannel); const keepBuilt = e.type === "clip" && e.onscreen?.subtitle === undefined && !meta; return { ...s, title, subtitle: keepBuilt ? s.subtitle : subtitle }; }), ...(stamps.length ? { factcheck: { stamps: roundStamps(stamps) } } : {}), ...(placed.length ? { posts: roundPosts(placed) } : {}), ...(moves.length ? { moves: moves.map((m) => ({ ...m, at: round(m.at), segmentAt: round(m.segmentAt) })) } : {}), }; } return estimateSchedule({ ...variantManifest, posts, timeline: entries.map(patched) }, { metas }); } /** * The manifest's posts with unsaved patches on top, by updatePosts' rule: * `attachTo: null` and `hide: false` remove the key. Pure; ids the draft names * that are not posts are ignored here (the writer is what refuses them). * * @param {Array>} posts * @param {Record} draft */ export function applyPostsDraft(posts, draft = {}) { if (!draft || !Object.keys(draft).length) return posts; return posts.map((p) => { const d = draft[p.id]; if (!d) return p; const out = { ...p }; if ("attachTo" in d) { if (d.attachTo) out.attachTo = d.attachTo; else delete out.attachTo; } if ("hide" in d) { if (d.hide) out.hide = true; else delete out.hide; } return out; }); } /** * A posts draft from the client, normalised the writer's way; an absent or * empty draft is `{}`. * * @param {unknown} draft */ export function normalizePostsDraft(draft) { if (draft === undefined || draft === null) return {}; if (typeof draft === "object" && !Array.isArray(draft) && !Object.keys(draft).length) return {}; return normalizePostPatches(draft); } /** * What a person reads off a clip in a "rides on" select: the deck title when * there is one, else the quote, else the stream's own title. * * @param {Record} entry * @param {Record | null} meta */ export function clipLabel(entry, meta) { return String(entry.onscreen?.title ?? entry.quote ?? entry.title ?? meta?.title ?? "").trim(); } /** * The posts table's rows, for one cut. Pure. * * Every post the manifest carries, hidden or not, with: * - `auto`: the clip the date rule picks with no override and not hidden -- * attachPosts on the post stripped of `attachTo` and `hide`, so a hidden * post still says where it WOULD ride; * - `effective`: where it rides as saved (null when hidden); * - `timing`: its slot in `schedule.posts` when a schedule places it. * * `clips` is every clip of the cut, in order, for the override select. * * @param {{ variantManifest: Record, metas: Array | null>, * schedule?: Record | null }} args */ export function postRows({ variantManifest, metas, schedule = null }) { const entries = variantManifest.timeline ?? []; const posts = Array.isArray(variantManifest.posts) ? variantManifest.posts : []; const labelOf = new Map(entries.map((e, i) => [e.id, clipLabel(e, metas[i] ?? null)])); const bare = posts.map(({ attachTo: _a, hide: _h, ...p }) => p); const auto = new Map(attachPosts({ posts: bare, entries, metas }).map((a) => [a.id, a])); const effective = new Map(attachPosts({ posts, entries, metas }).map((a) => [a.id, a])); const timing = new Map((schedule?.posts ?? []).map((p) => [p.id, p])); const where = (a) => (a ? { entryId: a.entryId, rule: a.rule, clipDay: a.clipDay, label: labelOf.get(a.entryId) ?? "" } : null); return { posts: posts.map((p) => { const t = timing.get(p.id); return { id: p.id, platform: p.platform, author: p.author ?? "", handle: p.handle ?? "", date: p.date, text: p.text, url: p.url, attachTo: p.attachTo ?? null, hide: p.hide === true, auto: where(auto.get(p.id)), effective: p.hide ? null : where(effective.get(p.id)), // A popup post's appear and leave, or a feed post's tick-in (`in`). timing: t ? { segment: t.segment, slot: t.slot, of: t.of, ...("in" in t ? { in: t.in } : { appear: t.appear, out: t.out }) } : null, }; }), clips: entries .map((e, i) => ({ e, i })) .filter(({ e }) => e.type === "clip") .map(({ e, i }) => ({ id: e.id, label: labelOf.get(e.id) ?? "", day: clipDay(e, metas[i] ?? null) })), }; } /** out//schedule.json when it is the deck's, else null. */ async function readBuiltSchedule(dir, variant) { try { const doc = JSON.parse(await readFile(path.join(dir, "out", variant, "schedule.json"), "utf8")); return doc?.kind === "deck" ? doc : null; } catch { return null; } } /** * The cut, the schedule a preview of it draws, and the render block the * geometry comes from. * * @param {{ dir: string }} project * @param {Record} manifest * @param {string} variant * @param {Map} draft */ export async function scheduleForPreview( project, manifest, variant, draft = new Map(), postsDraft = {}, { resolver = previewPostResolver(), claimsDraft = new Map() } = {}, ) { const selected = selectVariant(manifest, variant); const entries = selected.timeline ?? []; const posts = Array.isArray(selected.posts) ? selected.posts : []; const [built, metas, linked] = await Promise.all([ readBuiltSchedule(project.dir, variant), deckMetas(project.dir, manifest, entries), // The posts as the draft leaves them, so a post the draft un-hides is // linked too (a hidden one is not looked up). resolvePostLinks({ posts: applyPostsDraft(posts, postsDraft), provenance: selected.provenance ?? {}, render: selected.render ?? {}, resolver, deadlineMs: PREVIEW_LINK_DEADLINE_MS, }), ]); // The archive channel each post is kept in, found as the build finds it // (post-links.mjs, the same cache on disk), so the preview's QRs are the // build's. Set on this request's copy of the posts only, never written. const found = new Map(linked.posts.filter((p) => p.siteChannel).map((p) => [p.id, p.siteChannel])); const variantManifest = found.size ? { ...selected, posts: posts.map((p) => (!p.siteChannel && found.has(p.id) ? { ...p, siteChannel: found.get(p.id) } : p)) } : selected; return { variantManifest, metas, schedule: previewSchedule({ variantManifest, built, draft, metas, postsDraft, claimsDraft }) }; } /** The most a preview request waits on the archive for its posts' links, all of them together. */ const PREVIEW_LINK_DEADLINE_MS = 10000; /** * The preview's post-link resolver: one for the server's life, so its memory * cache spans requests. Each archive request may take eight seconds; a URL * that failed is not asked again for ten minutes, and a post missing from the * cached archive is looked for in a fresh copy at most that often. A request * waits at most PREVIEW_LINK_DEADLINE_MS for all its posts; one not found by * then links the original, as the build's would when the archive does not * answer, and its lookup finishes in the background for the next request. */ let previewResolver = null; function previewPostResolver() { previewResolver ??= createPostChannelResolver({ timeoutMs: 8000, refreshAfterMs: 10 * 60 * 1000 }); return previewResolver; } // compose-chrome writes one directory per cut. Two requests composing into it // at once would interleave their writes, so they queue per directory. /** @type {Map>} */ const queues = new Map(); /** * @template T * @param {string} key * @param {() => Promise} fn * @returns {Promise} */ function serialised(key, fn) { const prev = queues.get(key) ?? Promise.resolve(); const run = prev.then(fn, fn); const tail = run.then( () => undefined, () => undefined, ); queues.set(key, tail); tail.then(() => { if (queues.get(key) === tail) queues.delete(key); }); return run; } /** * Compose the preview project for a cut from `schedule`. No render. * * compose-chrome decides where the project goes; the files route serves * deckPreviewDir. If the two ever disagree the iframe would load nothing and * say nothing, so a mismatch is an error here instead. * * @param {{ dir: string }} project * @param {string} variant * @param {Record} schedule */ export async function composeDeckPreview(project, variant, schedule) { const outDir = path.join(project.dir, "out", variant); const want = deckPreviewDir(project.dir, variant); return serialised(want, async () => { /** @type {Record} */ const args = { manifestPath: manifestPath(project.dir), outDir, variant, region: "deck", schedule, preview: true }; const r = await composeChrome(/** @type {any} */ (args)); if (r?.projDir && path.resolve(r.projDir) !== path.resolve(want)) { throw new Error(`compose-chrome wrote the preview to ${r.projDir}, not ${want}`); } return r; }); } /** * Compose the PREVIEW of the posts FEED (a schedule with `layout: "feed"`): * compose-chrome's `feed` region, one project for the whole cut under * out//chrome/feed-preview/. No render. `compose` is injectable for * the unit test; the routes never pass it. * * @param {{ dir: string }} project * @param {string} variant * @param {Record} schedule * @param {{ compose?: (args: Record) => Promise }} [opts] */ export async function composeFeedPreview(project, variant, schedule, { compose = composeChrome } = {}) { const outDir = path.join(project.dir, "out", variant); const want = feedPreviewDir(project.dir, variant); return serialised(want, async () => { /** @type {Record} */ const args = { manifestPath: manifestPath(project.dir), outDir, variant, region: "feed", schedule, preview: true }; const r = await compose(/** @type {any} */ (args)); if (r?.projDir && path.resolve(r.projDir) !== path.resolve(want)) { throw new Error(`compose-chrome wrote the feed preview to ${r.projDir}, not ${want}`); } return r; }); } /** * Compose the PREVIEW of the fact-check stamps (a schedule whose `factcheck` * stamps a claim): compose-chrome's `stamp` region, one project for the whole * cut under out//chrome/stamp-preview/. No render. `compose` is * injectable for the unit test; the routes never pass it. * * @param {{ dir: string }} project * @param {string} variant * @param {Record} schedule * @param {{ compose?: (args: Record) => Promise }} [opts] */ export async function composeStampPreview(project, variant, schedule, { compose = composeChrome } = {}) { const outDir = path.join(project.dir, "out", variant); const want = stampPreviewDir(project.dir, variant); return serialised(want, async () => { /** @type {Record} */ const args = { manifestPath: manifestPath(project.dir), outDir, variant, region: "stamp", schedule, preview: true }; const r = await compose(/** @type {any} */ (args)); if (r?.projDir && path.resolve(r.projDir) !== path.resolve(want)) { throw new Error(`compose-chrome wrote the stamps preview to ${r.projDir}, not ${want}`); } return r; }); } /** * The box each built segment's footage was framed into, by entry id: the * `framing` its cut record names (build-video's segmentFraming), else -- a * segment built before records named it, or none built yet -- the deck's own * box. The preview carries a backdrop built for one layout into the other's * box (the feed switched on since the build). * * @param {{ dir: string }} project * @param {string} variant * @param {Array<{ id: string }>} entries * @param {Record} render * @returns {Promise>} */ export async function segmentBoxes(project, variant, entries, render) { const segs = path.join(project.dir, "out", variant, "segments"); const deckBox = deckGeometry(render).footage; const out = /** @type {Record} */ ({}); await Promise.all(entries.map(async (e) => { // An id names a file here: only a plain one is read. if (!/^[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(String(e.id)) || String(e.id).includes("..")) { out[e.id] = deckBox; return; } const rec = await readFile(path.join(segs, `${e.id}.cut.json`), "utf8").then(JSON.parse, () => null); const box = rec?.framing?.box; out[e.id] = box && [box.x, box.y, box.width, box.height].every(Number.isFinite) ? box : deckBox; })); return out; } /** * Compose the PREVIEW of the posts region for one window. No render. * * The posts region is compose-chrome's (`region: "posts"`, one project per * window under out//chrome/posts-preview-/), and this is the * ONE place umtool calls it: if its arguments change, they change here. * `compose` is injectable for the unit test; the routes never pass it. * * @param {{ dir: string }} project * @param {string} variant * @param {Record} schedule * @param {{ segment: string, from: number, to: number }} window * @param {{ compose?: (args: Record) => Promise }} [opts] */ export async function composePostsPreview(project, variant, schedule, window, { compose = composeChrome } = {}) { const outDir = path.join(project.dir, "out", variant); const want = postsPreviewDir(project.dir, variant, window.segment); return serialised(want, async () => { /** @type {Record} */ const args = { manifestPath: manifestPath(project.dir), outDir, variant, region: "posts", window: { segment: window.segment, from: window.from, to: window.to }, schedule, preview: true, }; const r = await compose(/** @type {any} */ (args)); if (r?.projDir && path.resolve(r.projDir) !== path.resolve(want)) { throw new Error(`compose-chrome wrote the posts preview to ${r.projDir}, not ${want}`); } return r; }); } /** * Every posts window of a schedule, composed for the preview. A window that * fails says why beside it rather than failing the deck's preview: the deck * is drawn either way, and a posts region that is not there yet is a fact * about the pipeline, not about this cut. * * @param {{ dir: string }} project * @param {string} variant * @param {Record} schedule * @param {{ compose?: (args: Record) => Promise }} [opts] * @returns {Promise>} */ export async function composePostsPreviews(project, variant, schedule, opts = {}) { const out = []; for (const w of postWindows(schedule)) { try { await composePostsPreview(project, variant, schedule, w, opts); out.push({ ...w, ok: true }); } catch (e) { out.push({ ...w, ok: false, error: e instanceof Error ? e.message : String(e) }); } } return out; } /** * A true still of the deck at `t`: the composition, screenshotted by the * render browser, as PNG bytes. Composed into the preview project (never the * build's), into a scratch file that is removed once read. * * @param {{ dir: string }} project * @param {string} variant * @param {Record} schedule * @param {number} t seconds in the cut's clock * @returns {Promise} */ export async function deckStill(project, variant, schedule, t) { const outDir = path.join(project.dir, "out", variant); const want = deckPreviewDir(project.dir, variant); const stills = path.join(outDir, "chrome", "deck-stills"); return serialised(want, async () => { await ensureWriteDir(stills); // through ensureOutDir: out/ may be a link to the media root const png = path.join(stills, `still-${process.pid}-${Math.random().toString(36).slice(2, 8)}.png`); try { /** @type {Record} */ const args = { manifestPath: manifestPath(project.dir), outDir, variant, region: "deck", schedule, preview: true, still: t, png, }; await composeChrome(/** @type {any} */ (args)); return await readFile(png); } finally { await rm(png, { force: true }); } }); } /** The moment a still of one entry shows: the middle of its segment. */ export function stillTimeOf(schedule, id) { const s = (schedule.segments ?? []).find((x) => x.id === id); return s ? Math.round((s.start + s.duration / 2) * 1000) / 1000 : null; }