import { readProvenance, type JoinedRow } from "./provenance"; import { readManifest } from "./manifest"; import { readSpec } from "./spec"; // --------------------------------------------------------------------------- // A timestamp on a RENDERED cut, resolved back to the moment it came from. // // This is the join the tree could never make on its own, and it is the reason // the marks are worth capturing. "The kit is late at 0:42" is a note about a // rendered file; what you actually need in order to do anything about it is // WHICH clip is playing there, which episode it was cut from, and where in that // episode to listen. All three are in the plan, keyed by song time. // // The lookup is "the last note that started at or before t", per voice -- not // the nearest. A plan places notes at slotStart and they sound until the next // one; the nearest-by-centre answer would jump forward to a note that has not // begun yet, which reads as the tool pointing at the wrong clip. // --------------------------------------------------------------------------- export type ResolvedMoment = { /** The mark, in seconds into the rendered file. */ t: number; /** Which plan or CSV the rows came from, so a citation can say. */ source: string | null; /** One row per voice sounding at `t`, nearest-start first. */ playing: JoinedRow[]; /** How far before `t` the closest of them started. */ lag: number | null; }; /** * `t` is a time in the RENDERED file, and the plan is in SONG time. * * For every cut in this tree they are the same clock -- the renders start at * song zero. The one documented exception is Mortal Kombat, where 8.000s was * sliced out of the finished file after the fact, so a mark on the shipped mp4 * is 8s earlier than the plan that made it. `spec.json` can declare that offset * and it is applied here rather than being carried in anybody's head. */ export async function resolveMoment( song: string, t: number, planName?: string | null, ): Promise { const [spec, manifest] = await Promise.all([readSpec(song), readManifest(song)]); const plan = planName ?? spec.voices?.plan ?? Object.values(manifest.builds).find((b) => b.plans.length)?.plans[0] ?? null; const prov = await readProvenance(song, plan); const songTime = t + (spec.renderOffset ?? 0); const byVoice = new Map(); for (const r of prov.rows) { if (r.songTime > songTime + 1e-6) continue; const held = byVoice.get(r.voice); if (!held || r.songTime > held.songTime) byVoice.set(r.voice, r); } const playing = [...byVoice.values()].sort((a, b) => b.songTime - a.songTime); return { t, source: prov.source?.name ?? null, playing, lag: playing.length ? +(songTime - playing[0].songTime).toFixed(3) : null, }; }