// The burned-in attribution line, in ONE place. // // Three programs have to agree on this string to the character: build-video.mjs // draws it into the frame, the clip bench previews it while somebody edits the // fields, and manifest.mjs decides which of those fields it will accept. When // they were three implementations the bench could show a line the renderer // would never produce -- and the only way to find out was a twenty-minute // build. // // So: no node builtins in this file. It is imported by a CLIENT component as // well as by the pipeline, and anything that reaches for `fs` here would take // the preview down with it. // Stream titles are full of emoji and !commands. drawtext renders them as tofu // with a text font, and they add nothing to an attribution line. export function cleanTitle(title) { return String(title ?? "") .replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{FE0F}]/gu, "") .replace(/\s*[!@]\S+/g, "") .replace(/\s{2,}/g, " ") .replace(/[\s·|-]+$/, "") .trim(); } /** The clock in the header: whole seconds, hours only when there are any. */ export function hms(total) { const s = Math.floor(Math.max(0, Number(total) || 0)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` : `${m}:${String(sec).padStart(2, "0")}`; } export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; /** * `YYYY-MM-DD`, and a day that exists. * * The regex alone accepts 2025-02-31, which reads as a date right up until * somebody tries to check the clip against the stream it claims to come from. */ export function isCalendarDate(s) { const v = String(s ?? ""); if (!DATE_RE.test(v)) return false; const [y, m, d] = v.split("-").map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d; } /** yt-dlp's `20250101` as the header writes it. Anything else passes through. */ export function uploadDateToIso(d) { const s = String(d ?? ""); return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : s; } /** * WHO is speaking, for the head of the header line. * * A compilation routinely spans several channels -- a streamer's own channel, * two VOD mirrors, a guest's show -- and a line that opens with a title alone * makes the viewer work out which of them they are watching from the furniture. * So the channel comes first and every clip in a cut reads the same way. * * Four answers, in order, and each exists because the one after it is wrong * somewhere: * * 1. `entry.channelTitle` — the per-clip override. A record's uploader name * can be a handle, a rebrand or an ALL-CAPS mirror name; this is the * author saying what to call it in this cut. * 2. `meta.channel` — the uploader name the cue record already carries, local * or from a published shard. Free: the record is loaded for the title and * the upload date anyway, so no lookup is added to a build. * 3. `provenance.channel` — the sweep's own display name, when this clip comes * from the sweep's own channel. A hand-written manifest has it even when a * cue record predates the field. * 4. The raw slug. Ugly on screen and meant to be: it is visibly a thing to * fix, where a silently empty head would just look like a design. * * @param {{ channelTitle?: string|null, channel?: string|null }} entry * @param {{ channel?: string|null }} [meta] * @param {{ channel?: string|null, channelSlug?: string|null }} [provenance] */ export function channelName(entry, meta = {}, provenance = {}) { const override = String(entry?.channelTitle ?? "").trim(); if (override) return override; const record = String(meta?.channel ?? "").trim(); if (record) return record; const slug = String(entry?.channel ?? provenance?.channelSlug ?? "").trim(); const own = String(provenance?.channel ?? "").trim(); if (own && (!entry?.channel || slug === String(provenance?.channelSlug ?? "").trim())) { return own; } return slug; } /** * The line burned into the header, from a clip entry and its source's metadata. * * `${channel} · ${title} · ${date} @ ${h:mm:ss}`. * * A clip's own `channelTitle`, `title` and `date` WIN. The header otherwise * prints the upload date of the archived copy, which for a VOD mirror is often * years after the stream — so the override is how a mirrored clip shows the * stream's own date. * * **The channel is not optional furniture.** Before it was there, a cut that * spanned a streamer's channel and two mirrors made every line look like the * same source, and the only way to tell a guest's show from the host's was to * recognise the room. Nothing else on screen says who is talking. * * @param {{ title?: string|null, date?: string|null, cite?: number|null, start?: number|null, channel?: string|null, channelTitle?: string|null }} entry * @param {{ title?: string|null, uploadDate?: string|null, channel?: string|null }} meta * @param {{ channel?: string|null, channelSlug?: string|null }} [provenance] */ export function attributionLine(entry, meta, provenance = {}) { const { channel, title, date, at } = attributionParts(entry, meta, provenance); // A cut with no channel to name anywhere reproduces the old line exactly, // rather than opening on a stray separator. const head = [channel, title].filter((s) => String(s ?? "").length > 0).join(" · "); return `${head} · ${date} @ ${hms(at)}`; } /** * The header line's four facts, resolved and NOT joined: who, which recording, * when, and the cited second. `attributionLine` joins them for the burned-in * header; the deck's subtitle (`deckSubtitle`) picks from them. One resolver, * so the two can never disagree about a clip's date or channel. * * @returns {{ channel: string, title: string, date: string, at: number }} */ export function attributionParts(entry, meta, provenance = {}) { return { channel: channelName(entry, meta, provenance), title: entry.title ?? cleanTitle(meta.title), date: entry.date ?? uploadDateToIso(meta.uploadDate), at: entry.cite ?? entry.start ?? 0, }; } const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; /** * A `YYYY-MM-DD` as the deck prints it. "long" is `Aug 14, 2026` -- spelled * out by hand, not by Intl, so a render does not depend on the machine's * locale. Anything that is not a real calendar date passes through unchanged. */ export function formatDeckDate(d, format = "long") { const s = String(d ?? ""); if (format === "iso" || !isCalendarDate(s)) return s; const [y, m, day] = s.split("-").map(Number); return `${MONTHS[m - 1]} ${day}, ${y}`; } /** * The deck's automatic subtitle: the source and its date, from * `attributionParts`. `parts: "auto"` is title and date, with the channel at * the head only when the cut spans more than one channel (one channel * throughout is furniture); an explicit list picks from channel/title/date/ * clock in the order given. Empty parts are dropped, never left as a dangling * separator. An entry's `onscreen.subtitle` replaces all of this -- that is the * caller's decision, not this function's. * * @param {{ channel?: string, title?: string, date?: string, at?: number|null }} parts * @param {{ parts?: "auto"|string[], dateFormat?: "long"|"iso" }} [opts] * @param {boolean} [multiChannel] */ export function deckSubtitle(parts, opts = {}, multiChannel = false) { const want = !opts.parts || opts.parts === "auto" ? (multiChannel ? ["channel", "title", "date"] : ["title", "date"]) : opts.parts; const value = { channel: () => parts.channel, title: () => parts.title, date: () => formatDeckDate(parts.date, opts.dateFormat ?? "long"), clock: () => (parts.at === null || parts.at === undefined ? "" : hms(parts.at)), }; return want .map((k) => String(value[k]?.() ?? "").trim()) .filter(Boolean) .join(" · "); } /** * The same header line, for a still IMAGE entry. * * An image has no clock: there is no moment inside it to cite, so the ` @ h:mm:ss` * a clip carries would be a number pointing at nothing. What is left is the * author's own caption and, when the picture is dated, its date -- in the same * ` · ` shape, so a still sitting between two clips reads as one more line of the * same header rather than as a different program's output. * * The title is NOT run through `cleanTitle`: a clip's title comes from an * archive record full of emoji and !commands, but an image's is typed by the * author for this cut and is already the line they meant. * * Both fields absent yields "", which the renderer reads as "draw no header" -- * an empty textfile is the one thing drawtext refuses outright. * * @param {{ title?: string|null, date?: string|null }} entry */ export function imageAttributionLine(entry) { const title = String(entry?.title ?? "").trim(); const date = String(entry?.date ?? "").trim(); return [title, date].filter(Boolean).join(" · "); }