/** * `vclaw video match-highlights --events-file … --place` — the PURE placement half. * * The hybrid this exists for: the free `match-highlights-local` skill FINDS every * ball and its outcome by reading the scoreboard overlay, at $0, but it can only * time a ball from the scorer's keystroke — which lands 3–25 s after the shot and * occasionally 50 s after it. Its clips therefore open on a batter standing still * and end as the run-up starts. The paid lane times events well, because its judge * passes ask the model where the ball actually is, but listing a whole match costs * about $8. * * So: take the free skill's event list, and spend a model call ONLY on placing the * handful of events that will reach a reel. One candidate reel, one call per 15 * minutes of source, about $0.50 a match. * * Everything here is a deterministic function of its inputs. The reels, uploads and * calls live in `src/cli/handlers/match-highlights-place.ts`. * * Two facts the window geometry is pinned to, measured on the free skill's own * part-1 output against the paid lane's verified reels: * - `[change − 30, change + 3]` contains the real shot for 14 of 14 boundaries. The * keystroke lag was measured across 61 scored events of the reference match: * median 10.5 s, p90 21.2 s, max 52.9 s, never negative. 30 s covers 60 of the 61. * A ball outside its window comes back `unplaced` on its original timing rather * than mis-timed. The 3 s tail exists because the keystroke sometimes lands BEFORE * the ball is dead (twice in 14), so a window that stopped at the keystroke would * cut the ball off mid-flight. * - **The lead is sized on FOURS, and wickets are slower.** Same match, by type: * fours median 10.5 / p90 17.4, wickets median 16.5 / p90 25.8 / **max 29.4** — * 0.6 s inside the 30 s lead. `unknown` is wicket-like, not average-like: it means * the score cell would not decode, and a dismissal animation covering the score is * the usual reason. The live acceptance run placed 14 fours and ZERO wickets, so * the wicket margin is inferred, not measured here. Widening the lead is NOT free: * a longer window holds more of the PREVIOUS ball, which is the one failure mode * the run actually produced. Measure a part with wickets before changing it. * - The anchor is the KEYSTROKE (`change`), not the free skill's own `s`/`e`. Its * `s`/`e` are already an estimate derived from that keystroke, so anchoring on * them would compound the guess instead of replacing it. */ import type { MatchEvent } from './match-highlights.js'; import { clipToSource, type GapStretch, type WindowTableRow } from './match-highlights-verify.js'; /** * Seconds of source kept BEFORE the anchor. Measured keystroke lag over 61 scored * events: median 10.5 s, p90 21.2 s, max 52.9 s. 30 s covers 60 of 61; the rest come * back `unplaced` rather than wrongly placed. Sized on fours — the slowest wicket in * that match lags 29.4 s, so wickets have under a second of headroom. */ export const PLACE_PRE_SECONDS = 30; /** * Per-type leads, because the scorer's lag depends on WHAT happened. * * Measured over 61 scored events of the reference match: fours run median 10.5 s and * p90 17.4 s, wickets median 16.5 s and p90 25.8 s with a worst case of 29.4 s. A * wicket is a passage rather than a moment — the dismissal, the appeal, the celebration * — and the scorer records it last, so one flat lead either wastes reel on every four * or cuts the slowest wicket off. * * `four` is 25 s, and DO NOT TIGHTEN IT TO 20. The p90 of 17.4 s invites that, but the * p90 is not the bar — the tail is. Every one of part 1's fourteen fours was measured * end to end against a hand-verified reel: min 3.0 s, median 10.5 s, max 24.5 s. A 20 s * lead would have put that 24.5 s four outside its window and left it `unplaced`; 30 s * missed none. 25 s is the shortest lead that still covers every four actually measured, * and it is 5 s cheaper per window than a flat 30. * * `unknown` follows the WICKET lead deliberately. It means the score cell would not * decode, and a dismissal animation covering the score is the usual reason — so it is * wicket-like, not average-like, exactly where the extra room matters. */ export const PLACE_LEAD_BY_TYPE: Readonly> = { four: 25, six: 25, wicket: 35, unknown: 35, }; /** Lead for a type the table does not name. */ export const PLACE_DEFAULT_LEAD_SECONDS = PLACE_PRE_SECONDS; /** * The lead for one event type: an explicit `--lead` overrides everything, then the * per-type overrides (`--lead-four`, `--lead-wicket`), then the measured table. */ /** `--lead` beats both; `--lead-four` covers six, `--lead-wicket` covers unknown. */ export interface PlacementLeadOverrides { all?: number; four?: number; wicket?: number; } export function leadForType( type: string | undefined, overrides: PlacementLeadOverrides = {}, ): number { if (overrides.all !== undefined) return overrides.all; const key = (type ?? '').trim().toLowerCase(); if ((key === 'four' || key === 'six') && overrides.four !== undefined) return overrides.four; if ((key === 'wicket' || key === 'unknown') && overrides.wicket !== undefined) return overrides.wicket; return PLACE_LEAD_BY_TYPE[key] ?? PLACE_DEFAULT_LEAD_SECONDS; } /** Seconds kept AFTER the anchor: the keystroke can land before the ball is dead. */ export const PLACE_POST_SECONDS = 3; /** * Source seconds per placement reel. The reel is re-encoded and then read into ONE * Buffer to upload, so the same cap the gap pass uses applies here. */ export const PLACE_BATCH_MAX_SECONDS = 900; /** * Lead-in added before the model's run-up second when the window is rebuilt. * * Three seconds, not one. Measured against the hand-verified reels: where a placed clip * misses the shot it is almost always because the model calls the run-up 2 to 4 seconds * later than a human does — it marks the bowler already moving, a human marks the walk * back turning into the approach. Three seconds absorbs that without reaching into the * previous ball, and it is applied AFTER the verdicts are mapped, so widening it costs * no call and does not touch the prompt or its hash. */ export const PLACE_LEAD_IN_SECONDS = 3; /** * How far outside its own window a returned timestamp may fall before the verdict is * discarded, in CLIP seconds. The same ±2 s the gap pass uses. * * The prompt states BOTH clip seconds and source seconds for every window, so a model * that answers in source units returns a number about a thousand seconds from its * window. Without this the only check is `end > start`, which such an answer passes — * and it would then be cut, labelled `placement: "model"` and presented as a timed * highlight. A `deadBall` borrowed from the neighbouring window fails the same way, * more quietly. */ export const PLACE_CLIP_TOLERANCE_SECONDS = 2; /** * Longest window a verdict may produce, in seconds. A candidate window is at most * `lead + post` long, so a placed window far past that is an arithmetic accident, not a * long delivery. It bounds the damage when both timestamps are wrong in the same * direction and so survive the tolerance check. */ export const PLACE_MAX_WINDOW_SECONDS = 45; /** * Tail added after the model's dead-ball second when the window is rebuilt. * * Measured on the live part-1 run, on the RAW placed window this constant produces — * the one the artifact records — not on the rendered clip: at 2 s only 8 of 14 still * held the ball eight seconds after the run-up, because the model reports dead-ball * tightly and the window closed on it. At 3 s that is 13 of 14. The RENDERED reel was * 13 of 14 either way, because `cutReel` adds its own `--post` on top and was covering * the short window; that is why the artifact's own windows are worth scoring. The tail * is applied AFTER the verdicts are mapped, so changing it does not touch the prompt, * its hash, or the cached answer — the re-derivation cost nothing. */ export const PLACE_TAIL_SECONDS = 3; /** * Where an event's window came from. * * `model` — the placement pass watched the window and said where the ball is. * `unplaced` — the pass was asked and returned nothing usable, so the events file's * own window stands. It is never dropped and never narrowed: an unplaced boundary * still reaches the reel with the window it arrived with. * * Absent means the pass was never asked about that event (it is not one of * `--types`, or `--place` was not passed). */ export type EventPlacement = 'model' | 'unplaced'; /** * The events-file type that means "the score cell would not decode". * * It is a candidate by default even though it is not in `--types`, because it is usually * a WICKET: a dismissal animation covers the score, which is exactly when the reader * fails. On the reference part 2 the hand-verified reel has a wicket at 2016.3 s, the * events file's nearest wicket is 136 s away, and it carries an `unknown` at 2029.5 s — * the right ball with a typical thirteen-second lag. Excluding it dropped that wicket * from the reel entirely. * * Unlike every other type, an `unknown` placed by the model takes the model's `kind`: * there is no scoreboard reading to defer to, which is what `unknown` MEANS, so the * model is the only witness. One the model cannot place, or types as `none`, stays * `unknown` and therefore stays out of a `--types` reel. */ export const UNKNOWN_EVENT_TYPE = 'unknown'; /** One row of an events file, with the second its placement window hangs off. */ export interface EventsFileRow { /** The event exactly as it will appear in the artifact. */ event: MatchEvent; /** * Second the placement window is anchored on: the scorer's keystroke (`change`) * when the file recorded one, otherwise the event's own end. */ anchor: number; } export interface ParsedEventsFile { /** The recording the file was built from, when it names one. */ source?: string; rows: EventsFileRow[]; } function finiteSecond(value: unknown): number | undefined { if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; } function round1(value: number): number { return Math.round(value * 10) / 10; } /** * Read an events file into rows. * * Accepts the free skill's `match-highlights-local.json` AND this command's own * artifact, because both carry `{source, events:[{s,e,t,n,…}]}`; the free skill * additionally carries `change`, which is the keystroke this pass anchors on. * * A row without a finite `s` and `e` cannot form a window, so it is a BLOCKER * rather than a silently dropped row: a half-read events file would quietly shrink * the reel, and the operator would have no way to see that it had. * * Throws a plain Error naming the problem; the handler turns it into a * `invalid_flag_value` blocker. */ export function parseEventsFileDocument(payload: unknown, label = 'the events file'): ParsedEventsFile { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throw new Error(`${label} is not a JSON object with an events[] array`); } const document = payload as { source?: unknown; events?: unknown }; if (!Array.isArray(document.events)) { throw new Error(`${label} has no events[] array`); } if (document.events.length === 0) { throw new Error(`${label} has no events`); } const rows: EventsFileRow[] = []; for (const [index, raw] of document.events.entries()) { if (!raw || typeof raw !== 'object') { throw new Error(`${label} row ${index} is not an object`); } const row = raw as { s?: unknown; e?: unknown; t?: unknown; n?: unknown; change?: unknown }; const s = finiteSecond(row.s); const e = finiteSecond(row.e); if (s === undefined) throw new Error(`${label} row ${index} has no finite "s"`); if (e === undefined) throw new Error(`${label} row ${index} has no finite "e"`); const change = finiteSecond(row.change); rows.push({ event: { s: round1(s), e: round1(e), ...(typeof row.t === 'string' && row.t !== '' ? { t: row.t } : {}), ...(typeof row.n === 'string' && row.n !== '' ? { n: row.n } : {}), segment: 'events-file', }, anchor: round1(change ?? e), }); } return { ...(typeof document.source === 'string' && document.source.trim() !== '' ? { source: document.source } : {}), rows, }; } /** * How far past the recording an events file may reach before it is refused. * * An event's end is the free skill's own padding around a keystroke, so the last * one routinely lands a fraction of a second past the probed duration; ffprobe's * duration drifts from the container's too. Those are clamped harmlessly by * `planWindows`. The gate is here to catch a file from a DIFFERENT recording or a * different part of the match, which is wrong by minutes, so it is given the same * keyframe-scale tolerance `segmentSecondsConflict` uses. */ export const SOURCE_LENGTH_TOLERANCE_SECONDS = 2; /** * Types present in the events file that `--types` excludes, with their counts. * * This exists for one measured case. An `unknown` row means the score cell would not * decode, and a dismissal animation covering the score is the usual cause — so an * `unknown` is usually a WICKET. On the reference part 2 the hand-verified reel has a * wicket whose run-up is at 2016.3 s; the events file's nearest wicket is 136 s away, * but it carries an `unknown` at 2029.5 s, a thirteen-second lag and exactly the right * ball. The default `--types` of four,six,wicket excluded it, so that wicket never * became a candidate and never reached the reel. * * Reporting it is the cheap forcing function: the operator sees what was left on the * table at the moment it matters, rather than discovering a missing wicket on screen. */ export function excludedTypeCounts( rows: readonly EventsFileRow[], types: readonly string[], ): Array<{ type: string; count: number }> { const keep = new Set(types.filter((type) => type !== '')); keep.add(UNKNOWN_EVENT_TYPE); const counts = new Map(); for (const row of rows) { const type = row.event.t; if (type === undefined || type === '' || keep.has(type)) continue; counts.set(type, (counts.get(type) ?? 0) + 1); } return [...counts].map(([type, count]) => ({ type, count })).sort((a, b) => b.count - a.count); } /** The last second any row reaches, used to refuse a source that is too short. */ export function lastEventSecond(rows: readonly EventsFileRow[]): number { let last = 0; for (const row of rows) last = Math.max(last, row.event.s, row.event.e); return round1(last); } /** * Which rows the placement pass pays for: the ones whose type is being kept. * * An empty `types` keeps everything, matching {@link planWindows}'s convention — * though the CLI refuses an explicitly empty `--types` before reaching here. * Returns indices into `rows`. */ export function selectPlacementCandidates( rows: readonly EventsFileRow[], types: readonly string[], options: { includeUnknown?: boolean } = {}, ): number[] { const keep = new Set(types.filter((type) => type !== '')); // `unknown` is timed by default even when `--types` omits it: it is usually a wicket // whose dismissal animation hid the score. `--no-unknown` opts out. if (options.includeUnknown !== false) keep.add(UNKNOWN_EVENT_TYPE); const picked: number[] = []; for (const [index, row] of rows.entries()) { if (keep.size === 0 || (row.event.t !== undefined && keep.has(row.event.t))) picked.push(index); } return picked; } /** The lead each candidate was given, by index into `rows`. Recorded on the event. */ export function leadsForCandidates( rows: readonly EventsFileRow[], candidateIndexes: readonly number[], overrides: PlacementLeadOverrides = {}, ): Map { return new Map(candidateIndexes.map((index) => [index, leadForType(rows[index].event.t, overrides)])); } /** * The candidate windows, ALREADY PADDED. * * They are emitted padded rather than as bare anchors so the same spans can be fed * to `batchStretches` (which measures `e - s`, and would see zero-length spans as * free and pack a whole match into one reel) and to `buildReel` with `pre: 0, * post: 0`. `planWindows` still clamps them to the recording and still carries the * `sources` indices a verdict is mapped back through. * * The note tells the model what outcome to look for — the free skill's scoreboard * transition, which is the strongest cue in the window. */ export function buildPlacementSpans( rows: readonly EventsFileRow[], candidateIndexes: readonly number[], options: { pre?: number; post?: number; leads?: PlacementLeadOverrides } = {}, ): GapStretch[] { const post = options.post ?? PLACE_POST_SECONDS; return candidateIndexes.map((index) => { const row = rows[index]; const label = row.event.t ?? 'event'; const note = row.event.n !== undefined && row.event.n !== '' ? `${label}: ${row.event.n}` : label; // `pre` still wins when a caller passes one, so the pass reels and the tests can pin // a single lead; otherwise the lead is chosen by the event's own type. const pre = options.pre ?? leadForType(row.event.t, options.leads ?? {}); return { s: round1(Math.max(0, row.anchor - pre)), e: round1(row.anchor + post), n: note }; }); } /** One window the model placed. */ export interface PlacementVerdict { /** Window index inside the placement reel. */ i: number; /** Index into the spans the reel was cut from (batch-local). */ candidate: number; /** Source second the rebuilt window starts at (run-up, less the lead-in). */ start: number; /** Source second the rebuilt window ends at (dead ball, plus the tail). */ end: number; /** What the model says happened: four | six | wicket. `none` never reaches here. */ kind: string; evidence?: string; } export interface MappedPlacements { verdicts: PlacementVerdict[]; /** * Answers that named an unknown window, carried a bad number, said `none`, fell * outside their own window, or produced an implausibly long one. Each one leaves its * event `unplaced` with the window it arrived with. */ dropped: number; } /** * Map one placement answer (`{i, runUpStart, shot, deadBall, kind, evidence}`, in * CLIP seconds) back onto the source timeline. * * The verdict is routed through the window table's `sources`, never by un-padding a * timestamp: a window clamped at 0 no longer carries its padding, and the clamp is * real here — a boundary in the first 30 seconds of a recording has a short window. * * `shot` is asked for but not used to build the window. It is the model's proof * that it found the delivery rather than the gap before it, and it is what keeps * `runUpStart` and `deadBall` on the same ball. * * A timestamp outside its own window (±{@link PLACE_CLIP_TOLERANCE_SECONDS}) or a * resulting window longer than {@link PLACE_MAX_WINDOW_SECONDS} is DROPPED, which leaves * that event `unplaced` on its original timing. Both are answers a bare `end > start` * check would have cut and labelled as model-timed. */ export function mapPlacementVerdicts( payload: unknown, table: readonly WindowTableRow[], options: { leadIn?: number; tail?: number; tolerance?: number; maxWindow?: number } = {}, ): MappedPlacements { const leadIn = options.leadIn ?? PLACE_LEAD_IN_SECONDS; const tail = options.tail ?? PLACE_TAIL_SECONDS; const tolerance = options.tolerance ?? PLACE_CLIP_TOLERANCE_SECONDS; const maxWindow = options.maxWindow ?? PLACE_MAX_WINDOW_SECONDS; const verdicts: PlacementVerdict[] = []; let dropped = 0; if (!Array.isArray(payload)) return { verdicts, dropped }; for (const raw of payload) { if (!raw || typeof raw !== 'object') { dropped += 1; continue; } const row = raw as { i?: unknown; runUpStart?: unknown; shot?: unknown; deadBall?: unknown; kind?: unknown; evidence?: unknown; }; const index = Number(row.i); const window = Number.isInteger(index) ? table[index] : undefined; const kind = typeof row.kind === 'string' ? row.kind.trim().toLowerCase() : ''; const runUp = finiteSecond(row.runUpStart); const dead = finiteSecond(row.deadBall); if (!window || kind === '' || kind === 'none' || runUp === undefined || dead === undefined) { dropped += 1; continue; } // Both times must land inside the window the model was shown, in CLIP seconds. An // answer given in SOURCE seconds is a thousand seconds out and would otherwise be // cut and labelled as timed; so would a timestamp borrowed from a neighbour. const clipLow = window.reelStart - tolerance; const clipHigh = window.reelEnd + tolerance; if (runUp < clipLow || runUp > clipHigh || dead < clipLow || dead > clipHigh) { dropped += 1; continue; } const start = Math.max(0, clipToSource(window, runUp) - leadIn); const end = clipToSource(window, dead) + tail; // `end > start` alone lets two same-direction errors through; a window longer than a // candidate window plus its padding is arithmetic, not a long delivery. if (!(end > start) || end - start > maxWindow) { dropped += 1; continue; } // mergeOverlaps is false for a placement reel, so `sources` is one index; the // loop is defensive, matching how the judge pass maps its verdicts. for (const candidate of window.sources) { verdicts.push({ i: window.i, candidate, start: round1(start), end: round1(end), kind, ...(typeof row.evidence === 'string' && row.evidence !== '' ? { evidence: row.evidence } : {}), }); } } return { verdicts, dropped }; } export interface PlacementApplication { events: MatchEvent[]; placed: number; unplaced: number; /** Events whose model `kind` disagreed with the events file's own type. */ disagreed: Array<{ index: number; fileType: string; modelKind: string }>; /** * `unknown` events the model typed, so they now carry a real outcome and can reach a * `--types` reel. The only case where the model's `kind` overrides the file's. */ retyped: Array<{ index: number; to: string }>; } /** * Rebuild the event list from the verdicts. * * A placed event takes the model's window and keeps the events file's TYPE. The * division of labour is deliberate: the scoreboard delta the free skill read is the * reliable answer to "what happened", and the model is here to answer "when". A * disagreement is reported rather than acted on. * * Every candidate the pass could not place keeps the window it arrived with and is * marked `unplaced`. Nothing is ever dropped. */ export function applyPlacements( rows: readonly EventsFileRow[], candidateIndexes: readonly number[], placements: ReadonlyMap, /** The lead each candidate was cut with, recorded on the event so a run is reproducible. */ leads: ReadonlyMap = new Map(), ): PlacementApplication { const candidates = new Set(candidateIndexes); const events: MatchEvent[] = []; const disagreed: PlacementApplication['disagreed'] = []; const retyped: PlacementApplication['retyped'] = []; let placed = 0; let unplaced = 0; for (const [index, row] of rows.entries()) { if (!candidates.has(index)) { events.push({ ...row.event }); continue; } const lead = leads.get(index); const leadField = lead !== undefined ? { lead } : {}; const verdict = placements.get(index); if (!verdict) { events.push({ ...row.event, ...leadField, placement: 'unplaced' }); unplaced += 1; continue; } // An `unknown` has no scoreboard reading to defer to — that is what it means — so // the model's kind becomes its type. Every other type keeps the file's, because the // scoreboard delta is the reliable answer to WHAT happened. const retype = row.event.t === UNKNOWN_EVENT_TYPE; if (retype) { retyped.push({ index, to: verdict.kind }); } else if (row.event.t !== undefined && row.event.t !== verdict.kind) { disagreed.push({ index, fileType: row.event.t, modelKind: verdict.kind }); } const note = joinNotes(row.event.n, verdict.evidence); events.push({ ...row.event, s: verdict.start, e: verdict.end, ...(retype ? { t: verdict.kind } : {}), ...(note !== undefined ? { n: note } : {}), ...leadField, placement: 'model', }); placed += 1; } events.sort((a, b) => (a.s === b.s ? a.e - b.e : a.s - b.s)); return { events, placed, unplaced, disagreed, retyped }; } function joinNotes(first: string | undefined, second: string | undefined): string | undefined { const parts = [first, second].filter((part): part is string => part !== undefined && part !== ''); return parts.length > 0 ? parts.join(' | ') : undefined; }