@ossclip/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,509 @@
1
+ import { z } from "zod/v4";
2
+ import {
3
+ LayoutSchema,
4
+ SceneComponentIdSchema,
5
+ ThemeSchema,
6
+ type SceneCue,
7
+ type SceneComponentId,
8
+ type Theme,
9
+ } from "./scene-schema";
10
+ import { resolveSceneProps } from "./scene-registry";
11
+ import type { CaptionLine } from "./captions";
12
+
13
+ /**
14
+ * The user's edit layer (SPEC: direct manipulation).
15
+ *
16
+ * Kept in its OWN file, never in production.json: that document is derived and
17
+ * every `produce` run overwrites it, so a user layer stored there would
18
+ * evaporate on the next run. Separation is what lets the producer re-roll
19
+ * `props` while hand edits survive — the merge rule from BRAINSTORM §4.6.
20
+ */
21
+
22
+ export const ElementTransformSchema = z.object({
23
+ dx: z.number().optional(),
24
+ dy: z.number().optional(),
25
+ scale: z.number().positive().optional(),
26
+ });
27
+ export type ElementTransform = z.infer<typeof ElementTransformSchema>;
28
+
29
+ export const SceneOverrideSchema = z.object({
30
+ /** Merged over the producer's props, key by key. */
31
+ props: z.record(z.string(), z.unknown()).default({}),
32
+ /** Per-element nudges, keyed by the component's `data-edit-id`. */
33
+ elements: z.record(z.string(), ElementTransformSchema).default({}),
34
+ /**
35
+ * Absolute output time. Setting this PINS the scene: it stops tracking the
36
+ * words it was anchored to, which is why the UI has to say so out loud.
37
+ */
38
+ timing: z.object({ startSec: z.number().nonnegative(), endSec: z.number().nonnegative() }).optional(),
39
+ /**
40
+ * Component/layout swaps (design spec Scope: v1 in-scope). Optional — most
41
+ * scenes never touch these — and validated against the same enums the
42
+ * producer itself is constrained to, so an override can't name a component
43
+ * or layout that doesn't exist in the registry.
44
+ */
45
+ component: SceneComponentIdSchema.optional(),
46
+ layout: LayoutSchema.optional(),
47
+ /**
48
+ * How the VIDEO sits inside this scene's slot, when the automatic
49
+ * face-aware crop gets it wrong.
50
+ *
51
+ * The motivating case: a `pip-bubble` fed a portrait canvas is cover-cropped
52
+ * width-first, which puts the head at ~120% of the circle's diameter — and
53
+ * that ratio is fixed no matter how large the bubble is, so no constant can
54
+ * fix it. Zooming out inside a round mask would leave crescent gaps, so the
55
+ * automatic path leaves it and this is the escape hatch: `scale` below 1
56
+ * shows more of the source (the gap fills with the stage backdrop), `dy`
57
+ * nudges the crop up or down.
58
+ *
59
+ * Deliberately per SCENE, not global: it is a property of one layout meeting
60
+ * one moment's framing, which is exactly what the editor is for.
61
+ */
62
+ video: z
63
+ .object({
64
+ scale: z.number().positive().max(4).optional(),
65
+ dy: z.number().optional(),
66
+ dx: z.number().optional(),
67
+ /** `false` switches the automatic idle-zoom layer off for this scene. */
68
+ autoZoom: z.boolean().optional(),
69
+ })
70
+ .optional(),
71
+ /**
72
+ * The pip bubble's mask roundness and placement (R14 §52) — mirrors
73
+ * `SceneCueSchema.pip`, validated here because this is the hand-editable
74
+ * layer. Per scene like `video`: it is one bubble meeting one moment's
75
+ * staging. Ignored unless the scene's resolved layout is `pip-bubble`, so
76
+ * it survives a layout round-trip instead of bending other layouts.
77
+ */
78
+ pip: z
79
+ .object({
80
+ cornerRadius: z.number().min(0).max(1).optional(),
81
+ x: z.number().min(0).max(1).optional(),
82
+ y: z.number().min(0).max(1).optional(),
83
+ })
84
+ .optional(),
85
+ /**
86
+ * Vertical centre for this scene's captions (R15 §56). NOT part of the
87
+ * top-level `captions` key — that one is the caption TEXT retype map,
88
+ * keyed by word index; position is a property of the SCENE, where the
89
+ * timeline selection can address it and "apply to all" can fan it out.
90
+ */
91
+ captionY: z.number().min(0).max(1).optional(),
92
+ /** Caption size multiplier (R16 §64) — same per-scene, fan-out-able shape
93
+ * as `captionY`, and the same reasoning for living on the scene. */
94
+ captionScale: z.number().min(0.2).max(3).optional(),
95
+ /**
96
+ * The graphic slot, reshaped by hand (PLAN 2026-07-31 Task 2) — frame
97
+ * fractions like every other rect. Validated HERE even though
98
+ * `SceneCueSchema.graphicRect` is not: this one is hand-editable user
99
+ * data, and §35's lesson is that validators are the constraint. The
100
+ * renderer additionally clamps into the platform-safe area at draw time.
101
+ */
102
+ graphicRect: z
103
+ .object({
104
+ x: z.number().min(0).max(1),
105
+ y: z.number().min(0).max(1),
106
+ w: z.number().min(0.08).max(1),
107
+ h: z.number().min(0.05).max(1),
108
+ })
109
+ .optional(),
110
+ /**
111
+ * The scene is deleted — SOFTLY (PLAN 2026-07-30 Task C): the cue drops
112
+ * from the render (`dropHiddenCues`) and its window becomes a plain take,
113
+ * but the plan still has the scene and the timeline shows a restorable
114
+ * ghost. Restore DELETES this key rather than writing `false`, matching
115
+ * `clearVideo`/`clearTiming`: an explicit `hidden: false` would still be
116
+ * an override with nothing to say.
117
+ */
118
+ hidden: z.boolean().optional(),
119
+ });
120
+ export type SceneOverride = z.infer<typeof SceneOverrideSchema>;
121
+
122
+ /**
123
+ * One retyped caption word (PLAN 2026-07-29 Task 7, scope (a) — decided with
124
+ * the author: 1:1 in-place retype, timing untouched).
125
+ *
126
+ * Keyed by the word's position in the caption stream, GUARDED by the text
127
+ * that was there when the edit was made — the same verification-anchor
128
+ * pattern as `AppliedRepair.heard` (§17). Captions are derived (repaired
129
+ * transcript through the TimeMap), so a changed cleanup level or repair set
130
+ * can shift positions; the guard means a stale edit is DROPPED WITH A LOG
131
+ * rather than silently landing on the wrong word.
132
+ */
133
+ export const CaptionEditSchema = z.object({
134
+ /** The replacement text. */
135
+ text: z.string().min(1).max(80),
136
+ /** The word this edit replaced — the stale-index guard. */
137
+ was: z.string(),
138
+ });
139
+ export type CaptionEdit = z.infer<typeof CaptionEditSchema>;
140
+
141
+ /**
142
+ * The `was` a caption edit should store (R15 §59). The FIRST edit's `was` is
143
+ * the base truth (the word as transcribed); every later re-edit of the same
144
+ * index sees the LIVE (already-edited) text, and storing that as `was` would
145
+ * make `applyCaptionEdits`' stale-guard drop the edit against the base lines.
146
+ * Preserving the existing entry's `was` keeps the guard anchored to the base
147
+ * — and makes "retyped back to the original" detectable, which is when the
148
+ * override should clear entirely.
149
+ */
150
+ export function captionEditWas(
151
+ captions: Record<string, CaptionEdit>,
152
+ index: number,
153
+ seen: string,
154
+ ): string {
155
+ return captions[String(index)]?.was ?? seen;
156
+ }
157
+
158
+ export const OverrideDocSchema = z.object({
159
+ /** Global style tokens — the look is a system, so these are not per-element. */
160
+ theme: ThemeSchema.partial().default({}),
161
+ scenes: z.record(z.string(), SceneOverrideSchema).default({}),
162
+ /** Retyped caption words, keyed by caption-stream word index. */
163
+ captions: z.record(z.string(), CaptionEditSchema).default({}),
164
+ /**
165
+ * Scene split points in ABSOLUTE output seconds (R16 §61 — Cmd/Ctrl+B at
166
+ * the playhead). Time-anchored rather than scene-anchored on purpose: a
167
+ * re-plan can rename or move scenes, and a split is a decision about a
168
+ * MOMENT of the output. Applied by `splitCues` after the plain fill, so a
169
+ * split lands on graphic cues and takes alike.
170
+ */
171
+ splits: z.array(z.number().nonnegative()).default([]),
172
+ });
173
+ export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
174
+
175
+ export const emptyOverrideDoc = (): OverrideDoc => OverrideDocSchema.parse({});
176
+
177
+ export interface AppliedOverrides {
178
+ cues: SceneCue[];
179
+ /** Scene ids the document mentions that the current plan no longer has. */
180
+ orphans: string[];
181
+ }
182
+
183
+ /**
184
+ * Merge the user's layer onto assembled cues.
185
+ *
186
+ * Orphans are REPORTED rather than dropped quietly: after a re-plan, edits
187
+ * pointing at scenes that no longer exist are the user's lost work, and
188
+ * silence would make it look like the editor forgot them.
189
+ */
190
+ /**
191
+ * Props for a scene whose COMPONENT the user just swapped.
192
+ *
193
+ * The producer's `cue.props` were written for the OLD component and are not
194
+ * merged in here at all — they were shaped for a different schema (a
195
+ * `StatCard`'s `value`/`label` mean nothing to a `FlowDiagram`) and passing
196
+ * them through would either fail validation or silently satisfy it with
197
+ * garbage. Falling back to the NEW component's `defaultProps` — same base
198
+ * `resolveSceneProps` always starts from — renders something coherent
199
+ * instead. `resolveSceneProps` returning null (an override value that fits
200
+ * no schema at all) still can't drop the scene: the registry's own
201
+ * `defaultProps` are the floor every component is built to satisfy on their
202
+ * own, so that's the guaranteed-valid fallback.
203
+ */
204
+ function resolveSwappedProps(
205
+ component: SceneComponentId,
206
+ propsOverride: Record<string, unknown>,
207
+ ): Record<string, unknown> {
208
+ return (
209
+ resolveSceneProps(component, {}, propsOverride) ??
210
+ // The override didn't fit the new schema at all — fall back to the
211
+ // registry's OWN defaults with nothing layered on top, run back through
212
+ // `resolveSceneProps` (rather than the raw `defaultProps` object) so
213
+ // zod-defaulted fields (e.g. `emphasizeLast`) are filled in the same way
214
+ // every other resolved cue's props are. `defaultProps` is guaranteed to
215
+ // validate on its own — every component in the registry is built on that
216
+ // invariant — so this can never itself return null.
217
+ resolveSceneProps(component, {}, {})!
218
+ );
219
+ }
220
+
221
+ /**
222
+ * The override entry a cue resolves against: its own, layered over its split
223
+ * ROOT's (R16 §68). A split half is still the same scene — captions scaled
224
+ * on the original must render scaled on both halves — so `id@ms` inherits
225
+ * everything from `id`, with two exceptions that describe the WHOLE original
226
+ * rather than a piece of it: `timing` (the root's absolute window would undo
227
+ * the split) and `hidden` (deleting the original is not deleting one half).
228
+ * The half's OWN entry wins key by key, and the record-shaped keys merge
229
+ * field-wise so nudging one field on a half doesn't drop the rest of what it
230
+ * inherited.
231
+ */
232
+ function effectiveOverride(
233
+ scenes: Record<string, SceneOverride>,
234
+ id: string,
235
+ ): SceneOverride | undefined {
236
+ const own = scenes[id];
237
+ const at = id.indexOf("@");
238
+ if (at === -1) return own;
239
+ const root = scenes[id.slice(0, at)];
240
+ if (!root) return own;
241
+ const { timing: _timing, hidden: _hidden, ...base } = root;
242
+ if (!own) return { ...base, props: { ...base.props }, elements: { ...base.elements } };
243
+ return {
244
+ ...base,
245
+ ...own,
246
+ props: { ...base.props, ...own.props },
247
+ elements: { ...base.elements, ...own.elements },
248
+ ...(base.video || own.video ? { video: { ...base.video, ...own.video } } : {}),
249
+ ...(base.pip || own.pip ? { pip: { ...base.pip, ...own.pip } } : {}),
250
+ };
251
+ }
252
+
253
+ export function applyOverrides(cues: readonly SceneCue[], doc: OverrideDoc): AppliedOverrides {
254
+ const ids = new Set(cues.map((c) => c.id));
255
+ const orphans = Object.keys(doc.scenes).filter((id) => !ids.has(id));
256
+ const out = cues.map((cue) => {
257
+ const o = effectiveOverride(doc.scenes, cue.id);
258
+ if (!o) return cue;
259
+ const swapped = o.component !== undefined && o.component !== cue.component;
260
+ const component = o.component ?? cue.component;
261
+ const props =
262
+ o.component !== undefined && swapped
263
+ ? resolveSwappedProps(o.component, o.props)
264
+ : { ...cue.props, ...o.props };
265
+ // A rect `routeAroundSourceText` baked into the base cue was computed
266
+ // FOR that cue's original layout — under a layout override it would
267
+ // silently keep winning over the new layout's slot, parking the graphic
268
+ // where the OLD layout needed it. Same reason the editor's `patchLayout`
269
+ // drops the override rect on a swap. A hand-set `o.graphicRect` is the
270
+ // user's own placement and still wins below.
271
+ const layoutSwapped = o.layout !== undefined && o.layout !== cue.layout;
272
+ const { graphicRect: _staleRouted, ...cueSansRoutedRect } = cue;
273
+ return {
274
+ ...(layoutSwapped ? cueSansRoutedRect : cue),
275
+ component,
276
+ layout: o.layout ?? cue.layout,
277
+ props,
278
+ ...(Object.keys(o.elements).length > 0 ? { elements: o.elements } : {}),
279
+ ...(o.video ? { video: o.video } : {}),
280
+ ...(o.pip ? { pip: o.pip } : {}),
281
+ ...(o.captionY !== undefined ? { captionY: o.captionY } : {}),
282
+ ...(o.captionScale !== undefined ? { captionScale: o.captionScale } : {}),
283
+ // After ...cue, so a hand-set rect WINS over one routeAroundSourceText
284
+ // baked into the base cues.
285
+ ...(o.graphicRect ? { graphicRect: o.graphicRect } : {}),
286
+ // Never onto an ALREADY-pinned cue (R16 §68): the second override pass
287
+ // runs after `splitCues`, and re-applying a pinned scene's original
288
+ // window to its first half — which kept the scene's id — would undo
289
+ // the cut and overlap the second half. An unsplit pinned cue skips a
290
+ // byte-identical re-application; a not-yet-pinned cue pins as before.
291
+ ...(o.timing && !cue.pinned
292
+ ? { startSec: o.timing.startSec, endSec: o.timing.endSec, pinned: true }
293
+ : {}),
294
+ };
295
+ });
296
+ return { cues: out, orphans };
297
+ }
298
+
299
+ /**
300
+ * A split half shorter than this is a slip, not an edit — the split is
301
+ * ignored rather than minting an unusably thin cue. Exported so the editor
302
+ * can refuse the keystroke up front instead of silently no-opping.
303
+ */
304
+ export const SPLIT_MIN_PIECE_SEC = 0.3;
305
+
306
+ /**
307
+ * Cut cues at the stored split points (R16 §61).
308
+ *
309
+ * Both halves keep everything but their window; the half STARTING at the
310
+ * split takes the id `${id}@${ms}` — named by its start time, so edits on it
311
+ * stay attached while the split exists, survive further splits of the same
312
+ * original cue, and are reported as orphans (never misapplied) if the split
313
+ * is removed. Runs AFTER `fillPlainCues` so takes split like scenes do, and
314
+ * BEFORE the final override pass so the halves' own edits (framing, timing,
315
+ * elements) land on them. A split that misses every cue — after a re-plan
316
+ * moved the material — is skipped; the time stays in the doc, harmless.
317
+ * NOTE for graphic halves: the second half re-enters through its component's
318
+ * intro animation (a Sequence restarts at its own frame 0) — acceptable for
319
+ * the feature's real use, cutting takes and re-timing halves.
320
+ */
321
+ export function splitCues(cues: readonly SceneCue[], times: readonly number[]): SceneCue[] {
322
+ const out = [...cues];
323
+ for (const t of [...times].sort((a, b) => a - b)) {
324
+ const i = out.findIndex(
325
+ (c) => t >= c.startSec + SPLIT_MIN_PIECE_SEC && t <= c.endSec - SPLIT_MIN_PIECE_SEC,
326
+ );
327
+ if (i === -1) continue;
328
+ const cue = out[i]!;
329
+ // Derive from the ROOT id, not the (possibly already-split) cue id:
330
+ // `take-0@6000`, never `take-0@3000@6000` — so a half's id depends only
331
+ // on the original cue and its own start time, and adding an EARLIER
332
+ // split cannot rename later halves out from under their edits.
333
+ out.splice(
334
+ i,
335
+ 1,
336
+ { ...cue, endSec: t },
337
+ { ...cue, id: `${cue.id.split("@")[0]}@${Math.round(t * 1000)}`, startSec: t },
338
+ );
339
+ }
340
+ return out;
341
+ }
342
+
343
+ export interface DropHiddenResult {
344
+ cues: SceneCue[];
345
+ /** Ids the edit layer hid, in cue order — for the console and the ghosts. */
346
+ hidden: string[];
347
+ }
348
+
349
+ /**
350
+ * Drop the cues the user deleted. A separate pass rather than a branch in
351
+ * `applyOverrides`, deliberately: that function's 1:1 contract (every cue in
352
+ * → every cue out) is load-bearing for its callers and much of its test
353
+ * suite, and hiding is the one edit that breaks it. Runs immediately after
354
+ * it, in `produce.ts` and the editor's live memo alike — BEFORE the plain
355
+ * fill, so a deleted scene's window becomes an editable take on both sides.
356
+ */
357
+ export function dropHiddenCues(cues: readonly SceneCue[], doc: OverrideDoc): DropHiddenResult {
358
+ const hidden: string[] = [];
359
+ const out = cues.filter((cue) => {
360
+ if (doc.scenes[cue.id]?.hidden !== true) return true;
361
+ hidden.push(cue.id);
362
+ return false;
363
+ });
364
+ return { cues: out, hidden };
365
+ }
366
+
367
+ export interface AppliedCaptionEdits {
368
+ lines: CaptionLine[];
369
+ /** Edits whose guard failed — the word at that index is not what they knew. */
370
+ dropped: Array<{ index: number; expected: string; found: string }>;
371
+ }
372
+
373
+ /**
374
+ * Apply retyped caption words. Text only, never timing — the stamps drive the
375
+ * kinetic highlight and the 1:1 constraint is what keeps scene anchors and
376
+ * §21's copy/caption agreement intact. An edit whose `was` no longer matches
377
+ * is reported, not applied and not silently discarded.
378
+ */
379
+ export function applyCaptionEdits(
380
+ lines: readonly CaptionLine[],
381
+ edits: Record<string, CaptionEdit>,
382
+ ): AppliedCaptionEdits {
383
+ const dropped: AppliedCaptionEdits["dropped"] = [];
384
+ if (Object.keys(edits).length === 0) return { lines: [...lines], dropped };
385
+ let index = 0;
386
+ const out = lines.map((line) => ({
387
+ ...line,
388
+ words: line.words.map((w) => {
389
+ const edit = edits[String(index++)];
390
+ if (!edit) return w;
391
+ if (w.text !== edit.was) {
392
+ dropped.push({ index: index - 1, expected: edit.was, found: w.text });
393
+ return w;
394
+ }
395
+ return { ...w, text: edit.text };
396
+ }),
397
+ }));
398
+ return { lines: out, dropped };
399
+ }
400
+
401
+ /** Theme tokens the user set, over whatever the production already had. */
402
+ export function resolveTheme(base: Theme, doc: OverrideDoc): Theme {
403
+ return ThemeSchema.parse({ ...base, ...doc.theme });
404
+ }
405
+
406
+ export function setElementTransform(
407
+ doc: OverrideDoc,
408
+ sceneId: string,
409
+ elementId: string,
410
+ patch: ElementTransform,
411
+ ): OverrideDoc {
412
+ const scene = doc.scenes[sceneId] ?? SceneOverrideSchema.parse({});
413
+ return {
414
+ ...doc,
415
+ scenes: {
416
+ ...doc.scenes,
417
+ [sceneId]: {
418
+ ...scene,
419
+ elements: { ...scene.elements, [elementId]: { ...scene.elements[elementId], ...patch } },
420
+ },
421
+ },
422
+ };
423
+ }
424
+
425
+ /** Reset: DELETE the entry, so "reset" stays distinct from "nudged to 0,0". */
426
+ export function clearElementTransform(
427
+ doc: OverrideDoc,
428
+ sceneId: string,
429
+ elementId: string,
430
+ ): OverrideDoc {
431
+ const scene = doc.scenes[sceneId];
432
+ if (!scene) return doc;
433
+ const { [elementId]: _removed, ...rest } = scene.elements;
434
+ return { ...doc, scenes: { ...doc.scenes, [sceneId]: { ...scene, elements: rest } } };
435
+ }
436
+
437
+ /**
438
+ * Un-pin: DELETE the `timing` override so the scene goes back to tracking its
439
+ * word anchors. Distinct from setting a timing that happens to match the
440
+ * derived one — this removes the override entirely.
441
+ */
442
+ export function clearTiming(doc: OverrideDoc, sceneId: string): OverrideDoc {
443
+ const scene = doc.scenes[sceneId];
444
+ if (!scene || !scene.timing) return doc;
445
+ const { timing: _removed, ...rest } = scene;
446
+ return { ...doc, scenes: { ...doc.scenes, [sceneId]: rest } };
447
+ }
448
+
449
+ /**
450
+ * Reset the hand-set graphic box: DELETE the key so the cue falls back to
451
+ * its layout slot (or the routed rect), distinct from a rect that happens
452
+ * to equal the default.
453
+ */
454
+ export function clearGraphicRect(doc: OverrideDoc, sceneId: string): OverrideDoc {
455
+ const scene = doc.scenes[sceneId];
456
+ if (!scene || !scene.graphicRect) return doc;
457
+ const { graphicRect: _removed, ...rest } = scene;
458
+ return { ...doc, scenes: { ...doc.scenes, [sceneId]: rest } };
459
+ }
460
+
461
+ /** Same floor `assembleScenes` enforces — a pinned scene re-clamped past this
462
+ * would be as unrenderable as one the assembler produced. */
463
+ const MIN_PINNED_SCENE_SEC = 1.2;
464
+ const PINNED_GAP_SEC = 0.05;
465
+
466
+ export interface ReclampResult {
467
+ cues: SceneCue[];
468
+ /** Ids whose pinned timing had to move to stop overlapping a neighbour. */
469
+ adjusted: string[];
470
+ }
471
+
472
+ /**
473
+ * Re-clamp every PINNED cue's absolute timing against its current neighbours
474
+ * in the array, in order.
475
+ *
476
+ * A pin freezes a scene's timing at whatever the neighbours' timing was the
477
+ * moment it was set — but a re-plan (a `--cleanup` level change, a re-run
478
+ * after new source material) can move those neighbours, leaving the pinned
479
+ * cue's frozen window overlapping one of them or the whole array out of
480
+ * time order. That reaches `SceneLayer` and `buildCaptionLines`'
481
+ * `breakpoints`, both of which assume non-overlapping, increasing windows.
482
+ * The editor already clamps a pinned nudge against its neighbours at DRAG
483
+ * time (`apps/editor/src/timing.ts`'s `clampTiming`) — this is the same
484
+ * clamp, re-run here because a re-plan can invalidate a clamp that was
485
+ * correct when it was made without the user touching anything.
486
+ */
487
+ export function reclampPinnedTiming(cues: readonly SceneCue[]): ReclampResult {
488
+ const out = cues.map((c) => ({ ...c }));
489
+ const adjusted: string[] = [];
490
+ for (let i = 0; i < out.length; i++) {
491
+ const cue = out[i]!;
492
+ if (!cue.pinned) continue;
493
+ const prev = out[i - 1];
494
+ const next = out[i + 1];
495
+ const lo = prev ? prev.endSec + PINNED_GAP_SEC : 0;
496
+ const hi = next ? next.startSec - PINNED_GAP_SEC : Number.POSITIVE_INFINITY;
497
+ let s = Math.min(Math.max(cue.startSec, lo), Math.max(lo, hi - MIN_PINNED_SCENE_SEC));
498
+ let e = Math.max(Math.min(cue.endSec, hi), s + MIN_PINNED_SCENE_SEC);
499
+ if (e > hi) {
500
+ e = hi;
501
+ s = Math.max(lo, e - MIN_PINNED_SCENE_SEC);
502
+ }
503
+ if (s !== cue.startSec || e !== cue.endSec) {
504
+ out[i] = { ...cue, startSec: s, endSec: e };
505
+ adjusted.push(cue.id);
506
+ }
507
+ }
508
+ return { cues: out, adjusted };
509
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Sound-alike comparison (FINDINGS §17/§21). ASR errors are *phonetic*: the
3
+ * recognizer heard the right sounds and picked the wrong words ("code churn"
4
+ * → "coach and", "tax" → "text"). Three places need to tell a repair from an
5
+ * invention:
6
+ * - the repair pass, gating what an LLM may rewrite into the captions;
7
+ * - the grounding check, so a repaired label isn't reported as a fabrication;
8
+ * - copy reconciliation, matching a scene's on-screen word to a spoken one.
9
+ *
10
+ * Deliberately small: a consonant-skeleton key plus an edit-distance ratio.
11
+ * Not a full Metaphone — this only has to separate "sounds like" from
12
+ * "unrelated", and it must stay dependency-free and deterministic.
13
+ */
14
+
15
+ /**
16
+ * Digraphs collapsed before single letters, longest first. The ch/sh and th
17
+ * sounds get DIGIT placeholders on purpose: a letter placeholder would be
18
+ * rewritten again by the SINGLES pass below (mapping them to "x" made "coach"
19
+ * expand back out to "ks" via x→ks, which quietly destroyed every score).
20
+ */
21
+ const DIGRAPHS: Array<[RegExp, string]> = [
22
+ [/ph/g, "f"],
23
+ [/gh/g, "f"],
24
+ [/ck/g, "k"],
25
+ [/[cs]h/g, "5"],
26
+ [/th/g, "0"],
27
+ [/wh/g, "w"],
28
+ [/qu/g, "kw"],
29
+ ];
30
+
31
+ const SINGLES: Array<[RegExp, string]> = [
32
+ [/[cq]/g, "k"],
33
+ [/z/g, "s"],
34
+ [/v/g, "f"],
35
+ [/j/g, "g"],
36
+ [/y/g, "i"],
37
+ [/x/g, "ks"],
38
+ ];
39
+
40
+ const VOWELS = /[aeiou]/g;
41
+
42
+ /**
43
+ * A word's consonant skeleton: lowercase, letters only, digraphs folded to
44
+ * single sounds, voiced/unvoiced pairs merged, vowels dropped (vowels are what
45
+ * ASR gets wrong most), runs collapsed.
46
+ *
47
+ * "coach" → "kx" "code" → "kd"
48
+ * "churn" → "xrn" "chun" → "xn"
49
+ * "tax" → "tks" "text" → "tkst"
50
+ *
51
+ * A word that is all vowels keeps its first letter rather than vanishing.
52
+ */
53
+ export function phoneticKey(word: string): string {
54
+ let s = word.toLowerCase().replace(/[^a-z]/g, "");
55
+ if (s.length === 0) return "";
56
+ for (const [re, to] of DIGRAPHS) s = s.replace(re, to);
57
+ for (const [re, to] of SINGLES) s = s.replace(re, to);
58
+ const skeleton = s.replace(VOWELS, "") || s[0]!;
59
+ // Collapse doubled sounds ("ll" → "l"): ASR never distinguishes them.
60
+ return skeleton.replace(/(.)\1+/g, "$1");
61
+ }
62
+
63
+ /** Phrase key — per-word keys joined, so word-count changes still compare. */
64
+ function phraseKey(text: string): string {
65
+ return text
66
+ .split(/\s+/)
67
+ .map(phoneticKey)
68
+ .filter(Boolean)
69
+ .join("");
70
+ }
71
+
72
+ function levenshtein(a: string, b: string): number {
73
+ if (a === b) return 0;
74
+ if (a.length === 0) return b.length;
75
+ if (b.length === 0) return a.length;
76
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
77
+ for (let i = 1; i <= a.length; i++) {
78
+ const curr = [i];
79
+ for (let j = 1; j <= b.length; j++) {
80
+ curr[j] = Math.min(
81
+ prev[j]! + 1,
82
+ curr[j - 1]! + 1,
83
+ prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
84
+ );
85
+ }
86
+ prev = curr;
87
+ }
88
+ return prev[b.length]!;
89
+ }
90
+
91
+ /**
92
+ * 0..1 similarity of two words or phrases by sound. 1 = identical skeletons.
93
+ * Compares the phonetic keys, so spelling and vowels don't dominate.
94
+ */
95
+ export function soundsLike(a: string, b: string): number {
96
+ const ka = phraseKey(a);
97
+ const kb = phraseKey(b);
98
+ if (ka.length === 0 && kb.length === 0) return 1;
99
+ if (ka.length === 0 || kb.length === 0) return 0;
100
+ const dist = levenshtein(ka, kb);
101
+ return Math.max(0, 1 - dist / Math.max(ka.length, kb.length));
102
+ }
103
+
104
+ /**
105
+ * Default floor for "this is a repair, not a rewrite". Deliberately low,
106
+ * because a real mishearing can move word boundaries ("code churn" → "coach
107
+ * and" re-segments the /tʃ/), which wrecks a pure edit-distance score. The
108
+ * onset test below does most of the discriminating.
109
+ */
110
+ export const SOUNDS_LIKE_FLOOR = 0.34;
111
+
112
+ /**
113
+ * Whether `b` is plausibly a mishearing of `a` (in either direction).
114
+ *
115
+ * Two conditions, because neither alone separates the real populations:
116
+ * - the skeletons must be close ENOUGH — resegmentation keeps genuine pairs
117
+ * around 0.4, so the floor sits under that;
118
+ * - **the first sound must match.** Recognizers mangle the middle and end of
119
+ * a phrase, essentially never its onset. This is what rejects a rewrite:
120
+ * "revenue" for "churn" and "monetization" for "agents" both score in the
121
+ * same range as a true repair, and both fail the onset test.
122
+ */
123
+ export function soundsSimilar(a: string, b: string, floor = SOUNDS_LIKE_FLOOR): boolean {
124
+ const ka = phraseKey(a);
125
+ const kb = phraseKey(b);
126
+ if (ka.length === 0 || kb.length === 0) return ka === kb;
127
+ if (ka[0] !== kb[0]) return false;
128
+ return soundsLike(a, b) >= floor;
129
+ }