@ossclip/core 0.1.18 → 0.1.20

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "ossclip's framework-free pipeline: schema, transcription, analysis, cutlist, captions, framing, and the LLM producer",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/browser.ts CHANGED
@@ -22,6 +22,20 @@ export {
22
22
  // lineDirection is a VALUE export but stays browser-safe: captions.ts
23
23
  // imports types only. CaptionTrack needs it at render time (Urdu field test
24
24
  // 2026-08-05 — RTL lines were laying out LTR).
25
- export { lineDirection, type CaptionLine, type CaptionWord } from "./captions";
26
- export type { KeptSpan } from "./timemap";
25
+ // `backfillSrcStart` rides along for the same reason: the EDITOR is the load
26
+ // path that has to repair a pre-§137 render-props.json before anything derives
27
+ // a caption key from it (§137), and the editor imports this surface. Pure, and
28
+ // captions.ts stays browser-safe — it imports types plus timemap, which is
29
+ // itself type-only against ./schema.
30
+ export {
31
+ backfillSrcStart,
32
+ lineDirection,
33
+ type CaptionLine,
34
+ type CaptionWord,
35
+ } from "./captions";
36
+ // `mapFromKeptSpans` is a VALUE export and browser-safe (timemap.ts imports
37
+ // types only). The editor's load-path repair needs the MAP, not the raw span
38
+ // array, to decide whether a repair is possible at all — see
39
+ // `anchorCaptionLines` (§137): a non-empty array can still build an empty map.
40
+ export { mapFromKeptSpans, TimeMap, type KeptSpan } from "./timemap";
27
41
  export type { Probe, Production, RenderSettings, Segment, Transcript, Word } from "./schema";
package/src/captions.ts CHANGED
@@ -1,11 +1,23 @@
1
1
  import type { Transcript, Word } from "./schema";
2
- import type { TimeMap } from "./timemap";
2
+ import { mapFromKeptSpans, type KeptSpan, type TimeMap } from "./timemap";
3
3
 
4
- /** Caption timing lives in OUTPUT time — captions never know about cuts. */
4
+ /**
5
+ * Caption *timing* lives in OUTPUT time: `start`/`end` are what the renderer
6
+ * draws against and they know nothing about cuts. `srcStart` is the deliberate
7
+ * exception (§137) — it is SOURCE time, carried so a re-cut cannot move it.
8
+ * Never read it as an output instant.
9
+ */
5
10
  export interface CaptionWord {
6
11
  text: string;
7
12
  start: number;
8
13
  end: number;
14
+ /**
15
+ * The word's start in SOURCE seconds (§137). `start`/`end` above are OUTPUT
16
+ * times and a re-cut moves them; this does not, which is what lets a caption
17
+ * edit survive one. The field the edit layer keys on — see
18
+ * `captionKeyFor` in overrides.ts.
19
+ */
20
+ srcStart: number;
9
21
  }
10
22
 
11
23
  export interface CaptionLine {
@@ -14,6 +26,52 @@ export interface CaptionLine {
14
26
  end: number;
15
27
  }
16
28
 
29
+ /**
30
+ * Fill in `srcStart` for caption lines read back from a `render-props.json`
31
+ * written before the field existed (§137).
32
+ *
33
+ * The type promises `srcStart` on every word, but the on-disk format predates
34
+ * it and there is no schema at that boundary — the editor loads render props
35
+ * as an unvalidated cast — so lines that TYPECHECK as `CaptionLine` can still
36
+ * arrive with the field missing. Without this, every legacy word would key on
37
+ * the same absent value and a retype would anchor to the wrong word: the exact
38
+ * failure the source anchor exists to prevent, arriving silently instead of as
39
+ * a crash.
40
+ *
41
+ * It is recoverable because the same file still carries `spans`: the word's
42
+ * output start projected back through the map those spans describe. That is a
43
+ * BEST-EFFORT recovery, not an inverse — exact for a word whose start survived
44
+ * the cut uncut, approximate in two cases the file no longer records:
45
+ * - at a seam (`outOut_k === outIn_{k+1}`) an output instant has two source
46
+ * preimages and `toSource` returns the earlier one (`timemap.ts:21-23`), so
47
+ * a word that truly began at `srcIn_{k+1}` lands on the far side of the cut;
48
+ * - a word whose start fell INSIDE a removed span was clamped to the nearest
49
+ * kept edge by `toOutputClamped` when the file was written, so its source
50
+ * instant is simply gone and backfill returns the edge.
51
+ * Both are the best the data on disk supports — the alternative is no anchor at
52
+ * all. Words written with a real `srcStart` are never re-derived this way.
53
+ * Pure — the caller owns reading the file.
54
+ */
55
+ export function backfillSrcStart(
56
+ lines: readonly CaptionLine[],
57
+ spans: readonly KeptSpan[],
58
+ ): CaptionLine[] {
59
+ /** The shape a legacy file actually holds, versus the one the type promises. */
60
+ type LegacyWord = Omit<CaptionWord, "srcStart"> & { srcStart?: number };
61
+ const legacy = (line: CaptionLine) => line.words as readonly LegacyWord[];
62
+ // Nothing to recover from a file already written with the field — and an
63
+ // existing `srcStart` is never recomputed, because the map that produced it
64
+ // may not be the one in `spans`.
65
+ if (lines.every((l) => legacy(l).every((w) => w.srcStart !== undefined))) return [...lines];
66
+ const map = mapFromKeptSpans(spans);
67
+ return lines.map((line) => ({
68
+ ...line,
69
+ words: legacy(line).map((w) =>
70
+ w.srcStart === undefined ? { ...w, srcStart: map.toSource(w.start) } : (w as CaptionWord),
71
+ ),
72
+ }));
73
+ }
74
+
17
75
  /**
18
76
  * Per-LINE direction from the text itself — the first-strong-character
19
77
  * heuristic (Unicode UAX #9 rules P2/P3), not the transcript's language
@@ -65,7 +123,9 @@ export function buildCaptionLines(
65
123
  const mapped: CaptionWord[] = [];
66
124
  for (const w of transcript.words) {
67
125
  const m = map.mapWord(w as Word);
68
- if (m) mapped.push({ text: w.text, start: m.start, end: m.end });
126
+ // `w.start` is source time, `m.start` output — both are needed, and only
127
+ // the source one is stable across a re-cut (§137).
128
+ if (m) mapped.push({ text: w.text, start: m.start, end: m.end, srcStart: w.start });
69
129
  }
70
130
 
71
131
  const lines: CaptionLine[] = [];
@@ -0,0 +1,112 @@
1
+ import { basename } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import type { Production, Segment } from "./schema";
4
+
5
+ /**
6
+ * FCPXML 1.10 marker export — the file format half of `ossclip analyse`
7
+ * (next-directions §2; design doc 2026-08-12-analyse-fcpxml-export-design.md).
8
+ * Pure by the house split: Production in, XML string out; the CLI owns the
9
+ * file write. The document is a project whose single asset-clip is the
10
+ * ORIGINAL source with one named marker per planned cut — markers, not
11
+ * applied cuts, because an editor wants to REVIEW "dead air here / blooper
12
+ * here" before acting on it, and both Resolve and Premiere read this format.
13
+ *
14
+ * No marker colours, resolved deliberately: stock FCPXML `<marker>` carries
15
+ * no colour attribute, and colour does not reliably survive a Resolve round
16
+ * trip — so the cut REASON travels in the marker name (the report.txt
17
+ * vocabulary), which every importer displays.
18
+ */
19
+
20
+ /** A frame's duration as an exact rational — FCPXML's own time currency. */
21
+ export interface FrameDuration {
22
+ num: number;
23
+ den: number;
24
+ }
25
+
26
+ /**
27
+ * NTSC rates have no integer-denominator form — 29.97 is exactly 30000/1001,
28
+ * and ffprobe reports it as a float that equals neither 29.97 nor the
29
+ * rational. Matched by tolerance for that reason; anything else is treated
30
+ * as the integer rate it rounds to.
31
+ */
32
+ const NTSC_RATES: Array<{ fps: number; fd: FrameDuration }> = [
33
+ { fps: 24000 / 1001, fd: { num: 1001, den: 24000 } },
34
+ { fps: 30000 / 1001, fd: { num: 1001, den: 30000 } },
35
+ { fps: 60000 / 1001, fd: { num: 1001, den: 60000 } },
36
+ ];
37
+
38
+ export function fpsToFrameDuration(fps: number): FrameDuration {
39
+ for (const { fps: ntsc, fd } of NTSC_RATES) {
40
+ if (Math.abs(fps - ntsc) < 0.01) return fd;
41
+ }
42
+ return { num: 1, den: Math.round(fps) };
43
+ }
44
+
45
+ /**
46
+ * A seconds value as a frame-aligned FCPXML rational (`"53/30s"`). A float
47
+ * like `1.77s` is legal FCPXML but frame-misaligned, and importers round it
48
+ * silently — quantizing here keeps the marker on the frame the report named,
49
+ * with the rounding visible in this codebase instead of implicit in theirs.
50
+ */
51
+ export function quantizeToFrame(seconds: number, fd: FrameDuration): string {
52
+ const frames = Math.round((seconds * fd.den) / fd.num);
53
+ return `${frames * fd.num}/${fd.den}s`;
54
+ }
55
+
56
+ function esc(s: string): string {
57
+ return s
58
+ .replaceAll("&", "&amp;")
59
+ .replaceAll("<", "&lt;")
60
+ .replaceAll(">", "&gt;")
61
+ .replaceAll('"', "&quot;")
62
+ .replaceAll("'", "&apos;");
63
+ }
64
+
65
+ /** The report.txt line, minus the timestamps the marker position already is. */
66
+ function markerName(seg: Segment): string {
67
+ const dur = (seg.srcOut - seg.srcIn).toFixed(2);
68
+ const conf = seg.confidence !== undefined ? ` (conf ${seg.confidence.toFixed(2)})` : "";
69
+ return `${seg.reason ?? "cut"} −${dur}s${conf}`;
70
+ }
71
+
72
+ export function buildFcpxmlMarkers(production: Production): string {
73
+ const { path, probe } = production.source;
74
+ const fd = fpsToFrameDuration(probe.fps);
75
+ const dur = quantizeToFrame(probe.duration, fd);
76
+ const name = basename(path);
77
+ const removals = (production.cutlist ?? []).filter((s) => s.kind === "remove");
78
+ const markers = removals
79
+ .map(
80
+ (s) =>
81
+ ` <marker start="${quantizeToFrame(s.srcIn, fd)}" ` +
82
+ `duration="${fd.num}/${fd.den}s" value="${esc(markerName(s))}"/>`,
83
+ )
84
+ .join("\n");
85
+ // pathToFileURL, not string concat: it percent-encodes the characters a
86
+ // URL cannot carry (spaces, #) while the XML escaping below handles the
87
+ // ones an ATTRIBUTE cannot (&) — two encodings, two owners.
88
+ const srcUrl = esc(pathToFileURL(path).href);
89
+ return `<?xml version="1.0" encoding="UTF-8"?>
90
+ <!DOCTYPE fcpxml>
91
+ <fcpxml version="1.10">
92
+ <resources>
93
+ <format id="r1" frameDuration="${fd.num}/${fd.den}s" width="${probe.width}" height="${probe.height}"/>
94
+ <asset id="r2" name="${esc(name)}" start="0/${fd.den}s" duration="${dur}" hasVideo="1" hasAudio="${probe.hasAudio ? 1 : 0}" format="r1">
95
+ <media-rep kind="original-media" src="${srcUrl}"/>
96
+ </asset>
97
+ </resources>
98
+ <library>
99
+ <event name="ossclip">
100
+ <project name="${esc(`${name} — ossclip markers`)}">
101
+ <sequence format="r1" duration="${dur}" tcStart="0/${fd.den}s">
102
+ <spine>
103
+ <asset-clip ref="r2" offset="0/${fd.den}s" name="${esc(name)}" start="0/${fd.den}s" duration="${dur}" format="r1">
104
+ ${markers ? `${markers}\n` : ""} </asset-clip>
105
+ </spine>
106
+ </sequence>
107
+ </project>
108
+ </event>
109
+ </library>
110
+ </fcpxml>
111
+ `;
112
+ }
package/src/index.ts CHANGED
@@ -27,5 +27,6 @@ export * from "./face";
27
27
  export * from "./cover";
28
28
  export * from "./source-text";
29
29
  export * from "./report";
30
+ export * from "./export-fcpxml";
30
31
  export * from "./config";
31
32
  export { run } from "./exec";
package/src/overrides.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  type Theme,
9
9
  } from "./scene-schema";
10
10
  import { resolveSceneProps } from "./scene-registry";
11
- import type { CaptionLine } from "./captions";
11
+ import type { CaptionLine, CaptionWord } from "./captions";
12
12
 
13
13
  /**
14
14
  * The user's edit layer (SPEC: direct manipulation).
@@ -98,8 +98,8 @@ export const SceneOverrideSchema = z.object({
98
98
  /**
99
99
  * Vertical centre for this scene's captions (R15 §56). NOT part of the
100
100
  * top-level `captions` key — that one is the caption TEXT retype map,
101
- * keyed by word index; position is a property of the SCENE, where the
102
- * timeline selection can address it and "apply to all" can fan it out.
101
+ * keyed per word; position is a property of the SCENE, where the timeline
102
+ * selection can address it and "apply to all" can fan it out.
103
103
  */
104
104
  captionY: z.number().min(0).max(1).optional(),
105
105
  /** Caption size multiplier (R16 §64) — same per-scene, fan-out-able shape
@@ -136,12 +136,13 @@ export type SceneOverride = z.infer<typeof SceneOverrideSchema>;
136
136
  * One retyped caption word (PLAN 2026-07-29 Task 7, scope (a) — decided with
137
137
  * the author: 1:1 in-place retype, timing untouched).
138
138
  *
139
- * Keyed by the word's position in the caption stream, GUARDED by the text
140
- * that was there when the edit was made — the same verification-anchor
141
- * pattern as `AppliedRepair.heard` (§17). Captions are derived (repaired
142
- * transcript through the TimeMap), so a changed cleanup level or repair set
143
- * can shift positions; the guard means a stale edit is DROPPED WITH A LOG
144
- * rather than silently landing on the wrong word.
139
+ * Keyed by the word's SOURCE time since §137 (`captionKeyFor` below — the
140
+ * original positional key is what a user cut broke), GUARDED by the text that
141
+ * was there when the edit was made — the same verification-anchor pattern as
142
+ * `AppliedRepair.heard` (§17). Captions are derived (repaired transcript
143
+ * through the TimeMap), so a changed cleanup level or repair set can still
144
+ * re-word the stream under an anchor that survived; the guard means a stale
145
+ * edit is DROPPED WITH A LOG rather than silently landing on the wrong word.
145
146
  */
146
147
  export const CaptionEditSchema = z.object({
147
148
  /** The replacement text. */
@@ -162,10 +163,78 @@ export type CaptionEdit = z.infer<typeof CaptionEditSchema>;
162
163
  */
163
164
  export function captionEditWas(
164
165
  captions: Record<string, CaptionEdit>,
165
- index: number,
166
+ key: string,
166
167
  seen: string,
167
168
  ): string {
168
- return captions[String(index)]?.was ?? seen;
169
+ return captions[key]?.was ?? seen;
170
+ }
171
+
172
+ /**
173
+ * The id a pre-§137 split gets when it is upgraded: the output milliseconds of
174
+ * whatever `at` the file holds NOW.
175
+ *
176
+ * Load-bearing for the migration — a saved doc hiding `scene-0@600` should
177
+ * still match that half after the upgrade — but ONLY for a doc that has not
178
+ * already been through a re-anchoring produce run, and the distinction is not
179
+ * cosmetic (final review, Important 3). `at` is the one thing a re-cut moves,
180
+ * so on a doc the §137 bug already damaged this reproduces the CURRENT time,
181
+ * not the original: the field workdir's live `overrides.json` holds
182
+ * `splits: [0]`, mints id `"0"`, and its saved `scene-0@600` matches nothing.
183
+ * There is no better derivation available — the original ms is genuinely gone
184
+ * from a re-anchored file, and nothing on disk records it — so the honest
185
+ * statement is that a doc damaged BEFORE this fix landed cannot recover its
186
+ * split-half overrides, and only `overrides.json.bak` can (which is why
187
+ * `produce`'s write gate must not spend it; final review, Critical 2).
188
+ */
189
+ export function legacySplitId(at: number): string {
190
+ return String(Math.round(at * 1000));
191
+ }
192
+
193
+ export const SplitSchema = z.union([
194
+ // `.finite()` is stated rather than assumed. JSON has no Infinity literal
195
+ // but an overflowing one (`1e400`) parses to it, and a non-finite `at`
196
+ // would derive `id: "Infinity"` — one shared name for every such split,
197
+ // the same garbage-derived-key failure `captionKeyFor` refuses for caption
198
+ // words. zod v4's `z.number()` already rejects non-finite where v3's did
199
+ // not, so this is a requirement written down at the site instead of a
200
+ // default that has already changed once underneath this file.
201
+ z.object({ at: z.number().finite().nonnegative(), id: z.string().min(1) }),
202
+ // Legacy: a bare number, upgraded in place so every overrides.json written
203
+ // before §137 parses and keeps its split-half overrides attached.
204
+ z.number().finite().nonnegative().transform((at) => ({ at, id: legacySplitId(at) })),
205
+ ]);
206
+ export type Split = z.infer<typeof SplitSchema>;
207
+
208
+ /**
209
+ * A split id that no split in `existing` already holds.
210
+ *
211
+ * Uniqueness is load-bearing (§137): the id is the ONLY thing tying an
212
+ * override to a split half — `splitCues` names the half `${rootId}@${id}` and
213
+ * `dropHiddenCues` filters on that exact string — so two splits sharing an id
214
+ * mint two cues with one name, and deleting one half deletes both, as does
215
+ * any framing or timing edit on it.
216
+ *
217
+ * Decoupling `id` from `at` is what made this reachable. While the id was
218
+ * recomputed from the time, two ids could only collide if two splits sat
219
+ * within 0.5ms of each other, which `SPLIT_MIN_PIECE_SEC` forbids. Now a
220
+ * split minted at 1.2s and re-anchored to 0.6s by a re-cut still holds
221
+ * `"1200"`, so ⌘B at 1.2s again asks for an id that is taken — and
222
+ * `addSplit`'s dedupe cannot see it, because that compares `at` and 0.6 is
223
+ * nowhere near 1.2.
224
+ *
225
+ * The suffix is a COUNTER, deliberately, not a nonce or a timestamp: this
226
+ * value is persisted in the user's `overrides.json` and names a cue, so it
227
+ * has to be reproducible from the doc alone.
228
+ */
229
+ export function mintSplitId(at: number, existing: readonly Split[]): string {
230
+ const base = legacySplitId(at);
231
+ const taken = new Set(existing.map((s) => s.id));
232
+ if (!taken.has(base)) return base;
233
+ // Starts at 2 so the first collision reads as "the second `1200`".
234
+ for (let n = 2; ; n++) {
235
+ const candidate = `${base}-${n}`;
236
+ if (!taken.has(candidate)) return candidate;
237
+ }
169
238
  }
170
239
 
171
240
  export const OverrideDocSchema = z.object({
@@ -185,16 +254,22 @@ export const OverrideDocSchema = z.object({
185
254
  */
186
255
  captionsHidden: z.boolean().optional(),
187
256
  scenes: z.record(z.string(), SceneOverrideSchema).default({}),
188
- /** Retyped caption words, keyed by caption-stream word index. */
257
+ /** Retyped caption words, keyed by the word's source time (§137). */
189
258
  captions: z.record(z.string(), CaptionEditSchema).default({}),
190
259
  /**
191
- * Scene split points in ABSOLUTE output seconds (R16 §61 — Cmd/Ctrl+B at
192
- * the playhead). Time-anchored rather than scene-anchored on purpose: a
193
- * re-plan can rename or move scenes, and a split is a decision about a
194
- * MOMENT of the output. Applied by `splitCues` after the plain fill, so a
195
- * split lands on graphic cues and takes alike.
260
+ * Scene split points. `at` is ABSOLUTE output seconds (R16 §61 — Cmd/Ctrl+B
261
+ * at the playhead) and moves when a re-cut re-anchors the doc; `id` is
262
+ * minted once when the split is created and NEVER recomputed (§137). The
263
+ * split half is named `${rootId}@${id}`, so re-anchoring `at` cannot rename
264
+ * the half out from under a `hidden` (or any other) override on it — the
265
+ * bug that resurrected a deleted scene in the field case.
266
+ *
267
+ * `at` stays time-anchored rather than scene-anchored on purpose: a re-plan
268
+ * can rename or move scenes, and WHERE to cut is a decision about a MOMENT
269
+ * of the output. Applied by `splitCues` after the plain fill, so a split
270
+ * lands on graphic cues and takes alike.
196
271
  */
197
- splits: z.array(z.number().nonnegative()).default([]),
272
+ splits: z.array(SplitSchema).default([]),
198
273
  /**
199
274
  * User cuts — ranges of the OUTPUT to remove, in the output seconds of the
200
275
  * CURRENT render-props (what the user saw when they cut) (PLAN 2026-08-04
@@ -292,8 +367,9 @@ function resolveSwappedProps(
292
367
  /**
293
368
  * The override entry a cue resolves against: its own, layered over its split
294
369
  * ROOT's (R16 §68). A split half is still the same scene — captions scaled
295
- * on the original must render scaled on both halves — so `id@ms` inherits
296
- * everything from `id`, with two exceptions that describe the WHOLE original
370
+ * on the original must render scaled on both halves — so `id@<split id>`
371
+ * inherits everything from `id` (the suffix is the split's own minted id since
372
+ * §137, not a time), with two exceptions that describe the WHOLE original
297
373
  * rather than a piece of it: `timing` (the root's absolute window would undo
298
374
  * the split) and `hidden` (deleting the original is not deleting one half).
299
375
  * The half's OWN entry wins key by key, and the record-shaped keys merge
@@ -378,10 +454,14 @@ export const SPLIT_MIN_PIECE_SEC = 0.3;
378
454
  * Cut cues at the stored split points (R16 §61).
379
455
  *
380
456
  * Both halves keep everything but their window; the half STARTING at the
381
- * split takes the id `${id}@${ms}` — named by its start time, so edits on it
382
- * stay attached while the split exists, survive further splits of the same
383
- * original cue, and are reported as orphans (never misapplied) if the split
384
- * is removed. Runs AFTER `fillPlainCues` so takes split like scenes do, and
457
+ * split takes the id `${rootId}@${split.id}` — named by the split's OWN
458
+ * minted id (§137), so edits on it stay attached while the split exists,
459
+ * survive further splits of the same original cue, and are reported as
460
+ * orphans (never misapplied) if the split is removed. The id used to be
461
+ * recomputed from the split's CURRENT start time, which meant a re-cut
462
+ * re-anchoring `at` renamed the half and orphaned every override on it —
463
+ * the field case where a deleted scene came back after a 0.6s cut.
464
+ * Runs AFTER `fillPlainCues` so takes split like scenes do, and
385
465
  * BEFORE the final override pass so the halves' own edits (framing, timing,
386
466
  * elements) land on them. A split that misses every cue — after a re-plan
387
467
  * moved the material — is skipped; the time stays in the doc, harmless.
@@ -389,23 +469,25 @@ export const SPLIT_MIN_PIECE_SEC = 0.3;
389
469
  * intro animation (a Sequence restarts at its own frame 0) — acceptable for
390
470
  * the feature's real use, cutting takes and re-timing halves.
391
471
  */
392
- export function splitCues(cues: readonly SceneCue[], times: readonly number[]): SceneCue[] {
472
+ export function splitCues(cues: readonly SceneCue[], splits: readonly Split[]): SceneCue[] {
393
473
  const out = [...cues];
394
- for (const t of [...times].sort((a, b) => a - b)) {
474
+ for (const s of [...splits].sort((a, b) => a.at - b.at)) {
395
475
  const i = out.findIndex(
396
- (c) => t >= c.startSec + SPLIT_MIN_PIECE_SEC && t <= c.endSec - SPLIT_MIN_PIECE_SEC,
476
+ (c) => s.at >= c.startSec + SPLIT_MIN_PIECE_SEC && s.at <= c.endSec - SPLIT_MIN_PIECE_SEC,
397
477
  );
398
478
  if (i === -1) continue;
399
479
  const cue = out[i]!;
400
480
  // Derive from the ROOT id, not the (possibly already-split) cue id:
401
481
  // `take-0@6000`, never `take-0@3000@6000` — so a half's id depends only
402
- // on the original cue and its own start time, and adding an EARLIER
482
+ // on the original cue and the split that made it, and adding an EARLIER
403
483
  // split cannot rename later halves out from under their edits.
404
484
  out.splice(
405
485
  i,
406
486
  1,
407
- { ...cue, endSec: t },
408
- { ...cue, id: `${cue.id.split("@")[0]}@${Math.round(t * 1000)}`, startSec: t },
487
+ { ...cue, endSec: s.at },
488
+ // The suffix comes from the SPLIT's own id, not from `s.at` — that is
489
+ // the §137 fix.
490
+ { ...cue, id: `${cue.id.split("@")[0]}@${s.id}`, startSec: s.at },
409
491
  );
410
492
  }
411
493
  return out;
@@ -462,17 +544,396 @@ export function splitThenDropHidden(
462
544
  return dropHiddenCues(splitCues(cues, doc.splits), doc);
463
545
  }
464
546
 
547
+ /**
548
+ * A caption edit's key: the word's source start, quantised to milliseconds
549
+ * (§137). Positional indices were the original design and a user cut breaks
550
+ * them — removing one word shifts every later index, so the `was` guard below
551
+ * fires on every edit and the user's retypes vanish into the report nobody
552
+ * printed. Source time is the one property of a word that a re-cut cannot
553
+ * move.
554
+ *
555
+ * THROWS on a non-finite `srcStart` rather than minting `wNaN`. The type
556
+ * promises a number and `captions.ts:33-39` says outright that the promise is
557
+ * a lie at the render-props boundary — the editor loads that file as an
558
+ * unvalidated cast, so a pre-§137 workdir yields words with the field absent.
559
+ * `w${Math.round(NaN * 1000)}` is `"wNaN"` for EVERY word: one shared anchor
560
+ * for a whole video, under which a single stored edit would rewrite every word
561
+ * in it. That is the failure this whole change exists to prevent, arriving
562
+ * silently. Parse, never coerce — and a loud throw is the parse here, since a
563
+ * missing anchor has no honest fallback. `backfillSrcStart` (Task 6's load
564
+ * path) is what keeps legacy files from reaching this.
565
+ */
566
+ export function captionKeyFor(srcStart: number): string {
567
+ if (!Number.isFinite(srcStart)) {
568
+ throw new Error(
569
+ `captionKeyFor: caption words need a finite srcStart (§137), got ${String(srcStart)} — ` +
570
+ `run backfillSrcStart on lines read from a pre-§137 render-props.json`,
571
+ );
572
+ }
573
+ return `w${Math.round(srcStart * 1000)}`;
574
+ }
575
+
576
+ /** A pre-§137 key: a bare non-negative integer, i.e. a caption-word position. */
577
+ export function isLegacyCaptionKey(key: string): boolean {
578
+ return /^\d+$/.test(key);
579
+ }
580
+
581
+ /**
582
+ * The anchor a word carries, or null when it carries none.
583
+ *
584
+ * `captionKeyFor` THROWS on a non-finite `srcStart` and should: reaching it
585
+ * with one is a programmer error. A word that simply has no `srcStart` is a
586
+ * DATA condition, not a programmer error — the render-props boundary is an
587
+ * unvalidated cast (`captions.ts:33-39`), so a pre-§137 workdir loads with the
588
+ * field absent on every word (the editor's own e2e fixture is exactly that,
589
+ * preserved on purpose). The editor calls `applyCaptionEdits` inside a
590
+ * render-time `useMemo` with no error boundary above it, so a throw down there
591
+ * white-screens the whole editor over a file that merely predates the field
592
+ * (§137 review). Distinguishing the two here keeps the parse loud where it
593
+ * means something and turns the boundary case into "this word can carry no
594
+ * edit" — the edits that then find no home are REPORTED (`found: null` /
595
+ * `unresolved`), which is the honest answer. `backfillSrcStart` on the load
596
+ * path is what makes these words anchorable again.
597
+ *
598
+ * PUBLIC since §137 Task 5, deliberately: the editor needs the same verdict
599
+ * before it writes an edit, and a second copy of "is this word anchorable"
600
+ * living in `apps/editor` is how the two would drift apart. Every caller that
601
+ * holds a word it did not itself construct should come through here rather
602
+ * than calling `captionKeyFor` — a `useEdits` retype runs in a React event
603
+ * handler with no error boundary above it, so a throw there is a crash on any
604
+ * pre-§137 workdir, not a caught parse failure.
605
+ */
606
+ export function captionAnchorOf(word: CaptionWord | undefined): string | null {
607
+ if (!word || !Number.isFinite(word.srcStart)) return null;
608
+ return captionKeyFor(word.srcStart);
609
+ }
610
+
611
+ /**
612
+ * How far either side of the stored index the migration will look for `was`
613
+ * (§137).
614
+ *
615
+ * A JUDGEMENT, not a measurement — stated because this is the constant that
616
+ * decides how much of a user's saved work the one-shot upgrade recovers, and
617
+ * it shipped with a *what* comment and no *why* (final review, Important 2).
618
+ * The trade runs in both directions at once: every extra word the scan
619
+ * considers is another chance that a common word (`the`, `it`) matches by
620
+ * coincidence, and the ambiguity rule below turns a coincidence into a
621
+ * REFUSAL. So widening this recovers more far-drifted edits and refuses more
622
+ * near ones; it is not a free "more is better" dial. Eight words is on the
623
+ * order of two or three seconds of speech — comfortably past the field case (a
624
+ * 0.6s trim moved every stored index by one) while still short enough that the
625
+ * window usually holds a given word once.
626
+ *
627
+ * The bound is affordable only because being past it is no longer a LOSS. Such
628
+ * an edit is reported `out-of-range` — the word is still on screen, merely too
629
+ * far from where the edit was stored to be sure it is the same one — and both
630
+ * migration callers carry it through into the doc they keep, so a later run
631
+ * against a different cut can still place it. Deleting it was the final
632
+ * review's Critical 1.
633
+ *
634
+ * THE TWO MIGRATION PATHS SEE DIFFERENT DRIFT, which is what makes the value
635
+ * user-visible rather than internal. The editor migrates against the LAST
636
+ * run's `render-props.json`, and its live preview deliberately never applies
637
+ * `doc.cuts` (`App.tsx`) — so the positions the doc stored and the positions
638
+ * it resolves against are the same ones: drift 0, every legacy edit exact-hits
639
+ * and shows as applied. `produce` migrates against lines built AFTER the
640
+ * user's new cut, so its drift is the number of caption words that cut
641
+ * removed. A cut removing more than this many words therefore shows every
642
+ * retype in the preview and reports them `out-of-range` in the render. Closing
643
+ * that gap would mean re-implementing the EDL in the browser, which is the one
644
+ * thing `App.tsx`'s live memo exists to avoid, so the divergence is STATED
645
+ * rather than fixed — and `out-of-range` plus the write-back's preservation
646
+ * rule is what keeps it a message instead of vanished work.
647
+ *
648
+ * Exported so the report lines can name the bound they hit, and so the
649
+ * boundary tests are written against the constant rather than against `8`.
650
+ */
651
+ export const MIGRATION_SEARCH_RADIUS = 8;
652
+
653
+ /**
654
+ * WHY an edit could not be migrated. Reported rather than folded into one
655
+ * message (§137 Task 6 review, Minor 7): each cause needs something different
656
+ * from the user, and blaming the cut for all of them sends someone looking for
657
+ * a word that is still sitting on screen.
658
+ * - `not-found`: no word here says `was` any more — a cut removed it, or a
659
+ * re-plan rewrote it.
660
+ * - `out-of-range`: a word here DOES say `was`, but only past
661
+ * `MIGRATION_SEARCH_RADIUS` from the stored index. Split out of
662
+ * `not-found` (final review, Important 2): the two are the same silence to
663
+ * the code and opposite advice to the user — one word is gone, the other is
664
+ * sitting on screen untouched, and telling that user "the cut removed it"
665
+ * sends them to redo work they can still see.
666
+ * - `ambiguous`: several words nearby say `was` and the search cannot tell
667
+ * which one the user meant.
668
+ * - `unanchorable`: the word IS here, but carries no source time to key on —
669
+ * a render-props.json with no usable `spans` to backfill from.
670
+ * - `collision`: two legacy edits resolved to the same word; neither can be
671
+ * trusted over the other.
672
+ * - `superseded`: a legacy edit resolved onto a word an already-source-keyed
673
+ * edit holds. The CURRENT-format edit wins and is kept; this is the older
674
+ * duplicate being retired, not a loss of the live edit.
675
+ */
676
+ export type CaptionMigrationReason =
677
+ | "not-found"
678
+ | "out-of-range"
679
+ | "ambiguous"
680
+ | "unanchorable"
681
+ | "collision"
682
+ | "superseded";
683
+
684
+ export interface CaptionKeyMigration {
685
+ edits: Record<string, CaptionEdit>;
686
+ /**
687
+ * Edits the migration would not commit — reported, never guessed at. Keyed
688
+ * by their ORIGINAL doc key, which is the only name the user's file knows
689
+ * them by, and carrying WHY (see `CaptionMigrationReason`).
690
+ */
691
+ unresolved: Array<{ key: string; was: string; reason: CaptionMigrationReason }>;
692
+ }
693
+
694
+ /**
695
+ * An answer from `resolveCaptionAnchor`: an anchor, or why there is none.
696
+ * `collision`/`superseded` are excluded because they are not properties of one
697
+ * edit at all — they need the other claims to be visible first.
698
+ */
699
+ type AnchorClaim =
700
+ | { to: string }
701
+ | { to: null; reason: Exclude<CaptionMigrationReason, "collision" | "superseded"> };
702
+
703
+ /**
704
+ * Where one stored edit wants to land, or why it cannot land anywhere.
705
+ *
706
+ * Split out of `migrateCaptionKeys` so every edit can be resolved BEFORE any
707
+ * of them is written: a collision is only visible once both claims exist, and
708
+ * a function that writes as it goes cannot see the second claim coming.
709
+ */
710
+ function resolveCaptionAnchor(
711
+ key: string,
712
+ edit: CaptionEdit,
713
+ words: readonly CaptionWord[],
714
+ ): AnchorClaim {
715
+ // Already a source key (or something that is not a position at all) — the
716
+ // doc's own key stands, and the collision check downstream still applies.
717
+ if (!isLegacyCaptionKey(key)) return { to: key };
718
+ const at = Number(key);
719
+ // The record confirming itself — see `migrateCaptionKeys` for why this
720
+ // wins ahead of the ambiguity rule rather than through it. A confirmed
721
+ // position that carries no anchor resolves to NOTHING rather than falling
722
+ // through to the search: the record already named the word, and letting the
723
+ // search then pick a same-text word elsewhere would rewrite one the user
724
+ // did not edit.
725
+ if (words[at]?.text === edit.was) return anchorOrUnanchorable(words[at]);
726
+ const matches: number[] = [];
727
+ for (let d = 1; d <= MIGRATION_SEARCH_RADIUS; d++) {
728
+ for (const i of [at - d, at + d]) {
729
+ if (words[i]?.text === edit.was) matches.push(i);
730
+ }
731
+ }
732
+ // Ambiguity is judged on the TEXT matches, before anchors are considered —
733
+ // an unanchorable candidate still means the search could not tell two words
734
+ // apart, so it must not silently narrow the field to one.
735
+ if (matches.length === 0) {
736
+ // Nothing WITHIN the radius — but that is two different facts, and they
737
+ // owe the user opposite advice (final review, Important 2). A full scan
738
+ // (only ever on the failure path, so it costs nothing on a healthy doc)
739
+ // separates "the cut removed this word" from "the word is right there,
740
+ // further from the stored index than the search is willing to trust". The
741
+ // second is not re-anchored — past the radius the position record has been
742
+ // proven wrong by too much for a lone text match to stand in for it — but
743
+ // it is REPORTED as what it is, and the callers keep the edit in the doc
744
+ // so a later run can still place it.
745
+ const elsewhere = words.some((w) => w.text === edit.was);
746
+ return { to: null, reason: elsewhere ? "out-of-range" : "not-found" };
747
+ }
748
+ if (matches.length > 1) return { to: null, reason: "ambiguous" };
749
+ return anchorOrUnanchorable(words[matches[0]!]);
750
+ }
751
+
752
+ /** The word was FOUND; whether it can be keyed on is a separate question. */
753
+ function anchorOrUnanchorable(word: CaptionWord | undefined): AnchorClaim {
754
+ const anchor = captionAnchorOf(word);
755
+ return anchor === null ? { to: null, reason: "unanchorable" } : { to: anchor };
756
+ }
757
+
758
+ /**
759
+ * Upgrade pre-§137 positional keys to source-time keys.
760
+ *
761
+ * Position first, and an exact position hit WINS OUTRIGHT — it is not a
762
+ * guess. The stored index is the position the editor recorded when the user
763
+ * made the edit, and `was` matching the word now sitting there is that record
764
+ * confirming itself: two independent facts agreeing. The same word appearing
765
+ * elsewhere nearby weakens neither, so the ambiguity rule below deliberately
766
+ * does NOT gate this branch (ruling on the §137 plan, task 2: folding the
767
+ * exact hit into the candidate scan makes a confirmed record lose to an
768
+ * unrelated coincidence, and a doc that never drifted at all would stop
769
+ * migrating because the user happened to edit a repeated word — that breaks
770
+ * "every existing overrides.json keeps working" for a case where we have the
771
+ * answer).
772
+ *
773
+ * When the word at that position is NOT the edit's `was`, the position has
774
+ * been PROVEN wrong (a cut removed words before it), so search outward for
775
+ * the `was`: that recovers the field case rather than discarding work the
776
+ * user already did. Ambiguity gates that search alone — two candidates a
777
+ * search genuinely cannot tell apart are reported instead, because a wrong
778
+ * anchor silently rewrites the wrong word, which is worse than an edit the
779
+ * user has to redo.
780
+ *
781
+ * TWO LEGACY EDITS RESOLVING TO THE SAME WORD are that same ambiguity one
782
+ * level up, and both go to `unresolved` — the output is a Record, so writing as
783
+ * we went would have the second edit silently overwrite the first and report
784
+ * nothing, which is the very bug this task was opened for. It is not a corner
785
+ * case:
786
+ * - an outward search can land on the word another edit exact-hit (`the cat
787
+ * sat on a mat`, edits at "0" and "5", both `was: "the"`);
788
+ * - two words can share a `srcStart` outright — `backfillSrcStart`
789
+ * (`captions.ts:44-50`) maps seam preimages and cut-clamped words onto the
790
+ * same source instant BY DESIGN, so duplicate keys are manufactured, not
791
+ * float trivia.
792
+ * Neither edit is guessed at, both are named, and the user can re-apply the
793
+ * one they meant.
794
+ *
795
+ * A LEGACY EDIT COLLIDING WITH AN ALREADY-SOURCE-KEYED ONE is NOT that case,
796
+ * and refusing both was a real bug (§137 Task 6 review, Important 3): a doc
797
+ * holding both key spaces at once — `{"0": …, "w6000": …}` over one word — is
798
+ * the normal shape of any project edited before and after this change, and
799
+ * treating it as an unbreakable tie DELETED the newer, current-format edit
800
+ * whose anchor was never in doubt. The source-keyed edit WINS: it is the one
801
+ * the editor wrote most recently, it names its word directly rather than by a
802
+ * position something may have shifted, and it is the format everything else
803
+ * reads. Only the legacy claim is retired, reported as `superseded`. (There
804
+ * can be at most one source-keyed claimant per anchor: such a claim resolves
805
+ * to its own key, and a Record cannot hold one key twice.)
806
+ */
807
+ export function migrateCaptionKeys(
808
+ edits: Record<string, CaptionEdit>,
809
+ lines: readonly CaptionLine[],
810
+ ): CaptionKeyMigration {
811
+ const words = lines.flatMap((l) => l.words);
812
+ const out: Record<string, CaptionEdit> = {};
813
+ const unresolved: CaptionKeyMigration["unresolved"] = [];
814
+
815
+ // Resolve every edit first, write second — see the collision paragraph.
816
+ const claims = Object.entries(edits).map(([key, edit]) => ({
817
+ key,
818
+ edit,
819
+ legacy: isLegacyCaptionKey(key),
820
+ claim: resolveCaptionAnchor(key, edit, words),
821
+ }));
822
+ type Claim = (typeof claims)[number];
823
+ const claimants = new Map<string, Claim[]>();
824
+ for (const c of claims) {
825
+ if (c.claim.to === null) continue;
826
+ const rivals = claimants.get(c.claim.to);
827
+ if (rivals) rivals.push(c);
828
+ else claimants.set(c.claim.to, [c]);
829
+ }
830
+
831
+ for (const c of claims) {
832
+ if (c.claim.to === null) {
833
+ unresolved.push({ key: c.key, was: c.edit.was, reason: c.claim.reason });
834
+ continue;
835
+ }
836
+ const rivals = claimants.get(c.claim.to)!;
837
+ // Identity, not `.key` — the winner has to be THIS claim object, or a
838
+ // second claimant would write itself in over the one that already won.
839
+ const winner = rivals.length === 1 ? rivals[0]! : rivals.find((r) => !r.legacy);
840
+ if (winner === c) {
841
+ out[c.claim.to] = c.edit;
842
+ continue;
843
+ }
844
+ unresolved.push({
845
+ key: c.key,
846
+ was: c.edit.was,
847
+ // `winner` undefined means every claimant was legacy: a genuine tie.
848
+ reason: winner === undefined ? "collision" : "superseded",
849
+ });
850
+ }
851
+ return { edits: out, unresolved };
852
+ }
853
+
854
+ /**
855
+ * The caption map to KEEP after a migration: everything it placed, plus every
856
+ * edit it would not place, left exactly as the user's file holds it.
857
+ *
858
+ * `migration.edits` alone is what both callers wrote back at first, and it is
859
+ * a DELETE (final review, Critical 1): an edit produce cannot anchor this run
860
+ * may well be anchorable the next one — a different cut, re-planned lines, or
861
+ * simply a `MIGRATION_SEARCH_RADIUS` the drift no longer exceeds — and
862
+ * dropping it forecloses that, permanently, on a run the user only asked to
863
+ * render. Nobody asked for a delete. An unresolved key left in the doc costs
864
+ * nothing: it addresses no word, so it applies to nothing, and it is reported
865
+ * by name on every run. That was the pre-§137 status quo for a stale key and
866
+ * it is the right one.
867
+ *
868
+ * `superseded` is the ONE retirement, and it is not a loss: a newer
869
+ * source-keyed edit already covers that word (see `migrateCaptionKeys`), so
870
+ * keeping the older legacy duplicate would re-report the same collision on
871
+ * every run forever with nothing the user could do about it.
872
+ *
873
+ * Preserved keys cannot collide with placed ones — a preserved key is legacy
874
+ * (`/^\d+$/`, since a source key always resolves to itself and wins its
875
+ * anchor) and a placed key is always `w<ms>`. `before` must have been through
876
+ * `OverrideDocSchema`, for the `"__proto__"` reason `migrateCaptionKeys`
877
+ * states.
878
+ */
879
+ export function captionEditsToKeep(
880
+ before: Record<string, CaptionEdit>,
881
+ migration: CaptionKeyMigration,
882
+ ): Record<string, CaptionEdit> {
883
+ const out: Record<string, CaptionEdit> = { ...migration.edits };
884
+ for (const u of migration.unresolved) {
885
+ if (u.reason === "superseded") continue;
886
+ const edit = before[u.key];
887
+ // Unreachable — `unresolved` is built from `before`'s own entries — but
888
+ // this is user data on its way back to disk, and a lookup that came back
889
+ // undefined must not be written into a map typed as `CaptionEdit`.
890
+ if (edit !== undefined) out[u.key] = edit;
891
+ }
892
+ return out;
893
+ }
894
+
465
895
  export interface AppliedCaptionEdits {
466
896
  lines: CaptionLine[];
467
- /** Edits whose guard failed — the word at that index is not what they knew. */
468
- dropped: Array<{ index: number; expected: string; found: string }>;
897
+ /**
898
+ * Edits that did not apply. `found: null` means no word carries that source
899
+ * anchor any more (a cut removed it); a string means the word is there but
900
+ * says something else (a re-plan changed it).
901
+ *
902
+ * `reason` is present ONLY for the third case — a SECOND word carrying an
903
+ * anchor an earlier word already claimed, which is not a stale edit at all
904
+ * (the edit may well have applied, to the first word). Written only when it
905
+ * has something to say, the same rule the override doc's own optional keys
906
+ * follow; absent means the ordinary stale report `found` already
907
+ * distinguishes. A key can therefore appear in this array more than once.
908
+ */
909
+ dropped: Array<{
910
+ key: string;
911
+ expected: string;
912
+ found: string | null;
913
+ reason?: "duplicate-anchor";
914
+ }>;
469
915
  }
470
916
 
471
917
  /**
472
918
  * Apply retyped caption words. Text only, never timing — the stamps drive the
473
919
  * kinetic highlight and the 1:1 constraint is what keeps scene anchors and
474
- * §21's copy/caption agreement intact. An edit whose `was` no longer matches
475
- * is reported, not applied and not silently discarded.
920
+ * §21's copy/caption agreement intact.
921
+ *
922
+ * Keyed by source time since §137, so a user cut earlier in the video no
923
+ * longer shifts every later edit onto the wrong word. An edit that does not
924
+ * apply is REPORTED — callers must surface `dropped`; the editor discarding it
925
+ * is what made this failure invisible in the field case.
926
+ *
927
+ * AT MOST ONE WORD per edit — the first carrying the key, and the guard's
928
+ * verdict on that word is final. Keys are millisecond-quantised, so two words
929
+ * CAN share one (`captions.ts:44-50`: backfilled seam preimages and
930
+ * cut-clamped words land on the same source instant by design, and rounding
931
+ * closes sub-millisecond gaps besides). A plain `.map()` rewrites every word
932
+ * that matches — fanning one retype out onto a word the user never touched,
933
+ * which is exactly the "wrong anchor silently rewrites the wrong word" the
934
+ * migration's ambiguity rule refuses to commit, and a breach of the 1:1
935
+ * in-place retype contract above. Later claimants are reported with
936
+ * `reason: "duplicate-anchor"` and left alone rather than edited.
476
937
  */
477
938
  export function applyCaptionEdits(
478
939
  lines: readonly CaptionLine[],
@@ -480,19 +941,40 @@ export function applyCaptionEdits(
480
941
  ): AppliedCaptionEdits {
481
942
  const dropped: AppliedCaptionEdits["dropped"] = [];
482
943
  if (Object.keys(edits).length === 0) return { lines: [...lines], dropped };
483
- let index = 0;
944
+
945
+ const seen = new Set<string>();
484
946
  const out = lines.map((line) => ({
485
947
  ...line,
486
948
  words: line.words.map((w) => {
487
- const edit = edits[String(index++)];
949
+ // No anchor, no edit — a pre-§137 word cannot be addressed, and this is
950
+ // the boundary that must not throw (see `captionAnchorOf`). The stored
951
+ // edits then fall out of the sweep below as `found: null`.
952
+ const key = captionAnchorOf(w);
953
+ if (key === null) return w;
954
+ const edit = edits[key];
488
955
  if (!edit) return w;
956
+ // An earlier word already answered for this anchor — whichever way it
957
+ // answered. Applying here too would fan one retype onto a second word;
958
+ // re-running the guard here would let an edit be applied AND reported
959
+ // dropped for the same key.
960
+ if (seen.has(key)) {
961
+ dropped.push({ key, expected: edit.was, found: w.text, reason: "duplicate-anchor" });
962
+ return w;
963
+ }
964
+ seen.add(key);
489
965
  if (w.text !== edit.was) {
490
- dropped.push({ index: index - 1, expected: edit.was, found: w.text });
966
+ dropped.push({ key, expected: edit.was, found: w.text });
491
967
  return w;
492
968
  }
493
969
  return { ...w, text: edit.text };
494
970
  }),
495
971
  }));
972
+
973
+ // An anchor no word carries any more — the cut removed the word the user
974
+ // edited. Silence here is exactly the field case, so say it.
975
+ for (const [key, edit] of Object.entries(edits)) {
976
+ if (!seen.has(key)) dropped.push({ key, expected: edit.was, found: null });
977
+ }
496
978
  return { lines: out, dropped };
497
979
  }
498
980
 
@@ -605,7 +1087,9 @@ export interface ReclampResult {
605
1087
  }
606
1088
 
607
1089
  /**
608
- * The scene a cue id belongs to, stripping a split half's `@ms` suffix —
1090
+ * The scene a cue id belongs to, stripping a split half's `@<split id>`
1091
+ * suffix. That suffix is opaque since §137 — a minted id, not a time — so the
1092
+ * `@` is the only thing this can key on; it is the
609
1093
  * same idiom `splitCues` itself uses to derive a later half's id from its
610
1094
  * root, and the same one `effectiveOverride` above inlines to find a half's
611
1095
  * root entry. Two cues sharing a root are the SAME scene, cut in two.
package/src/recut.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { OverrideDoc } from "./overrides";
1
+ import { SPLIT_MIN_PIECE_SEC, type OverrideDoc } from "./overrides";
2
2
  import type { Segment } from "./schema";
3
3
  import { mapsClose, TimeMap } from "./timemap";
4
4
 
@@ -56,7 +56,65 @@ export function remapOverridesThroughRecut(
56
56
  ): RecutRemap {
57
57
  const reports: string[] = [];
58
58
 
59
- const splits = doc.splits.map((t) => remapPoint("split", t, oldMap, newMap, reports));
59
+ // Only `at` moves: a split's `id` is minted once and never recomputed
60
+ // (§137, `SplitSchema`) — re-deriving it here is what renamed the half and
61
+ // orphaned the overrides on it.
62
+ const splits = doc.splits.map((s) => {
63
+ const before = reports.length;
64
+ const at = remapPoint(`split "${s.id}"`, s.at, oldMap, newMap, reports);
65
+ // `splitCues` needs a cue with `at >= startSec + SPLIT_MIN_PIECE_SEC` AND
66
+ // `at <= endSec - SPLIT_MIN_PIECE_SEC`. Output time runs [0,
67
+ // outputDuration] and every cue lives inside it, so a split closer than
68
+ // that to EITHER end can match no cue at all and is skipped: the half it
69
+ // named stops existing, every override keyed to it is orphaned, and the
70
+ // scene the user deleted comes back. Before §137 the only trace of that
71
+ // was produce.ts's generic `edit for scene-0@600 dropped — the plan no
72
+ // longer has that scene`, which blames the plan and names neither the
73
+ // split nor the re-cut that moved it. Name both.
74
+ //
75
+ // Start: the field case — a 0.6s cut pushed a split at 0.6s to 0.
76
+ // End: `at` need not move at all; cutting the tail moves `outputDuration`
77
+ // out from under it (a split at 9.5s of 10s, with 9.6–10.0 cut).
78
+ //
79
+ // Both fire only when THIS re-cut is what pushed the split past the bar:
80
+ // `reports` is the "a value MOVED" channel (see `RecutRemap`), and a
81
+ // split already past it beforehand is a pre-existing condition — one the
82
+ // editor's own SPLIT_MIN_PIECE_SEC guard refuses to create — that would
83
+ // otherwise be re-announced on every identity re-cut, forever.
84
+ //
85
+ // The two guards look mirrored and are NOT symmetric about a pure shift,
86
+ // which is worth stating because reading them as a matched pair invites
87
+ // "a shift can never be reported" — false at the start. The END bar is
88
+ // `outputDuration - MIN`, so it slides with the timeline and both sides of
89
+ // that comparison move together. The START bar is absolute 0 + MIN and
90
+ // does not slide, so `at < MIN && s.at >= MIN` DOES fire under a pure
91
+ // shift — and must: trimming 0.6s off the front is exactly what dragged a
92
+ // split at 0.6s down to 0 in the field case (recut.test.ts, "reports the
93
+ // split whose remapped `at` can no longer divide anything").
94
+ //
95
+ // `remapPoint` states the new time itself when it snapped this split onto
96
+ // a cut edge; restating it here would read as a second, separate move
97
+ // rather than the consequence of the one already reported.
98
+ const where = reports.length > before ? "is" : `is now ${at.toFixed(3)}s —`;
99
+ if (at < SPLIT_MIN_PIECE_SEC && s.at >= SPLIT_MIN_PIECE_SEC) {
100
+ reports.push(
101
+ `split "${s.id}" ${where} too close to the start to divide a scene, ` +
102
+ `so any edit on its second half will not apply`,
103
+ );
104
+ // `else`, not a second `if`: an output shorter than two minimum pieces
105
+ // trips both bars for the same split, and one line already says it can
106
+ // no longer divide anything — two would read as two problems.
107
+ } else if (
108
+ at > newMap.outputDuration - SPLIT_MIN_PIECE_SEC &&
109
+ s.at <= oldMap.outputDuration - SPLIT_MIN_PIECE_SEC
110
+ ) {
111
+ reports.push(
112
+ `split "${s.id}" ${where} too close to the end to divide a scene, ` +
113
+ `so any edit on its second half will not apply`,
114
+ );
115
+ }
116
+ return { ...s, at };
117
+ });
60
118
 
61
119
  // Record-shaped: rebuild key by key rather than mutate, matching every
62
120
  // other `OverrideDoc`-shaping function in overrides.ts (e.g.