// A report-to-video MANIFEST ↔ `report.json`, both ways. The video and the // report page can be cut from one source: a manifest becomes a report, and a // report becomes a starter manifest to refine in umtool. // // The manifest is umtool/report-to-video's (its README, "Manifest shape"). // What maps to what: // // manifest report // ──────────────────────────────────────── ────────────────────────────────────── // slug, title, subtitle id, title, subtitle // card (style "chapter", or none) a section: heading → title, sub → body // clip (channel ?? provenance.channelSlug, a video citation, id = the clip's id; // video, cutStart ?? start, an `audioOnly` clip an audio one // cutEnd ?? end, quote, date, note) // image (src, or its first panel; quote, a source citation (its still = src) // title, date, citeUrl, note) and a source of its own // posts[] (siteChannel, its id, text, date) a post citation, id = the post's id // `claim: { id, verdict }` on entries a claim with that verdict; its first // image is its source sentence // ledger[] (id, quote, label, date, a claim with no verdict (text = the // entryId, channel, video, cite) quote, title = the label) // // An entry that carries no claim is cited in its section's body, one line // each. A post goes with the clip it is attached to (`attachTo`, else the // clip it follows by date, else the first) — under that clip's claim, or in // its section's body. A claim's text is its ledger entry's quote, else the // heading of a card that carries it, else its source sentence, else its first // clip's quote. A report with any verdict is a fact-check, else a sweep. // // Left out, with a warning: a clip with its own media (`src`: no record to // cite), an entry without a quote, a post no archive channel is known to keep. // A manifest has more than a report (render settings, holds, the deck, cut // edits, redactions, the ledger's adjudication) and a report more than a // manifest (findings, a claim's own title, citation pads, labels, speakers); // neither survives the trip. Image paths are written as the manifest has // them: relative to ITS directory (the CLI says so when the report is written // elsewhere). // // A report → a STARTER manifest: no cold open — a title card, then per section // a chapter card, its body's citations in order, and per claim its source // sentence's still (an `image`) and its video and audio citations (`clip`s), // each carrying `claim: { id, verdict }` when the claim has a verdict, and its // posts in `posts[]` attached to the claim's last clip. Spans are written as // the report has them, so `resolve-windows.mjs` and the clip bench start from // the cited sentences. A post needs its platform, its link and its date: from // `postOf` (the CLI reads the channel's archive) or, for a numeric X id, its // `x.com/i/status/` link and the citation's date; one that has none of them is // left out with a warning. import type { Citation, Source, SourceCitation } from "../citations/schema"; import { isHttpUrl, isPartialDate } from "../citations/validate"; import { IdAllocator, emptyReport, finish, citedIds, reportIdOf, spanAt, type CitationMap, type ConvertContext, type Converted, } from "./convertShared"; import type { Claim, Report, Section } from "./schema"; import { isVerdict, type Verdict } from "./verdicts"; import { parseReport } from "./validate"; import { reportFullTitle } from "./views"; import type { Problem } from "../citations/validate"; type Obj = Record; const isObj = (v: unknown): v is Obj => !!v && typeof v === "object" && !Array.isArray(v); const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v : undefined); const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined); export type ManifestConvertOptions = ConvertContext & { id?: string; title?: string }; // A manifest entry's claim, when it carries a sound one. function claimOfEntry(e: Obj): { id: string; verdict: Verdict } | null { if (!isObj(e.claim) || e.type === "teaser") return null; const id = str(e.claim.id)?.trim(); return id && isVerdict(e.claim.verdict) ? { id, verdict: e.claim.verdict } : null; } // The id a post has on its platform: `postId`, else the link's. export function postNativeId(post: Obj): string | null { const given = str(post.postId); if (given && /^[A-Za-z0-9_-]{1,128}$/.test(given)) return given; try { const u = new URL(String(post.url ?? "")); const m = /\/post\/([A-Za-z0-9]+)\/?$/.exec(u.pathname) ?? /\/status(?:es)?\/(\d+)(?:\/|$)/.exec(u.pathname); return m ? m[1] : null; } catch { return null; } } const dayOf = (d: unknown) => (typeof d === "string" && /^\d{4}-\d{2}-\d{2}/.test(d) ? d.slice(0, 10) : null); // The clip a post goes with, as the build attaches it: `attachTo`, else the // clip whose date most closely precedes the post's (ties to the later in the // cut), else the first. function clipForPost(post: Obj, clips: Obj[]): Obj | null { const named = str(post.attachTo); if (named) { const c = clips.find((e) => e.id === named); if (c) return c; } const day = dayOf(post.date); let best: Obj | null = null; if (day) { for (const c of clips) { const cd = dayOf(c.date); if (cd && cd <= day && (!best || cd >= (dayOf(best.date) as string))) best = c; } } return best ?? clips[0] ?? null; } export async function manifestToReport(manifest: unknown, opts: ManifestConvertOptions = {}): Promise { const warnings: string[] = []; const m: Obj = isObj(manifest) ? manifest : {}; const timeline = (Array.isArray(m.timeline) ? m.timeline : []).filter(isObj); const ledger = (Array.isArray(m.ledger) ? m.ledger : []).filter(isObj); const posts = (Array.isArray(m.posts) ? m.posts : []).filter(isObj); const prov = isObj(m.provenance) ? m.provenance : {}; const title = opts.title ?? str(m.title) ?? str(m.slug) ?? "Report"; const report = emptyReport(reportIdOf(opts.id ?? str(m.slug), title), "sweep", title); if (str(m.subtitle)) report.subtitle = m.subtitle as string; const citations: CitationMap = {}; const sources: Record = {}; const citeIds = new IdAllocator(); const anchors = new IdAllocator(); const where = (e: Obj, i: number) => `timeline[${i}] ${str(e.id) ?? "?"}`; // ── The citations every entry is ── const citeOf = new Map(); for (const [i, e] of timeline.entries()) { if (e.type === "clip") { if (str(e.src)) { warnings.push(`${where(e, i)}: a clip of its own media (src) cites no record — left out`); continue; } const channel = str(e.channel) ?? str(prov.channelSlug); const video = str(e.video); const start = num(e.cutStart) ?? num(e.start); const end = num(e.cutEnd) ?? num(e.end); const quote = str(e.quote); if (!channel || !video || start === undefined || end === undefined) { warnings.push(`${where(e, i)}: a clip needs a channel, a video, a start and an end — left out`); continue; } if (!quote) { warnings.push(`${where(e, i)}: a clip without a quote — left out (a citation's quote is verbatim)`); continue; } const cid = citeIds.claim(str(e.id) ?? "c", "c"); const c: Citation = { kind: e.audioOnly === true ? "audio" : "video", channel, id: video, start, end, quote }; if (str(e.date) && isPartialDate(e.date as string)) c.date = e.date as string; if (str(e.note)) c.note = e.note as string; citations[cid] = c; citeOf.set(e, cid); } else if (e.type === "image") { const panels = Array.isArray(e.panels) ? e.panels.filter(isObj) : []; const src = str(e.src) ?? str(panels[0]?.src); if (!src) { warnings.push(`${where(e, i)}: an image with no src — left out`); continue; } if (panels.length > 1) warnings.push(`${where(e, i)}: ${panels.length} panels — the citation's still is the first`); let quote = str(e.quote); if (!quote) { quote = str(e.title) ?? str(e.id) ?? src; warnings.push(`${where(e, i)}: an image without a quote — its citation quotes its title; give it the still's words`); } const cid = citeIds.claim(str(e.id) ?? "a", "a"); const sid = citeIds.claim(`src-${cid}`); const source: Source = { kind: "other", title: str(e.title) ?? cid }; if (str(e.citeUrl) && isHttpUrl(e.citeUrl as string)) source.url = e.citeUrl as string; if (str(e.date) && isPartialDate(e.date as string)) source.date = e.date as string; sources[sid] = source; const c: SourceCitation = { kind: "source", source: sid, quote, image: src }; if (str(e.note)) c.note = e.note as string; citations[cid] = c; citeOf.set(e, cid); } } // ── Posts: each a citation, and the clip it goes with ── const clips = timeline.filter((e) => e.type === "clip" && citeOf.has(e)); const postsOfClip = new Map(); const loosePosts: string[] = []; for (const [i, p] of posts.entries()) { if (p.hide === true) continue; const native = postNativeId(p); const platform = p.platform === "bluesky" ? "bluesky" : "x"; const channel = str(p.siteChannel) ?? (native && opts.postChannelOf ? await opts.postChannelOf({ platform, id: native, handle: str(p.handle) }) : null); const text = str(p.text); if (!native || !channel || !text) { warnings.push( `posts[${i}] ${str(p.id) ?? "?"}: ${!native ? "no id on its platform" : !text ? "no text" : "no archive channel known to keep it (siteChannel)"} — left out`, ); continue; } const cid = citeIds.claim(str(p.id) ?? "p", "p"); citations[cid] = { kind: "post", channel, id: native, quote: text, ...(str(p.date) && isPartialDate(p.date as string) ? { date: p.date as string } : {}), }; const clip = clipForPost(p, clips); if (clip) postsOfClip.set(clip, [...(postsOfClip.get(clip) ?? []), cid]); else loosePosts.push(cid); } // ── Ledger claims: what the ledger says a claim is, and the clip it pins ── const ledgerById = new Map(); for (const l of ledger) if (str(l.id)) ledgerById.set(l.id as string, l); const timelineClaims = new Set(timeline.map(claimOfEntry).filter((c) => c).map((c) => c!.id)); const ledgerClaimOfClip = new Map(); for (const l of ledger) { const id = str(l.id); const pinned = str(l.entryId); if (id && pinned && !timelineClaims.has(id)) ledgerClaimOfClip.set(pinned, id); } // ── The walk: sections, claims, bodies ── const sections: { section: Section; body: string[] }[] = []; const claims = new Map(); const current = () => { if (!sections.length) { sections.push({ section: { id: anchors.claim("opening"), title: "Opening", claims: [] }, body: [] }); } return sections[sections.length - 1]; }; const claimFor = (id: string, verdict: Verdict | null) => { let k = claims.get(id); if (!k) { const claim: Claim = { id: anchors.claim(id, "k"), text: "", citations: [] }; if (verdict) claim.verdict = verdict; k = { claim, texts: [] }; claims.set(id, k); current().section.claims!.push(claim); } return k; }; const list = (claim: Claim, cid: string) => { if (!claim.citations!.includes(cid)) claim.citations!.push(cid); }; const bodyLine = (cid: string) => { const c = citations[cid]; const quote = c.quote.replace(/\s+/g, " ").trim(); const label = c.kind === "post" ? "post" : c.kind === "source" ? (sources[c.source]?.title ?? cid) : c.kind === "page" ? cid : `${c.id}`; return `- “${quote}” [${label.replace(/[[\]]/g, "")}](cite:${cid})`; }; for (const e of timeline) { const tagged = claimOfEntry(e); if (e.type === "card") { if (tagged) { const k = claimFor(tagged.id, tagged.verdict); if (str(e.heading)) k.texts.push({ from: "card", text: e.heading as string }); continue; } const style = e.style; if ((style === undefined || style === "chapter") && str(e.heading)) { const section: Section = { id: anchors.claim(str(e.id) ?? "section", "section"), title: e.heading as string, claims: [] }; sections.push({ section, body: str(e.sub) ? [e.sub as string] : [] }); } continue; } const cid = citeOf.get(e); if (!cid) continue; const ledgerClaim = !tagged && str(e.id) ? ledgerClaimOfClip.get(e.id as string) : undefined; const claimId = tagged?.id ?? ledgerClaim; if (claimId) { const k = claimFor(claimId, tagged?.verdict ?? null); const c = citations[cid]; if (c.kind === "source" && !k.claim.sourceQuote) { k.claim.sourceQuote = { citation: cid }; k.texts.push({ from: "source", text: c.quote }); } else { list(k.claim, cid); if (c.kind !== "source") k.texts.push({ from: "clip", text: c.quote }); } for (const pid of postsOfClip.get(e) ?? []) list(k.claim, pid); } else { const body = current().body; body.push(bodyLine(cid)); for (const pid of postsOfClip.get(e) ?? []) body.push(bodyLine(pid)); } } // Ledger claims no clip pins: their own moment, in a section of their own. const unpinned = ledger.filter((l) => { const id = str(l.id); return id && !claims.has(id) && !timelineClaims.has(id); }); if (unpinned.length) { sections.push({ section: { id: anchors.claim("ledger"), title: "Not clipped", claims: [] }, body: [] }); for (const l of unpinned) { const k = claimFor(l.id as string, null); const channel = str(l.channel) ?? str(prov.channelSlug); const video = str(l.video); const second = num(l.cite) ?? num(l.start); const quote = str(l.quote); if (channel && video && second !== undefined && quote) { const span = await spanAt(channel, video, second, quote, opts, num(l.end)); const cid = citeIds.claim(`l-${l.id as string}`); citations[cid] = { kind: "video", channel, id: video, start: span.start, end: span.end, quote }; list(k.claim, cid); } else { warnings.push(`ledger ${l.id as string}: no channel, video, second and quote to cite — the claim has no citation`); } } } if (loosePosts.length) { const body = current().body; for (const pid of loosePosts) body.push(bodyLine(pid)); } // ── Claim texts and titles ── for (const [id, { claim, texts }] of claims) { const l = ledgerById.get(id); const pick = (from: string) => texts.find((t) => t.from === from)?.text; const text = str(l?.quote) ?? pick("card") ?? pick("source") ?? pick("clip"); if (str(l?.label) && str(l?.quote)) claim.title = l!.label as string; if (text) claim.text = text.replace(/\s+/g, " ").trim(); else { claim.text = id; warnings.push(`claim ${id}: nothing in the manifest states it — its text is its id`); } if (!claim.citations!.length) delete claim.citations; } report.kind = [...claims.values()].some((k) => k.claim.verdict) ? "factcheck" : "sweep"; report.sources = sources; report.citations = citations; report.sections = sections.map(({ section, body }) => { const out: Section = { id: section.id, title: section.title }; if (body.length) out.body = body.join("\n"); if (section.claims!.length) out.claims = section.claims; return out; }); return finish(report, warnings); } // ─── report → manifest ─── // What a post record says that a post citation does not (the CLI reads it off // the channel's archive). export type PostRecord = { // "forum": a forum-thread post (platform "xenforo"), which a video manifest // does not carry yet — it is left out with a warning, never relabelled. platform: "x" | "bluesky" | "forum"; url: string; createdAt?: string; author?: string; authorName?: string; }; export type PostOf = (channel: string, id: string) => Promise; export type ManifestOptions = { // The archive the cut's QR codes link (provenance.siteOrigin). Default "" // — which `umtool check` blocks on until it is set, as for `umtool new`. siteOrigin?: string; postOf?: PostOf; // Replaces the starter `render` block. render?: Record; // `generatedOn`; default today. today?: string; }; // The render block `umtool new` writes (umtool/lib/projects/scaffold.mjs // `skeleton`), so a converted report renders as a new project does. Keep the // two alike. export const STARTER_RENDER: Readonly> = Object.freeze({ width: 1920, height: 1080, fps: 30, audioRate: 48000, audioChannels: 2, maxHeightSource: 1080, fontRegular: "/usr/share/fonts/TTF/FiraSans-Regular.ttf", fontBold: "/usr/share/fonts/TTF/FiraSans-Bold.ttf", palette: { bg: "#12100c", fg: "#f6f1e6", muted: "#a2957f", accent: "#c8752a", amber: "#ffc860" }, transition: 0.4, fetchPad: 3, snapWindow: 1.6, silenceMinDur: 0.09, silenceRelDb: 6, headerHeight: 56, footerHeight: 0, crf: 21, preset: "slow", qr: { scale: 4, quiet: 3, ecc: "M", margin: 28 }, }); // A manifest entry's `date` is a calendar day. const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; export const CARD_SECONDS = { title: 4, chapter: 3.5 } as const; export const IMAGE_SECONDS = 6; export type ManifestResult = | { ok: true; manifest: Record; warnings: string[] } | { ok: false; problems: Problem[] }; export async function reportToManifest(raw: unknown, opts: ManifestOptions = {}): Promise { const parsed = parseReport(raw); if (!parsed.ok || parsed.problems.length) return { ok: false, problems: parsed.problems }; const report: Report = parsed.value; const warnings: string[] = []; const citations = report.citations ?? {}; const sources = report.sources ?? {}; // Timeline ids are file names in a build: letters, digits, `_` and `-`. const entryIds = new IdAllocator(/[^A-Za-z0-9_-]+/g); const postIds = new IdAllocator(/[^A-Za-z0-9_-]+/g); const timeline: Record[] = []; const posts: Record[] = []; const postsDone = new Set(); const channels = new Map(); let lastClip: string | null = null; timeline.push({ type: "card", id: entryIds.claim("title"), style: "title", seconds: CARD_SECONDS.title, heading: report.title, sub: report.subtitle ?? "", }); const claimTag = (claim: Claim | null) => (claim?.verdict ? { claim: { id: claim.id, verdict: claim.verdict } } : {}); const emit = async (cid: string, claim: Claim | null, sourceSentence = false) => { const c = citations[cid]; if (!c) return; switch (c.kind) { case "video": case "audio": { const id = entryIds.claim(cid, "c"); timeline.push({ type: "clip", id, channel: c.channel, video: c.id, start: c.start, end: c.end, cite: Math.floor(c.start), quote: c.quote, ...(c.kind === "audio" ? { audioOnly: true } : {}), ...(c.date && DAY_RE.test(c.date) ? { date: c.date } : {}), ...(c.note ? { note: c.note } : {}), ...claimTag(claim), }); channels.set(c.channel, (channels.get(c.channel) ?? 0) + 1); lastClip = id; return; } case "source": { if (!c.image) { warnings.push( `${cid}: ${sourceSentence ? "a claim's source sentence" : "a source citation"} with no still — no image entry (shoot it, then set its image)`, ); return; } const source = sources[c.source]; const dayOfSource = [c.date, source?.date].find((d) => d && DAY_RE.test(d)); timeline.push({ type: "image", id: entryIds.claim(cid, "a"), src: c.image, seconds: IMAGE_SECONDS, title: source?.title ?? cid, quote: c.quote, ...(dayOfSource ? { date: dayOfSource } : {}), ...(source?.url ? { citeUrl: source.url } : {}), ...(c.note ? { note: c.note } : {}), ...claimTag(claim), }); return; } case "post": { if (postsDone.has(cid)) return; postsDone.add(cid); const rec = opts.postOf ? await opts.postOf(c.channel, c.id) : null; if (rec?.platform === "forum") { warnings.push(`${cid}: post ${c.channel}/${c.id} is a forum post, which a video manifest does not carry — left out of posts`); return; } const platform = rec?.platform ?? (/^\d+$/.test(c.id) ? "x" : null); const url = rec?.url ?? (platform === "x" ? `https://x.com/i/status/${c.id}` : null); const date = c.date ?? rec?.createdAt; if (!platform || !url || !date || !/^\d{4}-\d{2}-\d{2}/.test(date)) { warnings.push( `${cid}: post ${c.channel}/${c.id} — ${!platform || !url ? "its platform and link are unknown (give a channels dir)" : "no date"} — left out of posts`, ); return; } posts.push({ id: postIds.claim(cid, "p"), platform, ...(rec?.authorName ? { author: rec.authorName } : {}), ...(rec?.author ? { handle: rec.author } : {}), date, text: c.quote.slice(0, 3000), url, attachTo: lastClip, siteChannel: c.channel, ...(/^[A-Za-z0-9_-]{1,128}$/.test(c.id) ? { postId: c.id } : {}), }); return; } case "page": warnings.push(`${cid}: a page citation has no manifest entry — left out`); return; } }; for (const section of report.sections) { timeline.push({ type: "card", id: entryIds.claim(section.id, "section"), style: "chapter", seconds: CARD_SECONDS.chapter, heading: section.title, }); for (const cid of citedIds(section.body)) await emit(cid, null); for (const claim of section.claims ?? []) { if (claim.sourceQuote) await emit(claim.sourceQuote.citation, claim, true); const order = [...(claim.citations ?? []), ...citedIds(claim.findings)]; for (const cid of [...new Set(order)]) { if (cid !== claim.sourceQuote?.citation) await emit(cid, claim); } } } const channelSlug = [...channels.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? ""; const manifest: Record = { schemaVersion: 1, slug: report.id, title: reportFullTitle(report), subtitle: report.subtitle ?? "", generatedOn: opts.today ?? new Date().toISOString().slice(0, 10), provenance: { siteOrigin: opts.siteOrigin ?? "", channelSlug, channel: "" }, render: { ...(opts.render ?? STARTER_RENDER) }, timelineNodes: [], timeline, ...(posts.length ? { posts } : {}), }; return { ok: true, manifest, warnings }; }