// The bottom deck: one persistent on-screen panel for a whole report cut. // // `render.chrome = { engine: "hyperframes", layout: "deck", deck: {...} }` turns // it on. The footage is scaled into a box above the deck, and ONE HyperFrames // composition -- a pip timeline, the clip's authored title, its source-and-date // subtitle and its QR -- is overlaid across the whole concat. // // Everything in this file is PURE: geometry, the schedule arithmetic, the // choreography times, validation, the cache key. Three programs read it -- the // build (segment framing, schedule.json), the composition (where things are and // when they move) and umtool (validation on write, the estimated schedule its // preview draws before any build exists) -- and they agree because none of them // has a copy of any of it. // // No ffmpeg, no fs, no network. If a function here needs one, it belongs in // build-video.mjs or compose-chrome.mjs instead. import { createHash } from "node:crypto"; import { attributionParts, deckSubtitle } from "./attribution.mjs"; import { claimOf, originalUrlAt, resolveFactcheck, roundStamps, stampSchedule, validateFactcheck, } from "./factcheck.mjs"; import { threadOf, threadSchedule, threadsOn, validateThreads } from "./threads.mjs"; import { flipSchedule, flipsOn, validateFlips } from "./flips.mjs"; /** Is the left panel on: the thread rail (threads.mjs) or the flips panel (flips.mjs) -- one region, one or the other. */ export const railOn = (render) => threadsOn(render) || flipsOn(render); /** The renderer version, pinned. It is part of the cache key: a new renderer is new frames. */ export const HYPERFRAMES_PKG_DEFAULT = "hyperframes@0.8.24"; /** * Every deck setting and its default. A manifest names only what it changes; * `resolveDeck` fills the rest. Each default is a setting, not a constant -- * the alternative in each comment is a supported value. */ export const DECK_DEFAULTS = Object.freeze({ height: 190, footageScale: 0.82, background: "panel", // | "flush" pip: Object.freeze({ spacing: "even", size: 6, activeSize: 12 }), // spacing | "time" title: Object.freeze({ size: 54, maxChars: 48 }), subtitle: Object.freeze({ parts: "auto", dateFormat: "long" }), // dateFormat | "iso" // `links` is where a clip's QR goes: "site" (the archive's page at the // clip's start) or "original" (the record's own page -- YouTube at the // second, a Rumble page, an X post). An entry's `citeUrl` wins either way. qr: Object.freeze({ show: true, size: 150, links: "site" }), overCards: "hide", // | "show" motion: Object.freeze({ out: 0.3, in: 0.45, pip: 0.7 }), // The manifest's `posts`, drawn as cards over the footage at the end of the // clip each one is attached to. position | "top-left". // `hold` freezes the last frame of a clip that carries posts for that long, // so the last of them can be read before the next clip; `shift` moves the // footage away from the posts column (scaled to `scale`, over `seconds`) // while they are up, or is `false`. // `layout` "popup" draws them as above; "feed" keeps them in a column of // their own beside the footage for the whole cut (feedGeometry), each one // ticking in as its clip starts -- no hold, no move. // `links` is where a post's QR goes: "archive" (the post's page on the // archive the manifest names, `provenance.siteOrigin`, which survives the // post or the platform going away, and links on to the original) or // "original" (the bsky.app / x.com link itself). // `at` is when a popup's posts come up: "end" (the last `seconds` of the // clip, as above) or "start" (from the clip's start, sharing the whole clip // in the order the manifest lists them -- the post beside the words it goes // with, so no hold is needed). `shotMaxHeight` caps a post's screenshot in // px; null keeps it to the height of a full card of words (`maxLines`). posts: Object.freeze({ show: true, layout: "popup", seconds: 4, hold: 2.5, position: "top-right", width: 600, qrSize: 120, maxLines: 7, inset: 24, shift: Object.freeze({ scale: 0.86, seconds: 0.6 }), links: "archive", at: "end", shotMaxHeight: null, }), }); /** Where a clip's deck QR links (`qr.links`): the archive at the clip, or the original. */ export const QR_LINKS = Object.freeze(["site", "original"]); /** Subtitle tokens a `subtitle.parts` array may name. "auto" picks from these. */ export const SUBTITLE_TOKENS = Object.freeze(["channel", "title", "date", "clock"]); /** * Segment types the deck slides away over when `overCards: "hide"`. A * `teaser` is one too, and the deck slides away over it whatever `overCards` * says: it is a full-frame finale, never framed into the footage box. */ export const CARD_TYPES = Object.freeze(["card", "scroll", "chart", "ledger", "teaser"]); /** Does this render block ask for the deck? Absent, nothing in this file runs. */ export function deckOn(render) { const c = render?.chrome; return !!c && c.engine === "hyperframes" && c.layout === "deck"; } /** The deck settings with every default filled in. */ export function resolveDeck(render) { const d = render?.chrome?.deck ?? {}; const merged = { ...DECK_DEFAULTS }; for (const [k, v] of Object.entries(d)) { const base = DECK_DEFAULTS[k]; merged[k] = base && typeof base === "object" && v && typeof v === "object" && !Array.isArray(v) ? { ...base, ...v } : v; } // `posts.shift` is the one setting two levels down: `false` turns it off, // an object fills from the default's. const sh = d.posts?.shift; if (sh !== undefined && sh !== null) { merged.posts = { ...merged.posts, shift: sh === false ? false : { ...DECK_DEFAULTS.posts.shift, ...sh } }; } return merged; } // --------------------------------------------------------------------------- // Validation. ONE validator: umtool's writer refuses with it and the build // refuses with it, so a manifest umtool accepted is one the build accepts. // --------------------------------------------------------------------------- const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v); const numIn = (v, lo, hi) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi; function unknownKeys(obj, allowed, where, errors) { for (const k of Object.keys(obj)) { if (!allowed.includes(k)) errors.push(`${where}.${k} is not a deck setting`); } } /** * Every reason this `render.chrome` cannot be built, as sentences. Empty means * it can. `render` is the rest of the render block: the deck refuses a rail or * the legacy `chromeEngine` beside it, and checks the footage fits the frame. * * Unknown keys are refused, not ignored -- `footageScle` silently meaning the * default is the bug this catches. * * @returns {string[]} */ export function validateChrome(chrome, render = {}) { const errors = []; if (chrome === undefined || chrome === null) return errors; if (!isObj(chrome)) return ["render.chrome must be an object"]; unknownKeys(chrome, ["engine", "layout", "deck", "factcheck", "threads", "flips"], "render.chrome", errors); if (chrome.engine !== "hyperframes") errors.push('render.chrome.engine must be "hyperframes"'); if (chrome.layout !== "deck") errors.push('render.chrome.layout must be "deck"'); if (render.rail) errors.push("render.chrome (the deck) and render.rail cannot both be set"); if (render.chromeEngine !== undefined) { errors.push("render.chrome replaces render.chromeEngine — remove chromeEngine"); } // The end fade is a render key the deck's cut is finished with; checked // here too, so a writer that validates the deck refuses a bad one. errors.push(...validateEndFade(render)); // The fact-check stamps and tally (factcheck.mjs): drawn only by the deck. errors.push(...validateFactcheck(chrome.factcheck)); // The thread rail (threads.mjs): the deck's layout only, beside the footage. errors.push(...validateThreads(chrome.threads)); // The flips panel (flips.mjs): the same region as the rail, so not both. errors.push(...validateFlips(chrome.flips)); if (threadsOn({ chrome }) && flipsOn({ chrome })) errors.push("render.chrome.threads and render.chrome.flips share the left panel: set one"); const d = chrome.deck ?? {}; if (!isObj(d)) return [...errors, "render.chrome.deck must be an object"]; const w = "render.chrome.deck"; unknownKeys(d, Object.keys(DECK_DEFAULTS), w, errors); if (d.height !== undefined && !(Number.isInteger(d.height) && numIn(d.height, 120, 400))) { errors.push(`${w}.height must be a whole number of pixels from 120 to 400`); } if (d.footageScale !== undefined && !numIn(d.footageScale, 0.5, 1)) { errors.push(`${w}.footageScale must be from 0.5 to 1`); } if (d.background !== undefined && !["panel", "flush"].includes(d.background)) { errors.push(`${w}.background must be "panel" or "flush"`); } if (d.overCards !== undefined && !["hide", "show"].includes(d.overCards)) { errors.push(`${w}.overCards must be "hide" or "show"`); } const sub = (key, allowed, check) => { if (d[key] === undefined) return; if (!isObj(d[key])) { errors.push(`${w}.${key} must be an object`); return; } unknownKeys(d[key], allowed, `${w}.${key}`, errors); check(d[key]); }; sub("pip", ["spacing", "size", "activeSize"], (p) => { if (p.spacing !== undefined && !["even", "time"].includes(p.spacing)) { errors.push(`${w}.pip.spacing must be "even" or "time"`); } if (p.size !== undefined && !numIn(p.size, 2, 24)) errors.push(`${w}.pip.size must be from 2 to 24`); if (p.activeSize !== undefined && !numIn(p.activeSize, 2, 40)) { errors.push(`${w}.pip.activeSize must be from 2 to 40`); } const size = p.size ?? DECK_DEFAULTS.pip.size; const active = p.activeSize ?? DECK_DEFAULTS.pip.activeSize; if (numIn(size, 2, 24) && numIn(active, 2, 40) && active < size) { errors.push(`${w}.pip.activeSize must be at least pip.size`); } }); sub("title", ["size", "maxChars"], (t) => { if (t.size !== undefined && !numIn(t.size, 24, 96)) errors.push(`${w}.title.size must be from 24 to 96`); if (t.maxChars !== undefined && !(Number.isInteger(t.maxChars) && numIn(t.maxChars, 10, 120))) { errors.push(`${w}.title.maxChars must be a whole number from 10 to 120`); } }); sub("subtitle", ["parts", "dateFormat"], (s) => { if (s.parts !== undefined && s.parts !== "auto") { const ok = Array.isArray(s.parts) && s.parts.length > 0 && s.parts.every((p) => SUBTITLE_TOKENS.includes(p)) && new Set(s.parts).size === s.parts.length; if (!ok) { errors.push(`${w}.subtitle.parts must be "auto" or a list of distinct ${SUBTITLE_TOKENS.join("/")}`); } } if (s.dateFormat !== undefined && !["long", "iso"].includes(s.dateFormat)) { errors.push(`${w}.subtitle.dateFormat must be "long" or "iso"`); } }); sub("qr", ["show", "size", "links"], (q) => { if (q.show !== undefined && typeof q.show !== "boolean") errors.push(`${w}.qr.show must be true or false`); if (q.links !== undefined && !QR_LINKS.includes(q.links)) { errors.push(`${w}.qr.links must be ${QR_LINKS.map((l) => `"${l}"`).join(" or ")}`); } if (q.size !== undefined && !numIn(q.size, 80, 380)) errors.push(`${w}.qr.size must be from 80 to 380`); const h = d.height ?? DECK_DEFAULTS.height; const size = q.size ?? DECK_DEFAULTS.qr.size; if (numIn(size, 80, 380) && Number.isInteger(h) && size > h - 20) { errors.push(`${w}.qr.size ${size} does not fit a ${h}px deck (at most ${h - 20})`); } }); sub("posts", ["show", "layout", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift", "links", "at", "shotMaxHeight"], (p) => { if (p.at !== undefined && !POST_AT.includes(p.at)) { errors.push(`${w}.posts.at must be ${POST_AT.map((a) => `"${a}"`).join(" or ")}`); } if (p.shotMaxHeight !== undefined && p.shotMaxHeight !== null && !(Number.isInteger(p.shotMaxHeight) && numIn(p.shotMaxHeight, 120, 1080))) { errors.push(`${w}.posts.shotMaxHeight must be null or a whole number of pixels from 120 to 1080`); } if (p.layout !== undefined && !POST_LAYOUTS.includes(p.layout)) { errors.push(`${w}.posts.layout must be ${POST_LAYOUTS.map((l) => `"${l}"`).join(" or ")}`); } if (p.links !== undefined && !POST_LINKS.includes(p.links)) { errors.push(`${w}.posts.links must be ${POST_LINKS.map((l) => `"${l}"`).join(" or ")}`); } if (p.hold !== undefined && !numIn(p.hold, 0, 10)) errors.push(`${w}.posts.hold must be from 0 to 10 seconds`); if (p.shift !== undefined && p.shift !== false) { if (!isObj(p.shift)) errors.push(`${w}.posts.shift must be false or { scale, seconds }`); else { unknownKeys(p.shift, ["scale", "seconds"], `${w}.posts.shift`, errors); if (p.shift.scale !== undefined && !numIn(p.shift.scale, 0.5, 1)) errors.push(`${w}.posts.shift.scale must be from 0.5 to 1`); if (p.shift.seconds !== undefined && !numIn(p.shift.seconds, 0, 3)) errors.push(`${w}.posts.shift.seconds must be from 0 to 3`); } } if (p.show !== undefined && typeof p.show !== "boolean") errors.push(`${w}.posts.show must be true or false`); if (p.seconds !== undefined && !numIn(p.seconds, 0.5, 10)) errors.push(`${w}.posts.seconds must be from 0.5 to 10`); if (p.position !== undefined && !["top-right", "top-left"].includes(p.position)) { errors.push(`${w}.posts.position must be "top-right" or "top-left"`); } if (p.width !== undefined && !(Number.isInteger(p.width) && numIn(p.width, 320, 900))) { errors.push(`${w}.posts.width must be a whole number of pixels from 320 to 900`); } if (p.qrSize !== undefined && !numIn(p.qrSize, 80, 200)) errors.push(`${w}.posts.qrSize must be from 80 to 200`); if (p.maxLines !== undefined && !(Number.isInteger(p.maxLines) && numIn(p.maxLines, 2, 14))) { errors.push(`${w}.posts.maxLines must be a whole number from 2 to 14`); } if (p.inset !== undefined && !numIn(p.inset, 0, 80)) errors.push(`${w}.posts.inset must be from 0 to 80`); }); sub("motion", ["out", "in", "pip"], (m) => { for (const k of ["out", "in"]) { if (m[k] !== undefined && !numIn(m[k], 0, 2)) errors.push(`${w}.motion.${k} must be from 0 to 2 seconds`); } if (m.pip !== undefined && !numIn(m.pip, 0, 3)) errors.push(`${w}.motion.pip must be from 0 to 3 seconds`); }); // The footage has to fit above the deck. Clamping the scale instead would // make the setting lie about what was drawn. if (!errors.length) { const g = deckGeometry({ ...render, chrome }); // Only a deck that sets `posts` and draws them is held to the column's fit // here; one with posts on the defaults is checked by validatePosts, which // sees the posts. if (d.posts !== undefined && resolveDeck({ chrome }).posts.show) errors.push(...postsFitErrors({ ...render, chrome })); const room = g.H - g.deck.height; if (railOn({ chrome })) { const which = threadsOn({ chrome }) ? "render.chrome.threads" : "render.chrome.flips"; if (resolveDeck({ chrome }).posts.layout === "feed") errors.push(`${which} needs the popup posts, not the feed`); const rail = threadsGeometry({ ...render, chrome }); if (rail.cards.width < 220) { errors.push(`${which} leaves the rail ${rail.cards.width}px wide (at least 220: a smaller footageScale)`); } } if (g.footage.height > room) { const max = Math.floor((room / g.H) * 1000) / 1000; errors.push( `${w}.footageScale ${resolveDeck({ chrome }).footageScale} needs ${g.footage.height}px ` + `but a ${g.deck.height}px deck leaves ${room} (footageScale at most ${max})`, ); } } return errors; } /** `validateChrome`, thrown. The build calls this before it spends a single fetch. */ export function assertChrome(chrome, render = {}) { const errors = validateChrome(chrome, render); if (errors.length) throw new Error(`render.chrome: ${errors.join("; ")}`); } /** * A per-entry `onscreen` value, normalised: trimmed, empty strings dropped, * `null` when nothing is left (which is what a writer stores as "delete it"). * Throws on a shape no writer should store. * * It is nested -- `onscreen.title`, not `title` -- because `entry.title` * already means the STREAM title in headers and chapters. */ export function normalizeOnscreen(v) { if (v === undefined || v === null) return null; if (!isObj(v)) throw new Error("onscreen must be an object with title and/or subtitle"); for (const k of Object.keys(v)) { if (k !== "title" && k !== "subtitle") throw new Error(`onscreen.${k} is not an on-screen field`); } const out = {}; for (const k of ["title", "subtitle"]) { if (v[k] === undefined || v[k] === null) continue; if (typeof v[k] !== "string") throw new Error(`onscreen.${k} must be a string`); const s = v[k].trim(); if (/[\r\n]/.test(s)) throw new Error(`onscreen.${k} must be one line`); if (s.length > 200) throw new Error(`onscreen.${k} is ${s.length} characters (at most 200)`); if (s) out[k] = s; } return Object.keys(out).length ? out : null; } // --------------------------------------------------------------------------- // Geometry. // --------------------------------------------------------------------------- /** Nearest even integer -- yuv420 needs even dimensions, and pad offsets follow. */ export const even = (v) => Math.round(v / 2) * 2; /** * Where the footage and the deck sit in the frame. * * The footage box keeps the FRAME's aspect, is `footageScale` of its width * (evened), and is centred in the area above the deck. For 1920×1080 at 0.82 * with a 190 px deck that is 1574×886 at (173, 2). With the thread rail on * (threads.mjs) the box moves to the frame's right edge, `railGap` in, and the * rail takes the left: 1574×886 at (322, 2). * * @returns {{ W:number, H:number, * footage:{x:number,y:number,width:number,height:number}, * deck:{x:number,y:number,width:number,height:number} }} */ export function deckGeometry(render) { const W = render?.width ?? 1920; const H = render?.height ?? 1080; const deck = resolveDeck(render); const dh = deck.height; const fw = even(W * deck.footageScale); const fh = even((fw * H) / W); const fx = railOn(render) ? W - fw - railGap(render) : Math.floor((W - fw) / 2); return { W, H, footage: { x: fx, y: Math.floor((H - dh - fh) / 2), width: fw, height: fh }, deck: { x: 0, y: H - dh, width: W, height: dh }, }; } /** The air between the frame's edges, the thread rail and the footage. */ export const railGap = (render) => Math.round(24 * ((render?.width ?? 1920) / 1920)); /** * The thread rail's region (threads.mjs, chrome-threads.mjs): the frame's left * side beside the footage, as tall as the footage box, from the frame's edge * to the footage's -- so a lit card's string can reach the picture. `cards` * is where the cards sit inside it (region-local), `railGap` in from its left * and short of the footage. */ export function threadsGeometry(render) { const { footage } = deckGeometry(render); const gap = railGap(render); return { x: 0, y: footage.y, width: footage.x, height: footage.height, cards: { x: gap, y: 0, width: footage.x - 2 * gap, height: footage.height }, }; } /** * Where things sit INSIDE the deck region (region-local pixels). The * composition draws from this; umtool's preview frames the same rect. A * starting layout -- the composition may tune the numbers here, never copy them. * * `tally` is the fact-check tally's verdicts (factcheck.mjs `tallyOf`), or * null for none: with one, the tally takes a block of cells at one end of the * text column (`factcheck.tally.position`) and the text keeps the rest. * * @param {any} render * @param {{ tally?: string[] | null }} [opts] */ export function deckLayout(render, { tally = null } = {}) { const { W, deck: rect } = deckGeometry(render); const d = resolveDeck(render); const padX = 48; const trackY = 16; // The body is everything under the pip track: the text block and the QR are // both centred in it, so the title's optical centre and the code's agree // whatever the deck's height. 14 px clears the active pip's halo. const bodyTop = trackY + 14; const bodyH = rect.height - bodyTop - 8; const qr = d.qr.show ? { x: W - padX - d.qr.size, y: Math.round(bodyTop + (bodyH - d.qr.size) / 2), size: d.qr.size } : null; let textX = padX; let textRight = qr ? qr.x - 48 : W - padX; let tallyBox = null; if (tally?.length) { // A cell per verdict the cut uses: its count over its label. Beside the // QR's cell (whose plate starts 46 px before the code) on the right, or // ahead of the title on the left; 40 px of air to the text either way. const cell = 124, gap = 10; const width = tally.length * cell + (tally.length - 1) * gap; const height = Math.min(bodyH - 8, 118); const y = Math.round(bodyTop + (bodyH - height) / 2); if (resolveFactcheck(render).tally.position === "left") { tallyBox = { x: padX, y, width, height, cell, gap }; textX = padX + width + 40; } else { const right = qr ? qr.x - 46 - 24 : W - padX; tallyBox = { x: right - width, y, width, height, cell, gap }; textRight = tallyBox.x - 40; } } // Line boxes, not font sizes: the title's box holds its descenders inside the // wipe's overflow clip, and the rule sits in the gap between the two lines. // The subtitle scales with the title (26 px at the default 54). const titleSize = d.title.size; const subtitleSize = Math.round(Math.max(18, Math.min(34, titleSize * 0.48))); const titleBox = Math.round(titleSize * 1.2); const subtitleBox = Math.round(subtitleSize * 1.3); const gap = Math.round(titleSize * 0.3); const block = titleBox + gap + subtitleBox; const titleY = Math.round(bodyTop + Math.max(0, (bodyH - block) / 2)); return { width: W, height: rect.height, pipTrack: { x0: padX, x1: W - padX, y: trackY }, text: { x: textX, width: textRight - textX, titleSize, subtitleSize, titleY, titleBox, ruleY: titleY + titleBox + Math.round((gap - 4) / 2), subtitleY: titleY + titleBox + gap, subtitleBox, }, qr, // Only with a tally, so a deck without claims lays out as it always did. ...(tallyBox ? { tally: tallyBox } : {}), }; } /** * The x of every pip on the track. * * "even": evenly spaced, one per pip, a single pip centred. "time": each pip at * its segment's MIDPOINT in the cut's own clock, so a long clip owns more track * -- needs `schedule` (`{ total, segments: [{start, duration}] }`, the pipped * segments only, in order). */ export function pipXs(n, x0, x1, spacing = "even", schedule = null) { if (n <= 0) return []; if (spacing === "time") { if (!schedule?.segments || schedule.segments.length !== n || !(schedule.total > 0)) { throw new Error("pipXs: spacing \"time\" needs a schedule with one segment per pip"); } return schedule.segments.map((s) => x0 + ((x1 - x0) * (s.start + s.duration / 2)) / schedule.total); } if (n === 1) return [(x0 + x1) / 2]; return Array.from({ length: n }, (_, i) => x0 + (i * (x1 - x0)) / (n - 1)); } // --------------------------------------------------------------------------- // The schedule. // --------------------------------------------------------------------------- /** * Segment starts and the total, from durations and the crossfade. THE * arithmetic: build-video's segmentOffsets probes the durations and calls this, * and so does every estimate -- there is no second implementation to drift. */ export function scheduleFrom(durs, D) { const starts = []; let acc = 0; for (let i = 0; i < durs.length; i += 1) { starts.push(acc); acc += durs[i] - (i < durs.length - 1 ? D : 0); } return { starts, total: acc }; } /** * A segment's length before it is built, from the manifest alone. Clips are * their play window (the cut, with the lead-in, when there is one); silence * snapping moves the real one by a fraction of a second, which is why a * schedule built from these says `estimated: true`. */ export function estimatedDuration(entry, render = {}, D = transitionOf(render)) { if (entry.type === "clip") { const { from, to } = playWindow(entry, render); return Math.max(1, to - from); } if (entry.type === "image") return Number(entry.seconds ?? 4); // A teaser's dip makes its segment longer by the dissolve and the black (`teaserLead`). if (entry.type === "teaser") return teaserSeconds(entry, D, render.fps ?? 30); return Number(entry.seconds ?? 5); } /** * The SOURCE seconds a clip asks to play, before silence snapping: the cut * (`cutStart`/`cutEnd`) with the lead-in breath before it, clamped into the * extent, when there is one; else the extent (`start`/`end`). The build * snaps each end to a nearby silence, so the segment's true start is this * `from` moved by up to `snapWindow` -- which is why a build records the real * one beside the segment (`.cut.json`). */ export function playWindow(entry, render = {}) { const hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd); const lead = render.leadIn ?? 0.4; return { from: hasCut ? Math.max(entry.start, entry.cutStart - lead) : entry.start, to: hasCut ? Math.min(entry.end, entry.cutEnd) : entry.end, }; } /** The crossfade a build of this render block will use. */ export function transitionOf(render, { noXfade = false } = {}) { const t = render?.transition ?? 0.5; return noXfade || t === 0 ? 0 : t; } /** * Does the cut span more than one channel? The deck's auto subtitle names the * channel only when it does: one channel throughout is furniture. */ export function isMultiChannel(entries, provenance = {}) { const slugs = new Set( entries.filter((e) => e.type === "clip").map((e) => e.channel ?? provenance.channelSlug ?? ""), ); return slugs.size > 1; } /** Is the deck hidden over this segment? */ export function hidesDeck(entry, deck) { if (entry.type === "teaser") return true; return deck.overCards === "hide" && CARD_TYPES.includes(entry.type); } /** The deck's title and subtitle for one entry. Pure: the caller resolves `meta`. */ export function deckText(entry, meta, provenance, deck, multiChannel) { const o = entry.onscreen ?? {}; let title = o.title ?? ""; let subtitle = o.subtitle; if (subtitle === undefined) { if (entry.type === "clip") { subtitle = deckSubtitle(attributionParts(entry, meta ?? {}, provenance), deck.subtitle, multiChannel); } else if (entry.type === "teaser") { // The deck is never up over a teaser; its words are what a table of // the cut (umtool's On-screen rows, the schedule) names it by. if (!title) title = teaserTitle(entry); subtitle = ""; } else if (entry.type === "image") { subtitle = deckSubtitle( { channel: "", title: String(entry.title ?? "").trim(), date: String(entry.date ?? "").trim(), at: null }, { ...deck.subtitle, parts: ["title", "date"] }, false, ); } else { if (!title) title = entry.heading ?? ""; subtitle = entry.sub ?? ""; } } return { title, subtitle }; } /** * The QR the deck shows for one entry, or null for none. A clip's is the * per-clip corner QR's rule unchanged (`citeUrl` wins, else the site link at * the clip's start); a still has one only when it carries a `citeUrl`; a card * has none. * * With `links: "original"` (the deck's `qr.links`) a clip without a `citeUrl` * links its record's own page instead (`meta.webpageUrl`, factcheck.mjs * `originalUrlAt`: YouTube at the clip's start, a Rumble page or an X post * as-is) -- or the site link still, when its record names none. * * @param {any} entry * @param {any} [provenance] * @param {{ links?: string, meta?: { webpageUrl?: string | null } | null }} [opts] */ export function deckQrUrl(entry, provenance = {}, { links = DECK_DEFAULTS.qr.links, meta = null } = {}) { if (entry.type === "clip") { if (entry.citeUrl) return entry.citeUrl; // A clip from a file beside the manifest (`src`) has no page anywhere. if (entry.src != null) return null; const original = links === "original" ? originalUrlAt(meta?.webpageUrl, entry.start) : null; return original ?? `${provenance.siteOrigin}/?v=${encodeURIComponent( `${entry.channel ?? provenance.channelSlug}/${entry.video}`, )}&t=${Math.floor(entry.start)}`; } if (entry.type === "image") return entry.citeUrl ?? null; return null; } /** * The schedule document (`out//schedule.json` when the deck is on). * * `metas[i]` is entry i's source metadata (`{ title, uploadDate, channel, * webpageUrl }`; the last read only by `qr.links: "original"`) * or null. The build passes probed durations and real metadata; an estimate * passes `estimatedDuration`s and whatever metadata it has. * * @returns {{ version: 1, kind: "deck", estimated: boolean, fps: number, * transition: number, total: number, multiChannel: boolean, * segments: Array<{ id: string, type: string, start: number, duration: number, * end: number, title: string, subtitle: string, qrUrl: string|null, * hideDeck: boolean, claim?: { id: string, verdict: string } }>, * factcheck?: { stamps: Array<{ claim: string, verdict: string, segment: string, * at: number, landed: number, out: [number, number] }> } }} */ export function deckSchedule({ entries, durs, D, render, provenance = {}, metas = [], estimated = false, posts = [], }) { const deck = resolveDeck(render); const fps = render.fps ?? 30; // A clip that carries posts is held on its last frame for `posts.hold`: the // hold is part of the segment's length in the cut, so every start, the total // and the posts' timing below are measured with it. `durs` are the segments' // own (probed or estimated) lengths. (The feed holds nothing: postHolds.) const holds = deck.posts.show ? postHolds({ posts, entries, metas, render }) : new Map(); const full = durs.map((d, i) => d + (holds.get(entries[i].id) ?? 0)); const { starts, total } = scheduleFrom(full, D); const multiChannel = isMultiChannel(entries, provenance); const round = (v) => Math.round(v * 1000) / 1000; const segs = entries.map((e, i) => ({ id: e.id, start: starts[i], duration: full[i] })); const placed = deck.posts.show ? postSchedule({ posts, entries, metas, segments: segs, D, total, render, provenance }) : []; // The feed is a layout only when it has posts to draw: a feed with none is // the deck alone, and writes the schedule a deck without posts always did. const feed = placed.length > 0 && deck.posts.layout === "feed"; const moves = placed.length && !feed ? footageMoves({ posts: placed, segments: segs, render }) : []; const stamps = stampSchedule({ segments: segs.map((s, i) => ({ ...s, claim: claimOf(entries[i]) })), D, total, render }); const threads = threadsOn(render) ? threadSchedule({ segments: segs.map((s, i) => ({ ...s, thread: threadOf(entries[i]) })), D, total, render, moves }) : null; const flips = flipsOn(render) ? flipSchedule({ segments: segs, total, render, moves }) : null; return { version: 1, kind: "deck", estimated, fps, transition: D, total: round(total), multiChannel, ...(feed ? { layout: "feed" } : {}), segments: entries.map((e, i) => { const { title, subtitle } = deckText(e, metas[i] ?? null, provenance, deck, multiChannel); return { id: e.id, type: e.type, start: round(starts[i]), duration: round(full[i]), end: round(starts[i] + full[i]), // Only on a held clip, so a cut without posts writes the schedule it always did. ...(holds.get(e.id) ? { hold: round(holds.get(e.id)) } : {}), title, subtitle, qrUrl: deck.qr.show ? deckQrUrl(e, provenance, { links: deck.qr.links, meta: metas[i] ?? null }) : null, hideDeck: hidesDeck(e, deck), // Only on a teaser that dips, so a cut without one writes the schedule it always did. ...(dipOf(e, fps) ? { dip: dipOf(e, fps) } : {}), // Only on an entry that carries a claim (factcheck.mjs), likewise. ...(claimOf(e) ? { claim: claimOf(e) } : {}), }; }), // The fact-check stamps: present only when a claim is stamped. ...(stamps.length ? { factcheck: { stamps: roundStamps(stamps) } } : {}), // The thread rail: present only when it is on. ...(threads ? { threads } : {}), // The flips panel: present only when it is on. ...(flips ? { flips } : {}), // Present only when there are posts to draw, so a cut without them writes // the schedule it always did. ...(placed.length ? { posts: roundPosts(placed) } : {}), ...(moves.length ? { moves: moves.map((m) => ({ ...m, at: round(m.at), segmentAt: round(m.segmentAt) })) } : {}), }; } /** * `postSchedule`'s placements as a schedule writes them: their seconds to the * millisecond -- a popup's `appear` and `out`, a feed's `in`. umtool's preview * re-places posts and writes them through this too. */ export function roundPosts(placed) { const round = (v) => Math.round(v * 1000) / 1000; return placed.map((p) => ("in" in p ? { ...p, in: round(p.in) } : { ...p, appear: round(p.appear), out: p.out.map(round) })); } /** `deckSchedule` from the manifest alone, for a preview before any build. */ export function estimateSchedule(manifest, { metas = [], noXfade = false } = {}) { const render = manifest.render ?? {}; const entries = manifest.timeline ?? []; const D = transitionOf(render, { noXfade }); return deckSchedule({ entries, posts: manifest.posts ?? [], durs: entries.map((e) => estimatedDuration(e, render, D)), D, render, provenance: manifest.provenance ?? {}, metas, estimated: true, }); } // --------------------------------------------------------------------------- // Choreography. Absolute seconds in the cut's clock, so the composition, the // tests and a reviewer all read the same numbers. // --------------------------------------------------------------------------- /** * When everything moves at each segment boundary. * * The handover is centred on the MID-DISSOLVE, m = start + D/2 (= start for a * hard cut): the old title wipes out over [m − out, m], the new one expands * over [m, m + in] with the subtitle trailing by 0.08 s, the QR flips through m * (unscannable for 0.25 s and no longer), and the pip marker travels over * [m − pip/2, m + pip/2]. * * The deck itself slides away over a hidden segment's dissolve and back over * the next one's; a boundary into or out of a hidden segment has no text * handover, because the text it would animate is off screen. * * @returns {{ handovers: Array<{ i:number, from:string, to:string, m:number, * out:[number,number], in:[number,number], sub:[number,number], * qr:[number,number], pip:[number,number] }>, * visibility: Array<{ i:number, hide:boolean, at:[number,number] }> }} */ export function deckChoreography(schedule, render) { const deck = resolveDeck(render); const { out, in: inn, pip } = deck.motion; const D = schedule.transition; const segs = schedule.segments; const handovers = []; const visibility = []; const slide = Math.max(D, inn); if (segs.length && segs[0].hideDeck) visibility.push({ i: 0, hide: true, at: [0, 0] }); for (let i = 1; i < segs.length; i += 1) { const m = segs[i].start + D / 2; const a = segs[i - 1].hideDeck; const b = segs[i].hideDeck; if (a !== b) { // Gone before the card is fully up; back once the footage is. Into a // teaser that dips, the deck is gone in an instant at the frame the // dip has made black -- the previous segment's last (`dipHideAt`) -- // so nothing slides over the fade or the rise. const at = b && segs[i].dip ? [dipHideAt(segs[i], D, schedule.fps), dipHideAt(segs[i], D, schedule.fps)] : D > 0 ? [segs[i].start, segs[i].start + slide] : b ? [m - slide, m] : [m, m + slide]; visibility.push({ i, hide: b, at }); continue; } if (b) continue; handovers.push({ i, from: segs[i - 1].id, to: segs[i].id, m, out: [m - out, m], in: [m, m + inn], sub: [m + 0.08, m + 0.08 + inn], qr: [m - 0.125, m + 0.125], pip: [m - pip / 2, m + pip / 2], }); } return { handovers, visibility }; } /** The segments that get a pip: every one the deck is shown over. */ export function pipSegments(schedule) { return schedule.segments.filter((s) => !s.hideDeck); } // --------------------------------------------------------------------------- // Posts: written statements (a Bluesky or X post) drawn as cards over the // footage. A post is not a segment -- it has no footage of its own -- so it // rides on a clip: the clip whose recording most closely PRECEDES it, unless // the post names one with `attachTo`. A clip's posts appear at the end of it, // one every `posts.seconds`, stacking, so the last is up for the clip's last // `seconds`; all of them leave in the transition to the next segment. // --------------------------------------------------------------------------- export const POST_PLATFORMS = Object.freeze(["bluesky", "x", "web"]); /** When a popup's posts come up (`posts.at`): the end of their clip, or its start. */ export const POST_AT = Object.freeze(["end", "start"]); /** The cuts a post may be limited to (`posts[].variant`) -- build-video's VARIANTS. */ const POST_VARIANTS = Object.freeze(["sourced", "full"]); /** How posts are drawn (`posts.layout`): cards at the end of a clip, or a column for the whole cut. */ export const POST_LAYOUTS = Object.freeze(["popup", "feed"]); /** Where a post's QR links (`posts.links`): its page on the archive, or the platform's own link. */ export const POST_LINKS = Object.freeze(["archive", "original"]); const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide", "siteChannel", "siteUrl", "postId", "shot", "variant", "accent", "logo", "flag"]; /** A post's `flag`: at most this many characters. */ export const POST_FLAG_MAX = 60; /** The pictures a post's `shot` may be. */ export const POST_SHOT_EXTS = Object.freeze([".png", ".jpg", ".jpeg", ".webp"]); /** * Why a post's `shot` cannot be drawn, or null: a path to a screenshot of the * post, RELATIVE to the manifest (it is checked in beside it, as an image * entry's `src` is), inside the manifest's folder, and a picture by its name. */ function shotError(shot, what = "the post's screenshot") { if (typeof shot !== "string" || !shot.trim()) return `must be a path to ${what}, relative to the manifest`; const parts = shot.split(/[\\/]+/); if (/^([A-Za-z]:)?[\\/]/.test(shot) || parts.includes("..")) { return "must be relative to the manifest and inside its folder (no leading /, no ..)"; } const dot = shot.lastIndexOf("."); const ext = dot < 0 ? "" : shot.slice(dot).toLowerCase(); if (!POST_SHOT_EXTS.includes(ext)) return `must be a ${POST_SHOT_EXTS.join(", ")} picture`; return null; } /** * Every reason the manifest's `posts` cannot be built, as sentences. `timeline` * is the whole manifest's: an `attachTo` must name one of its clips. * * @returns {string[]} */ export function validatePosts(posts, timeline = [], render = null) { if (posts === undefined || posts === null) return []; if (!Array.isArray(posts)) return ["posts must be a list"]; const errors = []; const clips = new Set(timeline.filter((e) => e.type === "clip").map((e) => e.id)); const seen = new Set(); posts.forEach((p, i) => { const w = `posts[${i}]`; if (!isObj(p)) { errors.push(`${w} must be an object`); return; } unknownKeys(p, POST_KEYS, w, errors); if (typeof p.id !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(p.id)) { errors.push(`${w}.id must be letters, digits, dashes or underscores`); } else if (seen.has(p.id)) errors.push(`${w}.id ${p.id} is used twice`); else seen.add(p.id); if (!POST_PLATFORMS.includes(p.platform)) errors.push(`${w}.platform must be ${POST_PLATFORMS.join(" or ")}`); if (typeof p.date !== "string" || !/^\d{4}-\d{2}-\d{2}/.test(p.date) || Number.isNaN(Date.parse(p.date))) { errors.push(`${w}.date must be an ISO date or date-time`); } if (typeof p.text !== "string" || !p.text.trim()) errors.push(`${w}.text must be the post's words`); else if (p.text.length > 3000) errors.push(`${w}.text is ${p.text.length} characters (at most 3000)`); if (typeof p.url !== "string" || !/^https:\/\/\S+$/.test(p.url)) errors.push(`${w}.url must be the post's https link`); for (const k of ["author", "handle"]) { if (p[k] !== undefined && typeof p[k] !== "string") errors.push(`${w}.${k} must be a string`); } if (p.attachTo !== undefined && p.attachTo !== null && !clips.has(p.attachTo)) { errors.push(`${w}.attachTo ${JSON.stringify(p.attachTo)} is not a clip in the timeline`); } if (p.hide !== undefined && typeof p.hide !== "boolean") errors.push(`${w}.hide must be true or false`); // Like an entry's: a post in one cut only. Its clip should be in that cut too. if (p.variant !== undefined && !POST_VARIANTS.includes(p.variant)) { errors.push(`${w}.variant must be ${POST_VARIANTS.map((v) => `"${v}"`).join(" or ")}`); } // Where the archive keeps the post: its own channel's slug (a post channel // is not the video channel -- `piratesoftware-bsky`, not `piratesoftware`), // a page to link instead, or the post's id when its url does not carry one. // `null` is unset, as `attachTo: null` is. if (p.siteChannel != null && (typeof p.siteChannel !== "string" || !SLUG_RE.test(p.siteChannel))) { errors.push(`${w}.siteChannel must be the archive's channel slug (letters, digits, dots, dashes, underscores)`); } if (p.siteUrl != null && (typeof p.siteUrl !== "string" || !/^https?:\/\/\S+$/.test(p.siteUrl))) { errors.push(`${w}.siteUrl must be an http(s) link`); } if (p.postId != null && (typeof p.postId !== "string" || !POST_ID_RE.test(p.postId))) { errors.push(`${w}.postId must be the post's id on its platform (letters, digits, dashes, underscores)`); } // A screenshot drawn instead of the text card; the text stays the post's words. if (p.shot != null && shotError(p.shot)) errors.push(`${w}.shot ${shotError(p.shot)}`); // The card's own marks: a colour for its rail and rim, a logo in its corner, // a short flag above its words -- so one kind of card reads apart from another. if (p.accent != null && (typeof p.accent !== "string" || !/^#[0-9a-fA-F]{6}$/.test(p.accent))) { errors.push(`${w}.accent must be a #rrggbb colour`); } if (p.logo != null && shotError(p.logo, "a logo picture")) errors.push(`${w}.logo ${shotError(p.logo, "a logo picture")}`); if (p.flag != null && (typeof p.flag !== "string" || !p.flag.trim() || p.flag.length > POST_FLAG_MAX)) { errors.push(`${w}.flag must be a short label (1 to ${POST_FLAG_MAX} characters)`); } }); // Posts that will be drawn need a column that fits the footage box. if (render && deckOn(render) && resolveDeck(render).posts.show && posts.some((p) => isObj(p) && !p.hide)) { errors.push(...postsFitErrors(render)); } return errors; } /** Why the posts column cannot be drawn in this frame, as sentences (empty: it can). */ export function postsFitErrors(render) { const w = "render.chrome.deck"; const g = deckGeometry(render); const pp = resolveDeck(render).posts; const errors = []; if (pp.layout === "feed") { // The feed's column stands beside the footage, not over it: what has to // fit is the footage left beside it. const f = feedGeometry(render); if (f.footage.width < g.W / 2) { errors.push( `${w}.posts.width ${pp.width} with inset ${pp.inset} leaves the footage ${f.footage.width}px wide beside the feed ` + `(at least half the frame, ${g.W / 2}px)`, ); } } else if (pp.width + 2 * pp.inset > g.footage.width) { errors.push(`${w}.posts.width ${pp.width} with inset ${pp.inset} does not fit the ${g.footage.width}px footage box`); } if (pp.qrSize > pp.width / 2) errors.push(`${w}.posts.qrSize ${pp.qrSize} is more than half the card's width`); return errors; } /** The day a clip's recording is dated by: the clip's own `date`, else its record's upload date. */ export function clipDay(entry, meta) { if (entry.date) return entry.date; const d = String(meta?.uploadDate ?? ""); return /^\d{8}$/.test(d) ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : null; } /** * Which clip each post rides on. A post names its clip with `attachTo`, or * takes the clip with the LATEST day on or before its own (ties: the later in * the cut); a post older than every clip goes on the first. Hidden posts, and * posts in a cut with no clips, are not placed. * * @returns {Array<{ id: string, entryId: string, rule: "attachTo"|"date"|"first", clipDay: string|null }>} */ export function attachPosts({ posts = [], entries = [], metas = [] }) { const clips = entries .map((e, i) => ({ e, i, day: e.type === "clip" ? clipDay(e, metas[i]) : null })) .filter((c) => c.e.type === "clip"); if (!clips.length) return []; const out = []; for (const p of posts) { if (p.hide) continue; if (p.attachTo) { const c = clips.find((x) => x.e.id === p.attachTo); if (c) { out.push({ id: p.id, entryId: c.e.id, rule: "attachTo", clipDay: c.day }); continue; } // Not in this cut (a variant left it out): fall through to the date rule. } const day = String(p.date).slice(0, 10); let best = null; for (const c of clips) { if (!c.day || c.day > day) continue; if (!best || c.day > best.day || (c.day === best.day && c.i > best.i)) best = c; } out.push(best ? { id: p.id, entryId: best.e.id, rule: "date", clipDay: best.day } : { id: p.id, entryId: clips[0].e.id, rule: "first", clipDay: clips[0].day }); } return out; } /** * When each placed post is on screen, in the cut's clock. * * In the FEED (`posts.layout: "feed"`) a post has one time, `in`: when it * ticks into the column -- its clip's start + D, after the incoming dissolve, * and D in on the FIRST clip as well, which has no dissolve into it (the * popup's first clip starts at 0). A clip's posts (oldest first) follow one * every `posts.seconds`, or closer when the clip is too short for that: post j * of k ticks in at start + D + step·j, step = min(seconds, (A − start − D)/k), * A the start of the outgoing transition as below -- so the last one is in at * least `step` before its clip leaves. Once in, a post stays to the end. * * In the POPUP at the clip's start (`posts.at: "start"`), a clip's posts keep * the manifest's order and share the whole clip: post j of k appears at * from + share·j, from = the clip's start after its incoming dissolve, share = * (A − from)/k with A as below; they leave together over `out`. * * In the POPUP (at the end, the default), a clip's posts (oldest first) share an anchor A: the start of the outgoing * transition -- the next segment's start with a crossfade, 0.3 s before the cut * on a hard cut, 0.3 s before the end on the last segment. Post j of k appears * at A − step·(k − j), step = `posts.seconds`, so each has its seconds alone * before the next stacks on and the last has the final seconds. A clip too * short for that shares what it has after its incoming dissolve evenly. They * all leave together over `out`. * * Each one's `qrUrl` is postQrUrl's: its archive page under `posts.links` * "archive" when the post names its archive channel, else its own `url`. * * @returns {Array<{ id, segment, slot, of, appear, out: [number, number], date, text, * author, handle, platform, url, qrUrl }>} */ export function postSchedule({ posts = [], entries, metas = [], segments, D, total, render, provenance = {} }) { const settings = resolveDeck(render).posts; const postFields = (p) => postFieldsOf(p, provenance, settings.links); const feed = settings.layout === "feed"; const byId = new Map(posts.map((p) => [p.id, p])); const groups = new Map(); for (const a of attachPosts({ posts, entries, metas })) { if (!groups.has(a.entryId)) groups.set(a.entryId, []); groups.get(a.entryId).push(byId.get(a.id)); } const out = []; segments.forEach((seg, i) => { const group = groups.get(seg.id); if (!group) return; // Oldest first -- except a popup at the clip's start, which keeps the // manifest's order (a claim, then the post that bears on it). if (feed || settings.at !== "start") group.sort((x, y) => Date.parse(x.date) - Date.parse(y.date)); const last = i === segments.length - 1; const leave = last ? [total - 0.3, total] : D > 0 ? [segments[i + 1].start, segments[i + 1].start + D] : [segments[i + 1].start - 0.3, segments[i + 1].start]; const A = leave[0]; const k = group.length; if (feed) { const from = seg.start + D; const step = Math.min(settings.seconds, Math.max(0, A - from) / k); group.forEach((p, j) => out.push({ id: p.id, segment: seg.id, slot: j, of: k, in: from + step * j, ...postFields(p) })); return; } const from = seg.start + (i > 0 ? D : 0); if (settings.at === "start") { // The whole clip, shared evenly: post j comes up at from + step·j. const share = Math.max(0, A - from) / k; group.forEach((p, j) => { out.push({ id: p.id, segment: seg.id, slot: j, of: k, appear: from + share * j, out: leave, ...postFields(p) }); }); return; } const step = Math.min(settings.seconds, Math.max(0, A - from) / k); group.forEach((p, j) => { out.push({ id: p.id, segment: seg.id, slot: j, of: k, appear: A - step * (k - j), out: leave, ...postFields(p), }); }); }); return out; } /** What a placed post carries for the page that draws it. */ function postFieldsOf(p, provenance, links) { return { date: p.date, text: p.text, author: p.author ?? "", handle: p.handle ?? "", platform: p.platform, url: p.url, qrUrl: postQrUrl(p, provenance, { links }), // Only with a screenshot, so a post without one places as it always did. ...(typeof p.shot === "string" && p.shot ? { shot: p.shot } : {}), ...(typeof p.accent === "string" && p.accent ? { accent: p.accent } : {}), ...(typeof p.logo === "string" && p.logo ? { logo: p.logo } : {}), ...(typeof p.flag === "string" && p.flag.trim() ? { flag: p.flag.trim() } : {}), }; } const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const POST_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; /** * The post's id on its platform -- what the archive keys it by (a post * channel's `slugToPage`): an explicit `postId`, else read off its url -- a * Bluesky `/post/`, an X (or twitter.com) `/status/`. Null when * neither says. * * @returns {string|null} */ export function postNativeId(post) { if (typeof post?.postId === "string" && POST_ID_RE.test(post.postId)) return post.postId; let u; try { u = new URL(String(post?.url ?? "")); } catch { return null; } const m = /\/post\/([A-Za-z0-9]+)\/?$/.exec(u.pathname) ?? /\/status(?:es)?\/(\d+)(?:\/|$)/.exec(u.pathname); return m ? m[1] : null; } /** * A post's page on an archive: the site's post modal for `/`, * which shows the post and links on to the original. */ export function postArchiveUrl(siteOrigin, siteChannel, nativeId) { const origin = String(siteOrigin).replace(/\/+$/, ""); return `${origin}/?v=${encodeURIComponent(`${siteChannel}/${nativeId}`)}&vm=post`; } /** * Where a post's QR (and any link to it the cut draws) goes. * * `links: "original"` (the deck's `posts.links`) is the post's own `url`, * always. Under "archive" (the default): the post's explicit `siteUrl`; else, * with an archive to link (`provenance.siteOrigin`), the channel it is kept in * there (`siteChannel` -- pinned in the manifest, or found by the build's * resolver, post-links.mjs) and its id, the post's archive page; else its own * `url`, as before. * * @returns {string} */ export function postQrUrl(post, provenance = {}, { links = DECK_DEFAULTS.posts.links } = {}) { if (links === "original") return post.url; if (typeof post.siteUrl === "string" && post.siteUrl) return post.siteUrl; const origin = typeof provenance?.siteOrigin === "string" ? provenance.siteOrigin.trim() : ""; const id = postNativeId(post); if (origin && post.siteChannel && id) return postArchiveUrl(origin, post.siteChannel, id); return post.url; } /** * The windows the posts region is rendered for: one per clip that carries * posts, from its first post's appearance to the end of its leave. Frames are * only made for these seconds; the overlay places each at its `from`. */ export function postWindows(schedule) { // The feed is one composition for the whole cut, not windows. if (schedule.layout === "feed") return []; const by = new Map(); for (const p of schedule.posts ?? []) { const w = by.get(p.segment) ?? { segment: p.segment, from: Infinity, to: -Infinity }; w.from = Math.min(w.from, p.appear); w.to = Math.max(w.to, p.out[1]); by.set(p.segment, w); } return [...by.values()].sort((a, b) => a.from - b.from); } /** * A `postWindows` entry snapped OUTWARD to the frame grid, and kept inside the * cut: `from` down to a frame, `to` up to one, never past the cut's last frame. * `f0` is the cut frame a window's frame 1 lands on; `frames` is * `frameCount(to − from, fps)` of the snapped window -- the sequence's length, * which verify-build checks. * * @returns {{ segment: string, from: number, to: number, f0: number, frames: number }} */ export function snapWindow(window, { fps, total }) { const f0 = Math.max(0, Math.floor(window.from * fps + 1e-6)); const f1 = Math.min(Math.ceil(window.to * fps - 1e-6), frameCount(total, fps)); if (!(f1 > f0)) throw new Error(`posts: the window for ${window.segment} is empty (${window.from}s–${window.to}s)`); return { segment: window.segment, from: f0 / fps, to: f1 / fps, f0, frames: frameCount((f1 - f0) / fps, fps) }; } /** * Where the posts region sits in the frame: a column inside the footage box, * `inset` from its top and from the chosen side, `width` wide and the box's * height less the insets. Cards stack top-down inside it. */ export function postsGeometry(render) { const { W, footage: f } = deckGeometry(render); const p = resolveDeck(render).posts; if (p.layout === "feed") return feedGeometry(render).column; const width = even(p.width); const height = even(f.height - 2 * p.inset); const left = p.position === "top-left"; // With `shift` the footage makes room, so the column sits at the FRAME's // edge; without it, inside the footage box as before. const x = p.shift ? (left ? p.inset : W - p.inset - width) : (left ? f.x + p.inset : f.x + f.width - p.inset - width); return { x: Math.round(x), y: Math.round(f.y + p.inset), width, height }; } /** * The footage box while a clip's posts are up (`posts.shift`): scaled by * `shift.scale` about nothing in particular, its far edge `inset` from the * frame edge AWAY from the posts column, centred in the height above the deck. * null when shift is off. */ export function shiftedFootage(render) { const { W, H, deck: d, footage: f } = deckGeometry(render); const p = resolveDeck(render).posts; if (!p.shift) return null; const width = even(f.width * p.shift.scale); const height = even(f.height * p.shift.scale); const x = p.position === "top-left" ? W - p.inset - width : p.inset; return { x: Math.round(x), y: Math.floor((H - d.height - height) / 2), width, height }; } /** * The FEED's frame (`posts.layout: "feed"`): a column of posts for the whole * cut on the `position` side, standing on the deck from the frame's top, and * the footage box beside it. The deck stays full width at the bottom, so the * column and the deck read as one L-shaped surface around the picture. * * - The column (`column`, the feed region) is `posts.width` wide, flush with * the frame's side edge and top, down to the deck's top edge. * - The footage keeps the frame's aspect and is as large as fits in what is * left above the deck with `posts.inset` clear on every side (evened), and * is centred there. At 1920×1080 with the defaults (190 px deck, 600 px * column, inset 24): the column is 600×890 at (1320, 0) and the footage * 1272×716 at (24, 87) -- 66 % of the frame's width, against the deck * alone's 82 %. * * Nothing in it moves: every footage segment of a feed cut is framed into * this box when it is built (build-video's deckFraming), and stays there. * * @returns {{ W: number, H: number, column: {x:number,y:number,width:number,height:number}, * footage: {x:number,y:number,width:number,height:number}, deck: {x:number,y:number,width:number,height:number} }} */ export function feedGeometry(render) { const { W, H, deck } = deckGeometry(render); const p = resolveDeck(render).posts; const left = p.position === "top-left"; const cw = even(p.width); const room = { x: left ? cw : 0, width: W - cw, height: H - deck.height }; const fit = Math.min(room.width - 2 * p.inset, ((room.height - 2 * p.inset) * W) / H); const fw = even(Math.max(2, fit)); const fh = even((fw * H) / W); return { W, H, column: { x: left ? 0 : W - cw, y: 0, width: cw, height: room.height }, footage: { x: room.x + Math.floor((room.width - fw) / 2), y: Math.floor((room.height - fh) / 2), width: fw, height: fh, }, deck, }; } /** * Where the fact-check stamp region sits in the frame (chrome-stamp.mjs): a * box in the footage box -- the feed's when `feed` (the schedule's `layout: * "feed"`), else the deck's -- at `factcheck.stamp.position`, `inset` clear of * its edges. 560×200 at 1920 wide, scaled with the frame; the stamp is drawn * tilted inside it with room to spare. * * @param {any} render * @param {{ feed?: boolean }} [opts] * @returns {{ x: number, y: number, width: number, height: number }} */ export function stampGeometry(render, { feed = false } = {}) { const { W } = deckGeometry(render); const f = feed ? feedGeometry(render).footage : deckGeometry(render).footage; const k = W / 1920; const width = Math.min(even(560 * k), even(f.width)); const height = Math.min(even(200 * k), even(f.height)); const inset = Math.round(28 * k); const pos = resolveFactcheck(render).stamp.position; const [v, h] = pos === "center" ? ["center", "center"] : pos.split("-"); const x = h === "left" ? f.x + inset : h === "right" ? f.x + f.width - inset - width : f.x + (f.width - width) / 2; const y = v === "top" ? f.y + inset : v === "bottom" ? f.y + f.height - inset - height : f.y + (f.height - height) / 2; return { x: Math.round(x), y: Math.round(y), width, height }; } /** * Is this cut a FEED: the deck on, its posts shown in the `feed` layout, and * at least one post to draw on a clip of `entries` (the cut's whole timeline, * never an `--only` slice of it)? The build frames its footage into * `feedGeometry` exactly when this is true, and the schedule says * `layout: "feed"` exactly then -- a feed with nothing to draw is the deck alone. */ export function feedOn({ render, posts = [], entries = [] }) { if (!deckOn(render)) return false; const p = resolveDeck(render).posts; return p.show && p.layout === "feed" && attachPosts({ posts: posts ?? [], entries }).length > 0; } /** * How long each clip that carries posts is held on its last frame (entry id → * seconds), in WHOLE FRAMES: `tpad` clones a whole number of frames (2.5 s at * 25 fps is 63, not 62.5) while `apad` pads exactly, so an unrounded hold * would make a hard cut with several held clips longer than its schedule. */ export function postHolds({ posts = [], entries = [], metas = [], render }) { const fps = render?.fps ?? 30; const settings = resolveDeck(render).posts; const hold = Math.round(settings.hold * fps) / fps; const out = new Map(); // The feed never pauses the cut: a post is in its column while the clip plays on. if (!(hold > 0) || settings.layout === "feed") return out; for (const a of attachPosts({ posts, entries, metas })) out.set(a.entryId, hold); return out; } /** * When the footage moves aside for a clip's posts: one move per carrying clip, * starting as its first post appears (`at`, cut clock; `segmentAt`, the * segment's own clock, its hold included) and easing over `shift.seconds` * from the footage box to `shiftedFootage`. It stays there to the end of the * segment; the next segment comes in at the normal box through the * transition. `segmentAt` may fall inside the hold: the build runs the move * after the hold, so the frozen frame moves too. * * @returns {Array<{ segment, at, segmentAt, seconds, from, to }>} */ export function footageMoves({ posts, segments, render }) { const to = shiftedFootage(render); if (!to) return []; const from = deckGeometry(render).footage; const seconds = resolveDeck(render).posts.shift.seconds; const first = new Map(); for (const p of posts) first.set(p.segment, Math.min(first.get(p.segment) ?? Infinity, p.appear)); return segments .filter((s) => first.has(s.id)) .map((s) => ({ segment: s.id, at: first.get(s.id), segmentAt: first.get(s.id) - s.start, seconds, from, to })); } // --------------------------------------------------------------------------- // Cut edits made where the cut is joined, like the hold: a clip's `muteFrom` // and the cut's `render.endFade`. Neither touches a segment file, so // `--chrome-only` changes either without rebuilding a clip. Pure here: the // validators (umtool's writers and the build share them) and the arithmetic. // --------------------------------------------------------------------------- /** The fade into a `muteFrom`'s silence (`mute.mjs`, dependency-free so a client bundle can take it alone). */ export { MUTE_FADE } from "./mute.mjs"; /** `render.endFade`'s upper bound, in seconds. */ export const END_FADE_MAX = 10; /** * Why one timeline entry's `muteFrom` cannot be built, as sentences. It is in * SOURCE seconds, like `start`/`end`/`cutEnd`: a number within the clip's * extent. Absent (or null) is fine. */ export function validateMuteFrom(entry, where = `timeline entry ${entry?.id ?? "?"}`) { const v = entry?.muteFrom; if (v === undefined || v === null) return []; if (entry.type !== "clip") return [`${where}.muteFrom: only a clip has sound to mute`]; if (typeof v !== "number" || !Number.isFinite(v)) return [`${where}.muteFrom must be a number of source seconds`]; if (v < entry.start || v > entry.end) { return [`${where}.muteFrom ${v} is outside the clip's ${entry.start}–${entry.end}`]; } return []; } /** Why `render.endFade` cannot be built, as sentences: seconds from 0 (off) to END_FADE_MAX. */ export function validateEndFade(render) { const v = render?.endFade; if (v === undefined || v === null) return []; if (!numIn(v, 0, END_FADE_MAX)) return [`render.endFade must be from 0 to ${END_FADE_MAX} seconds`]; return []; } /** * Every `muteFrom` in the timeline, a `dip` on anything but a teaser, and * `render.endFade`, checked: the build refuses with these before it fetches. */ export function validateCutEdits(manifest) { const errors = []; (manifest?.timeline ?? []).forEach((e, i) => { errors.push(...validateMuteFrom(e, `timeline[${i}] (${e?.id ?? "?"})`)); // A teaser's dip is validateTeasers'; a dip anywhere else is refused here. if (e?.type !== "teaser") errors.push(...validateDip(e, `timeline[${i}] (${e?.id ?? "?"})`)); }); errors.push(...validateEndFade(manifest?.render)); return errors; } /** The end fade a render block asks for, in seconds (0 = none). */ export const endFadeOf = (render) => (numIn(render?.endFade, 0, END_FADE_MAX) ? render.endFade : 0); /** * Where a clip's `muteFrom` falls in its SEGMENT's clock, from the source * second the segment really starts at. * * The build cuts a segment from the snapped start, and records that start * beside it (`.cut.json`: `{ video, start, end }`, source seconds). A * record is believed when it names this clip's video and is as long as the * segment (`seconds`, its probed length) to within two frames; otherwise -- * a segment built before records existed, or one copied without its record -- * the start is the unsnapped `playWindow` start, and `note` says so: snapping * may have moved the true start by up to `snapWindow` seconds. * * @returns {{ at: number, source: "record" | "window", note?: string }} * `at` in segment seconds, never below 0 (a muteFrom before the segment's * start mutes it from its first sample). */ export function muteSegmentSeconds({ entry, record = null, render = {}, seconds = null }) { const fps = render.fps ?? 30; const ok = record && record.video === entry.video && Number.isFinite(record.start) && Number.isFinite(record.end) && (seconds == null || Math.abs(record.end - record.start - seconds) <= 2 / fps); const at = (from) => Math.max(0, Math.round((entry.muteFrom - from) * 1000) / 1000); if (ok) return { at: at(record.start), source: "record" }; const { from } = playWindow(entry, render); return { at: at(from), source: "window", note: `${entry.id}: ${record ? "the cut record does not match the segment" : "no cut record beside the segment"} — ` + `muteFrom measured from the unsnapped start ${from}; the real start may differ by up to ` + `${render.snapWindow ?? 1.6}s. Rebuild the clip to record it.`, }; } // --------------------------------------------------------------------------- // The teaser: a full-frame graphic card -- a season teaser's "coming soon" // screen -- whose words are the manifest's. Its segment is a HyperFrames // render (chrome-teaser.mjs draws it, compose-chrome renders it, the build // encodes it). Pure here: what the words are, and why they cannot be drawn. // // { "type": "teaser", "id": "fin", "seconds": 7, "beat": 0.7, // "lines": ["Pirate Software", // { "text": "The Largest Ferret Rescue in the United States", // "break": "in the United States" }, // "February 2027"], // "tail": "?", "hits": true } // // A line is a string, or `{ text, break }`: `break` is the END of `text` set // as a smaller second tier under the rest, a beat later. The tail hangs off // the right of the last line -- which is centred by its own words -- and // fades in on its own, `tailWait` (optional) after the last hit. `hits` (default true) puts a // trailer hit under each pop and a swell under the tail; false is silence. // `beat` (optional) is the seconds from one line's pop to the next // (`teaserMotion`); `seconds` (optional) is the card's length, and without it // the card is as long as its beats need (`teaserSeconds`). Nothing is ever // squeezed to fit: a `seconds` too short for the beats is refused. // Roles follow position: with three // or more lines the first is the overline and the last the kicker (a date), // everything between is a title; two lines are an overline and a title; one // is a title. // --------------------------------------------------------------------------- /** * The teaser's limits: lines, seconds, characters per line, the tail's length, * and `fit`: the characters one ROW may hold in its role -- the first tier * (`head`) in the line's role, a `break` in `sub`, the tail and its gap * counted TWICE on the row that carries it (the row is centred by its words * and the tail hangs off the right, so it needs that room on both sides of * the centre). A row wider than 80 % of the frame * shrinks to its role's floor (TEASER_TYPE in chrome-teaser.mjs) and no * further, so a longer row spilled past the frame's edges. Measured at the * floor in the face, on ordinary headline words in capitals: the title holds * 36–37 there, the kicker 58–60, the overline 65–67, the second tier 69–71; * each limit is a little under. A row of only wide capitals (M, W) can still * spill at these counts. */ export const TEASER_LIMITS = Object.freeze({ lines: [1, 8], rows: 5, seconds: [3, 20], chars: 80, tail: 8, hold: 4, fit: Object.freeze({ overline: 64, title: 34, kicker: 56, sub: 66 }), }); const LINE_KEYS = ["text", "break", "replace", "role", "hold", "together", "lead"]; /** The roles a line may be drawn in; a line's `role` names one of them. */ export const TEASER_ROLES = Object.freeze(["overline", "title", "kicker"]); /** * A teaser's lines, normalised: `{ text, head, sub, role, row }` each, `head` * the part drawn on the first tier and `sub` the second tier (`break`) or null. * Trims; assumes `validateTeaser` passed. * * ROWS, NOT LINES, HAVE POSITIONS. A line with `replace: true` takes the row * of the line before it -- it slams in where that one was and pushes it out * (`teaserTimes` gives the pushed line its `outAt`) -- so a row can say * several things in turn: an estimate, then the next one. `row` is the line's * row, and roles follow the ROW's position as they always followed the line's * (with three or more rows the first is the overline, the last the kicker, * the rest titles). A line's own `role` overrides that; a replacing line * without one takes the role of the line it replaces. `replace` and `hold` * (seconds held after the line before the next one pops) are present only * when set, so an entry that uses neither normalises exactly as it did. So is * `together`: a line with a `break` whose second tier pops WITH it, on its one * hit, rather than a beat-fraction later on a lighter hit of its own. And * `lead`: a small wide-tracked tier ABOVE the line (a label: "Breaking * ground" over "2027"), drawn as a second tier is and popping with its line. * * @returns {Array<{ text: string, head: string, sub: string|null, role: "overline"|"title"|"kicker", * row: number, replace?: true, hold?: number, together?: true, lead?: string }>} */ export function teaserLines(entry) { const lines = Array.isArray(entry?.lines) ? entry.lines : []; let row = -1; const rowOf = lines.map((l, i) => (i > 0 && isObj(l) && l.replace === true ? row : (row += 1))); const n = row + 1; const out = []; lines.forEach((l, i) => { const text = String(isObj(l) ? l.text ?? "" : l ?? "").trim(); const brk = isObj(l) && typeof l.break === "string" ? l.break.trim() : ""; const sub = brk && text.endsWith(brk) && text.length > brk.length ? brk : null; const head = sub ? text.slice(0, text.length - sub.length).trim() : text; const r = rowOf[i]; const replace = i > 0 && isObj(l) && l.replace === true; const byRow = n >= 3 ? (r === 0 ? "overline" : r === n - 1 ? "kicker" : "title") : n === 2 ? (r === 0 ? "overline" : "title") : "title"; const own = isObj(l) && TEASER_ROLES.includes(l.role) ? l.role : null; const role = own ?? (replace ? out[i - 1].role : byRow); const hold = isObj(l) && typeof l.hold === "number" && l.hold > 0 ? l.hold : null; const together = !!sub && isObj(l) && l.together === true; const lead = isObj(l) && typeof l.lead === "string" && l.lead.trim() ? l.lead.trim() : null; out.push({ text, head, sub, role, row: r, ...(replace ? { replace: true } : {}), ...(hold ? { hold } : {}), ...(together ? { together: true } : {}), ...(lead ? { lead } : {}), }); }); return out; } /** The teaser's tail, trimmed, or "" for none. */ export const teaserTail = (entry) => (typeof entry?.tail === "string" ? entry.tail.trim() : ""); /** * What a teaser is called where a cut names its entries -- its chapter, and * its row in umtool: the lines joined with " — ", the tail after the last. */ export function teaserTitle(entry) { const texts = teaserLines(entry).map((l) => l.text).filter(Boolean); const tail = teaserTail(entry); if (tail && texts.length) texts[texts.length - 1] = `${texts[texts.length - 1]} ${tail}`; return texts.join(" — "); } /** * The teaser's motion, in seconds -- ONE copy, read by the composition (the * cues, chrome-teaser.mjs) and by the build (the hits under them). The first * line lands `first` into the card (after the incoming dissolve); each next * one `gap` after the one before, or after its second tier, which pops `sub` * after its first. A line slams in from `slam`× its size and blurred and hits * -- undershooting to `under` -- `hit` after it starts, then settles to rest * over `settle`. The tail starts `tailAfter` after the last line's pop and * fades in over `tailDur`. The last `endRoom` seconds hold still for the * cut's end fade. `gap` is the default beat; an entry's `beat` replaces it * (`teaserMotion`). */ export const TEASER_MOTION = Object.freeze({ first: 0.55, gap: 0.7, sub: 0.3, slam: 1.42, under: 0.968, hit: 0.2, settle: 0.5, blur: 18, tailAfter: 0.8, tailDur: 1.7, endRoom: 1.2, push: 1.065, grainHz: 12, // A line pushed out of its row by the next (`replace`): over `out` seconds // from that one's pop it rises `outRise` px, shrinks to `outScale` and blurs // to `outBlur` px as it fades -- gone before the new line's slam lands. out: 0.3, outRise: 64, outScale: 0.9, outBlur: 12, }); /** The beats an entry's `beat` may be: from 0.4 s (packed) to 2.5 s (a pause between each). */ export const TEASER_BEAT = Object.freeze({ min: 0.4, max: 2.5 }); /** An entry's `tailWait` may be: seconds from the last hit to the tail's entrance. */ export const TEASER_TAIL_WAIT = Object.freeze({ min: 0.3, max: 6 }); /** * The seconds from a teaser's last hit -- its last line's impact, or that * line's second tier's pop when it has one (where its hit sounds) -- to the * tail's entrance: the entry's `tailWait`, else what the beat gives * (`tailAfter` counted from the last pop, less the slam's `hit` when the last * pop is a slam). Null without a tail. The ferret kicker at beat 1.05: 1.0 s. */ export function teaserTailWait(entry) { if (!teaserTail(entry)) return null; if (entry?.tailWait !== undefined && entry?.tailWait !== null) return Number(entry.tailWait); const m = teaserMotion(entry?.beat); const lines = teaserLines(entry); const last = lines[lines.length - 1]; const lastSub = !!last?.sub && !last.together; return Math.round((m.tailAfter - (lastSub ? 0 : m.hit)) * 10000) / 10000; } /** * The motion at an entry's beat: `gap` is the beat, and the two waits that * read as part of it scale with it in the default's proportion -- the second * tier's `sub` stays 3/7 of the beat (0.3 s of 0.7) and the tail's * `tailAfter` 8/7 (0.8 s of 0.7), so a slower beat is the same rhythm slowed, * not three faster pops with longer pauses between. What is NOT the beat stays * put: the first line's landing (`first`, timed to the incoming dissolve), the * slam's `hit` and `settle`, the tail's fade (`tailDur`, the swell under it) * and the end fade's room. No beat, or the default's own, is TEASER_MOTION * itself -- an entry without `beat` composes the page it always did. */ export function teaserMotion(beat) { const m = TEASER_MOTION; if (beat === undefined || beat === null || Number(beat) === m.gap) return m; const k = Number(beat) / m.gap; const r = (v) => Math.round(v * 10000) / 10000; return Object.freeze({ ...m, gap: r(Number(beat)), sub: r(m.sub * k), tailAfter: r(m.tailAfter * k) }); } /** * When everything in a teaser happens, in the card's clock: per line its * start (`at`), its impact (`impact` = at + hit, where the slam lands, the * flash fires and the hit sounds) and its second tier's pop (`subAt`, null * without one); the tail's start and length; `end`, when the last thing has * arrived; and `need`, the card's least length -- `end` plus the end fade's * still room. Nothing is compressed: a card shorter than `need` is refused by * `validateTeaser`, never squeezed. `scale` is always 1 and `T` only rounds; * both stay because the page carries `scale` in its data and the cues are * written through `T` -- an unchanged teaser's page, and so its render key, * are unchanged. * * @returns {{ lines: Array<{ at: number, impact: number, subAt: number|null }>, * tailAt: number|null, tailDur: number, end: number, need: number, scale: number, * T: (v: number) => number }} */ export function teaserTimes(lines, tail, m = TEASER_MOTION) { let t = m.first; const raw = []; lines.forEach((l, i) => { if (i > 0) t += m.gap + (lines[i - 1].hold ?? 0); const at = t; // `together`: the second tier pops with its line (one pop, one hit). const subAt = l.sub ? at + (l.together ? 0 : m.sub) : null; if (subAt != null) t = subAt; raw.push({ at, subAt }); }); // A line held after its pop holds the tail back too. t += lines[lines.length - 1]?.hold ?? 0; const tailRaw = tail ? t + m.tailAfter : null; const endRaw = tailRaw != null ? tailRaw + m.tailDur : t + m.hit + m.settle; const r = (v) => Math.round(v * 10000) / 10000; const T = r; return { // `outAt`: a line the next one replaces is pushed out as that one pops. // Only on such a line, so a teaser without `replace` times as it did. lines: raw.map((b, i) => ({ at: T(b.at), impact: r(T(b.at) + T(m.hit)), subAt: b.subAt == null ? null : T(b.subAt), ...(lines[i + 1]?.replace ? { outAt: T(raw[i + 1].at) } : {}), })), tailAt: tailRaw == null ? null : T(tailRaw), tailDur: T(m.tailDur), end: T(endRaw), need: r(endRaw + m.endRoom), scale: 1, T, }; } // ---- the dip: the cut goes to black before a teaser ------------------------ // // { "type": "teaser", "id": "fin", "dip": { "fade": 1.2, "black": 0.6 }, … } // // The previous segment's last `fade` seconds -- the WHOLE frame as the cut // plays it, footage and every overlay, and its sound -- ease to black and // silence, ending on its last frame; then `black` seconds of black; then the // teaser comes up out of it. The black is the teaser's own LEAD // (`teaserLead`): its page and its sound start with the dissolve into it and // the black, both dark, so the dissolve is black on black and no hold is // needed anywhere. The fade is made where the cut is joined (build-video's // `dipWindows`), so `--chrome-only` changes it without touching a clip. /** A dip's limits, in seconds: the fade out, and the black after it. */ export const DIP_LIMITS = Object.freeze({ fade: Object.freeze([0.3, 4]), black: Object.freeze([0, 3]) }); /** * The rise out of a dip, in seconds around the first line's impact: the veil * over the ground starts lifting `before` it, as the black ends, and is gone * `after` it -- so the first hit is the moment the light comes on. The first * line's slam starts `before − hit` after the black (0.15 s). */ export const DIP_RISE = Object.freeze({ before: 0.35, after: 0.3 }); /** The riser under the black: at most `seconds` long, ending on the first hit; a noise swell over a low sub. */ export const DIP_RISER = Object.freeze({ seconds: 1, gain: 0.22, f0: 30, f1: 55 }); const DIP_KEYS = ["fade", "black"]; /** * Why one entry's `dip` cannot be built, as sentences. Only a teaser dips (for * now); `fade` and `black` are both required, in DIP_LIMITS. Absent is fine. */ export function validateDip(entry, where = `timeline entry ${entry?.id ?? "?"}`) { const v = entry?.dip; if (v === undefined || v === null) return []; if (entry.type !== "teaser") { return [`${where}.dip: only a teaser dips to black before it -- move the dip onto the teaser that follows`]; } if (!isObj(v)) return [`${where}.dip must be { fade, black } in seconds`]; const errors = []; for (const k of Object.keys(v)) if (!DIP_KEYS.includes(k)) errors.push(`${where}.dip.${k} is not a dip setting (fade, black)`); const [flo, fhi] = DIP_LIMITS.fade; const [blo, bhi] = DIP_LIMITS.black; if (!numIn(v.fade, flo, fhi)) errors.push(`${where}.dip.fade must be from ${flo} to ${fhi} seconds`); if (!numIn(v.black, blo, bhi)) errors.push(`${where}.dip.black must be from ${blo} to ${bhi} seconds`); return errors; } /** * Seconds as a whole number of frames at `fps`: unchanged when they already * are one (0.6 at 30 fps stays 0.6, byte for byte), else the nearest frame, * to the ten-thousandth. */ export function snapToFrames(seconds, fps = 30) { const n = Math.round(seconds * fps); if (Math.abs(seconds * fps - n) < 1e-6) return seconds; return Math.round((n / fps) * 10000) / 10000; } /** * An entry's dip, `{ fade, black }`, or null: only a teaser's, and only a * sound one. `black` is snapped to whole frames at `fps` (`snapToFrames`), so * the teaser's lead, its page's rise, its frame count and the joined cut's * black (`dipWindows`' `until`) all fall on the same frame. */ export function dipOf(entry, fps = 30) { if (entry?.type !== "teaser" || !isObj(entry.dip)) return null; const { fade, black } = entry.dip; if (!numIn(fade, ...DIP_LIMITS.fade) || !numIn(black, ...DIP_LIMITS.black)) return null; return { fade, black: snapToFrames(black, fps) }; } /** * The dark start of a teaser that dips, in its own clock: the dissolve into it * (`D`, the cut's transition) plus the dip's black (whole frames at `fps`). * Its page is black and its sound silent (but for the riser) until then; 0 * without a dip. */ export function teaserLead(entry, D = 0.5, fps = 30) { const dip = dipOf(entry, fps); return dip ? Math.round((D + dip.black) * 10000) / 10000 : 0; } /** * The cut second the deck and the feed are gone at, over a teaser that dips: * the previous segment's last frame (the dissolve's end less a frame), which * the dip has made black. `seg` is the teaser's schedule segment. */ export function dipHideAt(seg, D, fps = 30) { return Math.round((seg.start + D - 1 / fps) * 10000) / 10000; } /** * The motion of an entry's teaser in its SEGMENT's clock: `teaserMotion` of its * beat, and with a dip the first line's start moved to `lead + before − hit` * -- after the black, timed to the rise (DIP_RISE) instead of the incoming * dissolve. Without a dip it is `teaserMotion(beat)` itself. */ export function teaserMotionOf(entry, D = 0.5, fps = 30) { return dippedMotion(entry, teaserLead(entry, D, fps)); } /** * `teaserMotion(beat)`, its first line moved to `lead + before − hit` when the * entry dips, and its `tailAfter` set from the entry's `tailWait` when it has * one (`teaserTailWait`, counted from the last hit). Without either it is * `teaserMotion(beat)` itself. */ function dippedMotion(entry, lead) { let m = teaserMotion(entry?.beat); const r = (v) => Math.round(v * 10000) / 10000; if (entry?.tailWait !== undefined && entry?.tailWait !== null && teaserTail(entry)) { const lines = teaserLines(entry); const last = lines[lines.length - 1]; const lastSub = !!last?.sub && !last.together; m = Object.freeze({ ...m, tailAfter: r(Number(entry.tailWait) + (lastSub ? 0 : m.hit)) }); } if (!dipOf(entry)) return m; return Object.freeze({ ...m, first: r(lead + DIP_RISE.before - m.hit) }); } /** * The card's length -- from where the light comes up, after any dip's black: * its `seconds` when it sets one, else what its beats need (`teaserTimes(...).need`: * the last pop, the tail's fade, the end fade's still room), rounded UP to a * tenth of a second and at least the shortest a teaser may be. The same * whatever the transition. Assumes `validateTeaser` passed. */ export function teaserCardSeconds(entry) { if (entry?.seconds !== undefined && entry?.seconds !== null) return Number(entry.seconds); const need = teaserTimes(teaserLines(entry), teaserTail(entry), dippedMotion(entry, 0)).need; return Math.max(TEASER_LIMITS.seconds[0], Math.ceil(need * 10 - 1e-6) / 10); } /** * A teaser's length in seconds: its lead (`teaserLead`: the dissolve and a * dip's black, 0 without a dip) and its card (`teaserCardSeconds`). `D` is * the cut's transition, read only for a dip. Without a dip it is * `teaserCardSeconds`, as it always was. Assumes `validateTeaser` passed. */ export function teaserSeconds(entry, D = 0.5, fps = 30) { const lead = teaserLead(entry, D, fps); return lead ? Math.round((lead + teaserCardSeconds(entry)) * 10000) / 10000 : teaserCardSeconds(entry); } /** * The teaser's sound design, as data: one trailer hit under each pop, at the * moment the composition says it lands, and a low swell under the tail's * slow entrance. Empty when `hits: false`. * * Gains are relative (the main title is 1): a title's hit is the biggest, an * overline's and a kicker's a little smaller, a second tier's lighter and * shorter. The build turns this into one ffmpeg graph (`teaserAudioGraph`). * * A teaser that dips (`D` is the cut's transition, read only then) has every * time in its segment's clock, after the lead (`teaserMotionOf`), and a riser * first: DIP_RISER's sub and noise swelling up through the black for at most * `seconds`, ending on the first line's impact. * * @returns {Array<{ kind: "hit"|"swell"|"riser", at: number, role: string, gain: number, * decay: number, f0: number, f1: number, dur?: number }>} */ export function teaserHits(entry, D = 0.5, fps = 30) { if (entry?.hits === false) return []; const lines = teaserLines(entry); const tail = teaserTail(entry); const times = teaserTimes(lines, tail, teaserMotionOf(entry, D, fps)); const out = []; if (dipOf(entry) && times.lines.length) { const end = times.lines[0].impact; const at = Math.round(Math.max(0, end - DIP_RISER.seconds) * 10000) / 10000; const { gain, f0, f1 } = DIP_RISER; out.push({ kind: "riser", at, role: "dip", gain, decay: 0.04, f0, f1, dur: Math.round((end - at) * 10000) / 10000 }); } const HIT = { title: { gain: 1, decay: 0.42, f0: 92, f1: 40 }, overline: { gain: 0.72, decay: 0.34, f0: 96, f1: 44 }, kicker: { gain: 0.8, decay: 0.36, f0: 94, f1: 42 }, sub: { gain: 0.42, decay: 0.16, f0: 120, f1: 64 }, }; lines.forEach((l, i) => { out.push({ kind: "hit", at: times.lines[i].impact, role: l.role, ...HIT[l.role] }); if (l.sub && !l.together && times.lines[i].subAt != null) { out.push({ kind: "hit", at: times.lines[i].subAt, role: "sub", ...HIT.sub }); } }); if (tail && times.tailAt != null) { out.push({ kind: "swell", at: times.tailAt, role: "tail", gain: 0.34, decay: 0.7, f0: 46, f1: 62, dur: times.tailDur }); } return out; } /** * Why one teaser entry cannot be built, as sentences (empty: it can). * * @returns {string[]} */ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?"}`) { const errors = []; const [lo, hi] = TEASER_LIMITS.lines; const [slo, shi] = TEASER_LIMITS.seconds; if (typeof entry?.id !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(entry.id)) { errors.push(`${where}.id must be letters, digits, dashes or underscores (it names the segment's file)`); } const hasSeconds = entry?.seconds !== undefined && entry?.seconds !== null; if (hasSeconds && !numIn(entry.seconds, slo, shi)) errors.push(`${where}.seconds must be from ${slo} to ${shi}, or absent`); const hasBeat = entry?.beat !== undefined && entry?.beat !== null; if (hasBeat && !numIn(entry.beat, TEASER_BEAT.min, TEASER_BEAT.max)) { errors.push(`${where}.beat must be from ${TEASER_BEAT.min} to ${TEASER_BEAT.max} seconds, or absent`); } if (entry?.tailWait !== undefined && entry?.tailWait !== null) { if (!numIn(entry.tailWait, TEASER_TAIL_WAIT.min, TEASER_TAIL_WAIT.max)) { errors.push(`${where}.tailWait must be from ${TEASER_TAIL_WAIT.min} to ${TEASER_TAIL_WAIT.max} seconds, or absent`); } else if (!(typeof entry.tail === "string" && entry.tail.trim())) { errors.push(`${where}.tailWait is the wait before the tail, and there is no tail`); } } const oneLine = (s, w) => { if (typeof s !== "string" || !s.trim()) { errors.push(`${w} must be words, not empty`); return false; } if (/[\r\n]/.test(s)) { errors.push(`${w} must be one line`); return false; } if (s.trim().length > TEASER_LIMITS.chars) { errors.push(`${w} is ${s.trim().length} characters (at most ${TEASER_LIMITS.chars})`); return false; } return true; }; const lines = entry?.lines; if (!Array.isArray(lines) || lines.length < lo || lines.length > hi) { errors.push(`${where}.lines must be a list of ${lo} to ${hi} lines`); } else { // What the frame holds is ROWS: a replacing line shares the row before it. const rows = lines.filter((l, i) => !(i > 0 && isObj(l) && l.replace === true)).length; if (rows > TEASER_LIMITS.rows) { errors.push( `${where}.lines make ${rows} rows, and at most ${TEASER_LIMITS.rows} fit the frame ` + `-- set "replace": true on a line to have it take the row of the line before it`, ); } lines.forEach((l, i) => { const w = `${where}.lines[${i}]`; if (typeof l === "string") { oneLine(l, w); return; } if (!isObj(l)) { errors.push(`${w} must be a string or { text, break, replace, role, hold, together, lead }`); return; } for (const k of Object.keys(l)) if (!LINE_KEYS.includes(k)) errors.push(`${w}.${k} is not a teaser line field`); if (l.replace !== undefined && l.replace !== null) { if (typeof l.replace !== "boolean") errors.push(`${w}.replace must be true or false`); else if (l.replace && i === 0) errors.push(`${w}.replace: the first line has no line before it to replace`); } if (l.role !== undefined && l.role !== null && !TEASER_ROLES.includes(l.role)) { errors.push(`${w}.role must be one of ${TEASER_ROLES.join(", ")}`); } if (l.together !== undefined && l.together !== null) { if (typeof l.together !== "boolean") errors.push(`${w}.together must be true or false`); else if (l.together && !(typeof l.break === "string" && l.break.trim())) { errors.push(`${w}.together pops the second tier with its line, and it has no break`); } } if (l.lead !== undefined && l.lead !== null && oneLine(l.lead, `${w}.lead`) && l.lead.trim().length > TEASER_LIMITS.fit.sub) { errors.push(`${w}.lead is ${l.lead.trim().length} characters, and at most ${TEASER_LIMITS.fit.sub} fit the frame as a small tier`); } if (l.hold !== undefined && l.hold !== null && !numIn(l.hold, 0, TEASER_LIMITS.hold)) { errors.push(`${w}.hold must be from 0 to ${TEASER_LIMITS.hold} seconds`); } if (!oneLine(l.text, `${w}.text`)) return; if (l.break === undefined || l.break === null) return; if (!oneLine(l.break, `${w}.break`)) return; const text = l.text.trim(); const brk = l.break.trim(); if (!text.endsWith(brk)) errors.push(`${w}.break must be the end of its text ("${brk}" is not how "${text}" ends)`); else if (!text.slice(0, text.length - brk.length).trim()) errors.push(`${w}.break leaves nothing for the first tier`); }); } if (entry?.hits !== undefined && typeof entry.hits !== "boolean") errors.push(`${where}.hits must be true or false`); errors.push(...validateDip(entry, where)); if (entry?.tail !== undefined && entry?.tail !== null) { if (typeof entry.tail !== "string" || !entry.tail.trim()) errors.push(`${where}.tail must be a short string, or absent`); else if (/[\r\n]/.test(entry.tail)) errors.push(`${where}.tail must be one line`); else if (entry.tail.trim().length > TEASER_LIMITS.tail) { errors.push(`${where}.tail is ${entry.tail.trim().length} characters (at most ${TEASER_LIMITS.tail})`); } } // What fits the frame, row by row, once every line and the tail are sound. if (!errors.length) { const rows = teaserLines(entry); const tail = teaserTail(entry); const fit = TEASER_LIMITS.fit; // The row that carries the tail is centred by its own words and the tail // hangs off its right: the row needs the tail and its gap on BOTH sides. rows.forEach((l, i) => { const w = `${where}.lines[${i}]`; const tailHere = tail && i === rows.length - 1 ? 2 * (tail.length + 1) : 0; const head = l.head.length + (l.sub ? 0 : tailHere); if (head > fit[l.role]) { errors.push( `${w} ${l.sub ? "before its break " : ""}is ${head} characters${!l.sub && tailHere ? " with the tail" : ""}, ` + `and at most ${fit[l.role]} fit the frame as the ${l.role}` + (l.sub ? "" : " -- shorten it, or set its end as a break"), ); } if (l.sub && l.sub.length + tailHere > fit.sub) { errors.push( `${w}.break is ${l.sub.length + tailHere} characters${tailHere ? " with the tail" : ""}, ` + `and at most ${fit.sub} fit the frame as the second tier`, ); } }); } // The length the beats need, once the lines, the tail and the beat are // sound: never squeezed, so a `seconds` short of it is refused with it, and // a card that needs more than a teaser may run says so. // With a dip, `seconds` and the need are the card's, from where the light // comes up -- the lead before it is the dip's, not the lines'. if (!errors.length) { const m = dippedMotion(entry, 0); const need = teaserTimes(teaserLines(entry), teaserTail(entry), m).need; const least = teaserCardSeconds({ ...entry, seconds: undefined }); const at = `at a beat of ${m.gap}s`; if (hasSeconds && entry.seconds < need - 1e-9) { errors.push( `${where}.seconds is ${entry.seconds}, and ${at} its lines need ${least}s ` + `(the last pop, the tail's fade and ${TEASER_MOTION.endRoom}s still for the end fade` + `${dipOf(entry) ? ", counted from the end of the dip's black" : ""}) -- ` + `set it to ${least} or more, or leave it out for exactly that`, ); } else if (!hasSeconds && least > shi) { errors.push(`${where} needs ${least}s ${at}, and a teaser runs at most ${shi}s -- a shorter beat, or fewer lines`); } } return errors; } /** Every teaser in the timeline, checked: the build refuses with these before it fetches. */ export function validateTeasers(manifest) { const errors = []; (manifest?.timeline ?? []).forEach((e, i) => { if (e?.type !== "teaser") return; const where = `timeline[${i}] (${e.id ?? "?"})`; errors.push(...validateTeaser(e, where)); // A dip fades the segment before the teaser: the first entry has none. if (i === 0 && e.dip !== undefined && e.dip !== null) { errors.push(`${where}.dip: a dip fades the entry before the teaser to black, and the first entry has none before it`); } }); return errors; } // --------------------------------------------------------------------------- // The render cache and the renderer command. // --------------------------------------------------------------------------- export const sha256 = (data) => createHash("sha256").update(data).digest("hex"); /** * The deck render's cache key: the composition HTML, every asset by content * hash (name-sorted), the fps, the frame count and the renderer version. Same * key and the same number of frames on disk = skip the render. */ export function chromeCacheKey({ html, assets = [], fps, frames, version }) { const sorted = [...assets].map(([n, h]) => [String(n), String(h)]).sort((x, y) => x[0].localeCompare(y[0])); return sha256(JSON.stringify({ v: 1, html: sha256(html), assets: sorted, fps, frames, version })); } /** Frames a render of `total` seconds at `fps` produces. */ export const frameCount = (total, fps) => Math.round(total * fps); /** * The `data-duration` a page states for `seconds` at `fps`: its whole frames * (`frameCount`) back in seconds, FLOORED to 4 decimals. HyperFrames renders * ceil(duration × fps) frames, so a duration rounded UP past a frame boundary * -- a cut of 434 frames is 14.4667 s, and its schedule's 14.467 -- rendered * one frame more than the build expects (435) and the build refused it. A * duration already on the 4-decimal grid of whole frames (14.4, 7.9, 356.7) is * written as it always was. */ export const pageDuration = (seconds, fps) => Math.floor((frameCount(seconds, fps) / fps) * 10000 + 1e-6) / 10000; /** * How to invoke HyperFrames. `HYPERFRAMES_BIN` (an executable taking the CLI's * own arguments, e.g. an e2e stub) wins; else `npx --yes `, * pinned by default. `version` is what the cache key records. */ export function hyperframesCommand(env = process.env) { if (env.HYPERFRAMES_BIN) { return { cmd: env.HYPERFRAMES_BIN, args: [], version: `bin:${env.HYPERFRAMES_BIN}` }; } const pkg = env.HYPERFRAMES_PKG ?? HYPERFRAMES_PKG_DEFAULT; return { cmd: "npx", args: ["--yes", pkg], version: pkg }; }