import { readSong } from "./browse"; import { readSpec, validateSpec, operationsFor } from "./spec"; import { readManifest } from "./manifest"; import { readProvenance } from "./provenance"; import { readNotes, momentsOf, type NoteMap } from "./notes"; import { resolveMoment } from "./moment"; import { archiveMomentUrl } from "./archive"; import { decisionsForSong, decisionsMarkdown } from "./decisions"; // --------------------------------------------------------------------------- // The whole picture, as text you can paste. // // The problem this solves is the one that started the browse work: the state of // a song lives in a tree, a set of JSON files, and a conversation -- and only // the first two survive. Handing someone (or a model) the current picture meant // re-describing it from scrollback, badly, every time. // // So: one markdown block per song, holding what it is, what ships, what is // competing, every judgement WITH ITS REASON, the outstanding decisions, and // every timestamped mark resolved to the source moment it points at. // // Markdown rather than JSON on purpose. This is read by a person as often as by // a model, and a JSON dump of the same facts is something neither reads well. // --------------------------------------------------------------------------- const noteBlock = (notes: NoteMap, target: string, indent = " "): string => { const n = notes[target]; if (!n) return ""; // Generous notes are multi-line by design, so they are indented as a block // rather than truncated onto one line. return `\n${n.note .split("\n") .map((l) => `${indent}> ${l}`) .join("\n")}`; }; const ts = (t: number) => { const m = Math.floor(t / 60); const s = t - m * 60; return `${m}:${s.toFixed(1).padStart(4, "0")}`; }; /** One line a person can paste into chat and act on. */ export async function momentCitation( song: string, rel: string, t: number, note?: string, ): Promise { const resolved = await resolveMoment(song, t); const head = `[${song} ${rel} @ ${ts(t)}]${note ? ` ${note.split("\n")[0]}` : ""}`; if (!resolved.playing.length) { return `${head}\n (no plan resolved for this song — pick one on the cut page)`; } const lines = resolved.playing.slice(0, 3).map((r) => { const link = archiveMomentUrl(r.video, r.srcStart); const who = r.title || r.video; return ( ` ${r.voice}: ${r.clipId}${r.word ? ` "${r.word}"` : ""} — ${who}${r.date ? ` (${r.date})` : ""}` + `${link ? `\n ${link}` : ""}` ); }); return `${head}\n${lines.join("\n")}`; } /** * One clip in the arrangement, cited directly. * * Deliberately NOT routed through resolveMoment: a provenance row already knows * which clip it is, and going back through the song-time lookup would re-apply * `renderOffset` to a time that is already in song time. */ export async function clipCitation(song: string, clipId: string): Promise { const spec = await readSpec(song); const prov = await readProvenance(song, spec.voices?.plan ?? null); const row = prov.rows.find((r) => r.clipId === clipId); const notes = await readNotes(song); const note = notes[`clip:${clipId}`]; if (!row) { const at = clipId.lastIndexOf("@"); const link = at > 0 ? archiveMomentUrl(clipId.slice(0, at), Number(clipId.slice(at + 1))) : null; return `[${song}] ${clipId}${link ? `\n ${link}` : ""}${note ? `\n ${note.note}` : ""}\n`; } const link = archiveMomentUrl(row.video, row.srcStart); return ( `[${song} @ song ${ts(row.songTime)}] ${row.voice}: ${row.clipId}` + `${row.word ? ` "${row.word}"` : ""}${row.pile ? ` (${row.pile})` : ""}\n` + ` ${row.title || row.video}${row.date ? ` (${row.date})` : ""} · shift ${row.shift.toFixed(2)} st\n` + `${link ? ` ${link}\n` : ""}` + `${note ? `${note.note.split("\n").map((l) => ` ${l}`).join("\n")}\n` : ""}` ); } /** Every mark on one rendition, each resolved to the clip playing there. */ export async function momentsContext(song: string, rel: string): Promise { const notes = await readNotes(song); const marks = momentsOf(notes, rel); if (!marks.length) return `No marks on ${song}/${rel}.\n`; const parts: string[] = []; for (const m of marks) { parts.push(await momentCitation(song, rel, m.t, m.note)); const rest = m.note.split("\n").slice(1).join("\n"); if (rest.trim()) parts.push(rest.split("\n").map((l) => ` ${l}`).join("\n")); } return parts.join("\n") + "\n"; } /** Everything about one song, as markdown. */ export async function songContext(id: string): Promise { const song = await readSong(id); if (!song) return `# ${id}\n\nNo such song.\n`; const spec = await readSpec(id); const [problems, manifest, notes] = await Promise.all([ validateSpec(spec), readManifest(id), readNotes(id), ]); const out: string[] = []; out.push(`# ${spec.title || song.title} (\`${id}\`)`); if (spec.note) out.push(spec.note); out.push(""); // --- what it is ---------------------------------------------------------- out.push("## What it is"); if (spec.builder) out.push(`- built by \`${spec.builder}\``); if (spec.background) { out.push( `- background: \`${spec.background.path}\`` + [ spec.background.fit ? `fit=${spec.background.fit}` : "", spec.background.dim !== undefined ? `dim=${spec.background.dim}` : "", spec.background.handoverAt !== undefined ? `handover=${spec.background.handoverAt}s` : "", ] .filter(Boolean) .map((s) => ` ${s}`) .join("") + (spec.background.note ? ` — ${spec.background.note}` : ""), ); } for (const s of spec.sounds ?? []) { out.push( `- sound \`${s.id}\`: \`${s.path}\`${s.at !== undefined ? ` at ${s.at}s` : ""}` + (s.note ? ` — ${s.note}` : ""), ); } if (spec.drums) { out.push( `- drums: \`${spec.drums.path}\` shift=${spec.drums.shift ?? "?"} tempo×${spec.drums.tempoScale ?? "?"} trim=${spec.drums.trim ?? "?"}` + (spec.drums.note ? ` — ${spec.drums.note}` : ""), ); } if (spec.hooks) out.push(`- hooks: trim set \`${spec.hooks.trimSet}\``); if (spec.voices) out.push(`- arrangement: \`plan/${spec.voices.plan}\``); if (spec.renderOffset) { out.push( `- **render offset ${spec.renderOffset}s** — a time in the shipped file is that much earlier than song time`, ); } if (!spec.builder && !spec.background && !spec.hooks && !spec.voices) { out.push("- nothing declared yet — the spec sheet is empty"); } const noSpec = noteBlock(notes, "song", ""); if (noSpec) out.push(noSpec.trimStart()); if (problems.length) { out.push(""); out.push("Spec problems:"); for (const p of problems) out.push(`- ${p.level}: ${p.where} — ${p.message}`); } out.push(""); // --- what ships, and what is competing ----------------------------------- out.push("## Cuts and variants"); for (const cut of song.cuts) { const rec = cut.shipped ? manifest.builds[cut.shipped.rel] : null; if (cut.shipped) { const v = cut.shipped.verdict; out.push( `- **${cut.name}** — ships \`${cut.shipped.rel}\`` + (v ? ` · ${v.verdict}${v.note ? ` ("${v.note}")` : ""}` : " · no verdict") + (rec?.script ? ` · built by \`${rec.script}\`` : ""), ); } else { out.push(`- **${cut.name}** — NOT BUILT`); } const slot = noteBlock(notes, `cut:${cut.name}`); if (slot) out.push(slot.replace(/^\n/, "")); if (cut.shipped) { const fn = noteBlock(notes, `file:${cut.shipped.rel}`); if (fn) out.push(fn.replace(/^\n/, "")); } for (const v of cut.variants) { const vd = v.verdict; out.push( ` - ${v.retired ? "(retired) " : ""}\`${v.tag}\`` + (vd ? ` · ${vd.verdict}${vd.note ? ` ("${vd.note}")` : ""}` : " · no verdict"), ); const vn = noteBlock(notes, `file:${v.rel}`, " "); if (vn) out.push(vn.replace(/^\n/, "")); } } if (song.unattributed.length) { out.push(`- matches no cut name: ${song.unattributed.map((v) => `\`${v.rel}\``).join(", ")}`); } out.push(""); // --- the marks, resolved ------------------------------------------------- const marked = [ ...song.cuts.map((c) => c.shipped), ...song.cuts.flatMap((c) => c.variants), ].filter((r): r is NonNullable => !!r); const markLines: string[] = []; for (const r of marked) { for (const m of momentsOf(notes, r.rel)) { markLines.push(await momentCitation(id, r.rel, m.t, m.note)); const rest = m.note.split("\n").slice(1).join("\n"); if (rest.trim()) markLines.push(rest.split("\n").map((l) => ` ${l}`).join("\n")); } } if (markLines.length) { out.push("## Marks"); out.push(""); out.push("```"); out.push(markLines.join("\n")); out.push("```"); out.push(""); } // --- the arrangement ----------------------------------------------------- const prov = await readProvenance(id, spec.voices?.plan ?? null); if (prov.rows.length) { out.push("## Arrangement"); out.push( `From \`${prov.source?.name}\`: ${prov.rows.length} notes, ${prov.videos.length} source episodes` + (prov.shiftP90 !== null ? `, |shift| p90 ${prov.shiftP90.toFixed(2)} st` : "") + (prov.voices.length ? `, voices ${prov.voices.join("/")}` : "") + ".", ); const noted = prov.videos.filter((v) => notes[`source:${v.video}`]); for (const v of noted) { out.push(`- \`${v.video}\` (${v.clips} clips) — ${v.title || "untitled"}`); out.push(noteBlock(notes, `source:${v.video}`).replace(/^\n/, "")); } out.push(""); } else if (song.plans.length) { out.push("## Arrangement"); out.push( `No plan chosen. \`plan/\` holds: ${song.plans.map((p) => `\`${p.name}\``).join(", ")} — ` + "which one shipped is not recorded, so it is not guessed.", ); out.push(""); } for (const p of song.plans) { const pn = noteBlock(notes, `plan:${p.name}`, ""); if (pn) { out.push(`\`plan/${p.name}\`:`); out.push(pn.trimStart()); out.push(""); } } // --- clip notes ---------------------------------------------------------- const clipNotes = Object.keys(notes).filter((k) => k.startsWith("clip:")); if (clipNotes.length) { out.push("## Clip notes"); for (const k of clipNotes) { const clipId = k.slice("clip:".length); const at = clipId.lastIndexOf("@"); const link = at > 0 ? archiveMomentUrl(clipId.slice(0, at), Number(clipId.slice(at + 1))) : null; out.push(`- \`${clipId}\`${link ? ` — ${link}` : ""}`); out.push(noteBlock(notes, k).replace(/^\n/, "")); } out.push(""); } // --- what is waiting on a person ----------------------------------------- // // The same reducer /browse/decisions renders, in the same paste as everything // else. Without it the paste describes the state perfectly and says nothing // about what to do next, which is the question it gets asked. const decisions = await decisionsForSong(id); if (decisions.length) { out.push(...decisionsMarkdown(decisions)); out.push(""); } // --- what can be done ---------------------------------------------------- out.push("## Operations available"); for (const op of operationsFor(spec, song)) { out.push(`- ${op.blocked ? `~~${op.label}~~ (${op.blocked})` : op.label}`); } out.push(""); const built = song.cuts.filter((c) => c.shipped).length; const undecided = Object.values(song.verdicts).length; out.push( `_${built}/${song.cuts.length} cuts built · ${song.variantCount} live variants · ` + `${undecided} judged · ${Object.keys(notes).length} notes · generated by umtool /browse._`, ); return out.join("\n") + "\n"; } /** Every song at once, for the widest possible paste. */ export async function allContext(ids: string[]): Promise { const parts = await Promise.all(ids.map((id) => songContext(id))); return parts.join("\n---\n\n"); }