@ossclip/core 0.1.30 → 0.1.33

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/src/overrides.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import { z } from "zod/v4";
2
2
  import {
3
3
  LayoutSchema,
4
+ SceneAnchorSchema,
4
5
  SceneComponentIdSchema,
5
6
  ThemeSchema,
7
+ type SceneAnchor,
6
8
  type SceneCue,
7
9
  type SceneComponentId,
8
10
  type Theme,
9
11
  } from "./scene-schema";
10
12
  import { RemovalReasonSchema } from "./schema";
13
+ import type { TimeMap } from "./timemap";
11
14
  import { resolveSceneProps } from "./scene-registry";
12
15
  import type { CaptionLine, CaptionWord } from "./captions";
13
16
 
@@ -40,16 +43,75 @@ export const ElementTransformSchema = z.object({
40
43
  });
41
44
  export type ElementTransform = z.infer<typeof ElementTransformSchema>;
42
45
 
46
+ /**
47
+ * A pinned scene window, in one of two clocks — the last unconverted
48
+ * old-clock field in the doc (source-anchoring audit, 2026-08-26).
49
+ *
50
+ * {startSec, endSec} — LEGACY: absolute OUTPUT seconds on the clock of the
51
+ * LAST RENDER. Semantics unchanged, and it stays in
52
+ * `remapOverridesThroughRecut`'s hands: a re-cut moves those numbers.
53
+ * {srcStart, srcEnd} — SOURCE seconds, the §155 principle ("key on the
54
+ * property the disruption cannot move") that `cuts[].src`,
55
+ * `cleanup.kept`, `splits[].src` and the caption keys already anchor on.
56
+ * RECUT-IMMUNE by construction: the remap passes it through untouched,
57
+ * and it is resolved onto whichever clock is in hand by
58
+ * `resolveTimingPin`.
59
+ *
60
+ * The same DELIBERATE divergence `SplitSchema.src` claims applies here: the
61
+ * editor MAY write the src shape. A cut is a RANGE over props the editor did
62
+ * not compute; a pinned window is a pair of POINTS on a clock the editor
63
+ * holds exactly — the drag preview's own clock, converted at the gesture
64
+ * (`previewClockMappers.toSourceSec` under a live veto,
65
+ * `mapFromKeptSpans(spans).toSource` otherwise). That gesture-time
66
+ * conversion IS the fix: with a veto live, the preview seconds spoke the
67
+ * LIVE clock while `timing` spoke the last render's, so a dragged block was
68
+ * stored seconds from where it was dropped and snapped back (or vanished) on
69
+ * the next derive.
70
+ *
71
+ * No third shape and no dual-write: one of these is authoritative, and a
72
+ * doc that recorded both would need a rule for which clock wins after a
73
+ * re-cut moved only one of them.
74
+ */
75
+ export const SceneTimingSchema = z.union([
76
+ z.object({
77
+ startSec: z.number().finite().nonnegative(),
78
+ endSec: z.number().finite().nonnegative(),
79
+ }),
80
+ // `.finite()` stated rather than assumed, `SplitSchema`'s reasoning: an
81
+ // overflowing literal (`1e400`) parses to Infinity, and a non-finite
82
+ // source second would resolve through `TimeMap` into a window nothing can
83
+ // render.
84
+ z
85
+ .object({
86
+ srcStart: z.number().finite().nonnegative(),
87
+ srcEnd: z.number().finite().nonnegative(),
88
+ })
89
+ // Ordered, because unlike the legacy arm there is no downstream clamp
90
+ // that could rescue it: `resolveTimingPin` maps both edges through the
91
+ // same map, so an inverted pair stays inverted and reaches `SceneLayer`,
92
+ // which assumes increasing windows.
93
+ .refine((t) => t.srcEnd > t.srcStart, { message: "srcEnd must be after srcStart" }),
94
+ ]);
95
+ export type SceneTiming = z.infer<typeof SceneTimingSchema>;
96
+
97
+ /** The src-anchored arm of `SceneTimingSchema` — a guard, so no consumer
98
+ * re-derives the discriminant (and gets it subtly wrong on a doc where a
99
+ * legacy entry happens to carry an extra key). */
100
+ export function isSrcTiming(timing: SceneTiming): timing is { srcStart: number; srcEnd: number } {
101
+ return "srcStart" in timing;
102
+ }
103
+
43
104
  export const SceneOverrideSchema = z.object({
44
105
  /** Merged over the producer's props, key by key. */
45
106
  props: z.record(z.string(), z.unknown()).default({}),
46
107
  /** Per-element nudges, keyed by the component's `data-edit-id`. */
47
108
  elements: z.record(z.string(), ElementTransformSchema).default({}),
48
109
  /**
49
- * Absolute output time. Setting this PINS the scene: it stops tracking the
110
+ * An absolute window. Setting this PINS the scene: it stops tracking the
50
111
  * words it was anchored to, which is why the UI has to say so out loud.
112
+ * `SceneTimingSchema` above owns which clock the numbers speak.
51
113
  */
52
- timing: z.object({ startSec: z.number().nonnegative(), endSec: z.number().nonnegative() }).optional(),
114
+ timing: SceneTimingSchema.optional(),
53
115
  /**
54
116
  * Component/layout swaps (design spec Scope: v1 in-scope). Optional — most
55
117
  * scenes never touch these — and validated against the same enums the
@@ -121,6 +183,16 @@ export const SceneOverrideSchema = z.object({
121
183
  h: z.number().min(0.05).max(1),
122
184
  })
123
185
  .optional(),
186
+ /**
187
+ * The word range of the cue this edit was made against, stamped by the
188
+ * editor at save time (stampSceneAnchors). This is the edit's IDENTITY
189
+ * across a re-plan: ids are positional (`scene-${i}`) and a re-plan can
190
+ * hand an id to a different moment — matching on the anchor instead is
191
+ * what stops that edit landing there silently (handoff-edit-anchoring).
192
+ * Optional: docs written before this field keep id-only behaviour, the
193
+ * same no-retroactive-protection posture §137 took for captions.
194
+ */
195
+ anchor: SceneAnchorSchema.optional(),
124
196
  /**
125
197
  * The scene is deleted — SOFTLY (PLAN 2026-07-30 Task C): the cue drops
126
198
  * from the render (`dropHiddenCues`) and its window becomes a plain take,
@@ -249,13 +321,96 @@ export const SplitSchema = z.union([
249
321
  // words. zod v4's `z.number()` already rejects non-finite where v3's did
250
322
  // not, so this is a requirement written down at the site instead of a
251
323
  // default that has already changed once underneath this file.
252
- z.object({ at: z.number().finite().nonnegative(), id: z.string().min(1) }),
324
+ //
325
+ // `src` (cut-review rework, 2026-08-26) is the split's SOURCE second — the
326
+ // §155 principle ("key on the property the disruption cannot move")
327
+ // applied to splits, the way `cuts[].src`, `cleanup.kept` and the caption
328
+ // keys already anchor. When present it is AUTHORITATIVE and the split is
329
+ // recut-immune (`remapOverridesThroughRecut` passes it through untouched);
330
+ // `at` then survives only as the historical old-clock record, the
331
+ // `cuts[].startSec` posture. Three shapes, all meaningful:
332
+ // {at} — legacy doc; produce backfills `src` once via priorMap.
333
+ // {at, src} — a normal ⌘B: the editor dual-writes both.
334
+ // {src} — ⌘B INSIDE revived material (a kept cleanup removal): no
335
+ // old-clock image exists, and none is invented.
336
+ //
337
+ // DELIBERATE divergence from the `cuts[].src` editor-never-writes rule:
338
+ // the editor MAY write `splits[].src`. A cut is a RANGE drawn on props the
339
+ // editor did not compute, resolvable only against produce's priorMap; a
340
+ // split is a POINT on a clock the editor itself holds exactly —
341
+ // `newMap.toSource(liveSec)` under a live veto (`livePreviewMap` builds
342
+ // that very map client-side), `mapFromKeptSpans(spans).toSource` otherwise.
343
+ z
344
+ .object({
345
+ at: z.number().finite().nonnegative().optional(),
346
+ src: z.number().finite().nonnegative().optional(),
347
+ id: z.string().min(1),
348
+ })
349
+ .refine((s) => s.at !== undefined || s.src !== undefined, {
350
+ message: "a split needs `at` or `src`",
351
+ }),
253
352
  // Legacy: a bare number, upgraded in place so every overrides.json written
254
353
  // before §137 parses and keeps its split-half overrides attached.
255
- z.number().finite().nonnegative().transform((at) => ({ at, id: legacySplitId(at) })),
354
+ z
355
+ .number()
356
+ .finite()
357
+ .nonnegative()
358
+ // `src: undefined` spelled out so both union arms share one output
359
+ // shape — consumers read `s.src` without narrowing the arm first.
360
+ .transform((at) => ({ at: at as number | undefined, src: undefined as number | undefined, id: legacySplitId(at) })),
256
361
  ]);
257
362
  export type Split = z.infer<typeof SplitSchema>;
258
363
 
364
+ /** A split resolved onto ONE clock — what `splitCues` actually consumes.
365
+ * `resolveSplitPoints` (src-aware, needs a TimeMap) and `atSplitPoints`
366
+ * (old-clock, no map needed) both produce this. */
367
+ export interface ResolvedSplitPoint {
368
+ at: number;
369
+ id: string;
370
+ }
371
+
372
+ /** The splits that carry an old-clock `at` — the editor's pass-1 input (its
373
+ * cue windows speak the last render's clock) and the no-map fallback. A
374
+ * src-only split is NOT here by definition; it applies via
375
+ * `resolveSplitPoints` on the clock the map defines. */
376
+ export function atSplitPoints(splits: readonly Split[]): ResolvedSplitPoint[] {
377
+ return splits.flatMap((s) => (s.at !== undefined ? [{ at: s.at, id: s.id }] : []));
378
+ }
379
+
380
+ /**
381
+ * Resolve every split onto the clock `map` defines. `src` wins when present
382
+ * (the authoritative anchor); a src whose `toOutput` is null sits inside
383
+ * material the CURRENT cut removes — the split is INERT this run, skipped
384
+ * with a report, never clamped to a seam (`cleanup.kept`'s inert-entry
385
+ * posture: the doc keeps it, and it wakes up when that material is kept
386
+ * again). A src-less split passes its old-clock `at` through — meaningful
387
+ * only when `map` IS that old clock, which is produce's situation after
388
+ * `remapOverridesThroughRecut` re-anchored it, and the editor's pass-1.
389
+ */
390
+ export function resolveSplitPoints(
391
+ splits: readonly Split[],
392
+ map: TimeMap,
393
+ ): { points: ResolvedSplitPoint[]; reports: string[] } {
394
+ const points: ResolvedSplitPoint[] = [];
395
+ const reports: string[] = [];
396
+ for (const s of splits) {
397
+ if (s.src !== undefined) {
398
+ const at = map.toOutput(s.src);
399
+ if (at === null) {
400
+ reports.push(
401
+ `split "${s.id}" sits in removed material (source ${s.src.toFixed(3)}s) — ` +
402
+ `inert until that material is kept again`,
403
+ );
404
+ continue;
405
+ }
406
+ points.push({ at, id: s.id });
407
+ } else if (s.at !== undefined) {
408
+ points.push({ at: s.at, id: s.id });
409
+ }
410
+ }
411
+ return { points, reports };
412
+ }
413
+
259
414
  /**
260
415
  * A split id that no split in `existing` already holds.
261
416
  *
@@ -378,17 +533,79 @@ export const OverrideDocSchema = z.object({
378
533
  )
379
534
  .default({}),
380
535
  /**
381
- * Scene split points. `at` is ABSOLUTE output seconds (R16 §61 — Cmd/Ctrl+B
382
- * at the playhead) and moves when a re-cut re-anchors the doc; `id` is
383
- * minted once when the split is created and NEVER recomputed (§137). The
384
- * split half is named `${rootId}@${id}`, so re-anchoring `at` cannot rename
385
- * the half out from under a `hidden` (or any other) override on it — the
386
- * bug that resurrected a deleted scene in the field case.
536
+ * Per-LINE caption display WINDOWS — "show this caption from HERE to HERE",
537
+ * an ABSOLUTE span in SOURCE seconds, keyed by the LINE's FIRST WORD's
538
+ * SOURCE time (`captionKeyFor`, §137) like `captionLineTiming` above.
539
+ * Written by the editor's audio-first timing tool, which does all of its
540
+ * arithmetic against the waveform — SOURCE audio, the one clock a re-cut
541
+ * cannot move — so what the user heard while dragging is literally what is
542
+ * stored. Applied by `applyCaptionLineWindows` AFTER the nudge layer, which
543
+ * is what makes a window the LAST word on a line it is stored for.
387
544
  *
388
- * `at` stays time-anchored rather than scene-anchored on purpose: a re-plan
389
- * can rename or move scenes, and WHERE to cut is a decision about a MOMENT
390
- * of the output. Applied by `splitCues` after the plain fill, so a split
391
- * lands on graphic cues and takes alike.
545
+ * ABSOLUTE, NOT A DELTA, and that is the difference from `captionLineTiming`
546
+ * rather than a duplication of it. A nudge asks the derived window to move a
547
+ * bit and therefore has to be interpreted against whatever the derivation
548
+ * produced this run; a window states the answer. That is why this record
549
+ * needs NO SWEEP, NO COINCIDENCE RULE AND NO NEIGHBOUR WRITES: the sweep in
550
+ * `applyCaptionLineTiming` exists to keep SEAM edits ordered, and an
551
+ * absolute window is not a seam edit — the user placed it against the audio
552
+ * and only the user gets to say two captions may not share a moment.
553
+ * OVERLAP IS THEREFORE LEGAL here (the renderer mounts overlapping
554
+ * `<Sequence>`s happily, `CaptionTrack.tsx`); the editor TINTS a conflict
555
+ * instead of resolving it, because the neighbour it would have to move is
556
+ * material the user may be about to place too.
557
+ *
558
+ * Recut-immune BY CONSTRUCTION — both the key and the value are source
559
+ * seconds, so there is nothing in this record for `remapOverridesThroughRecut`
560
+ * to re-anchor and it deliberately has no entry there (the `splits[].src`
561
+ * property, and the reason the deltas above need none either). It DOES get
562
+ * re-keyed by `rekeyCaptionRecords` when a range re-transcription moves the
563
+ * stamps under it (Phase A): the KEY names a word whose stamp moved, and the
564
+ * value is a window the user placed against audio that did not.
565
+ *
566
+ * The floor and the ordering are the schema's, not a clamp: a window
567
+ * narrower than `MIN_CAPTION_SEC` is a delete wearing a timing gesture's
568
+ * clothes (the constant's own docstring) and an inverted one would mirror
569
+ * the line's word order through `scaleWordsIntoWindow`. The layer clamps
570
+ * once more in OUTPUT seconds anyway, because a cut INSIDE the window can
571
+ * narrow it however wide the source span was. `.default({})` keeps every
572
+ * pre-existing overrides.json parsing byte-identically, and the field never
573
+ * existed in the legacy positional-key era, so `migrateCaptionKeys` must not
574
+ * process it.
575
+ */
576
+ captionLineWindows: z
577
+ .record(
578
+ z.string(),
579
+ z
580
+ .object({ srcStart: z.number(), srcEnd: z.number() })
581
+ // The message states the floor literally rather than interpolating
582
+ // the constant: this schema is built at module load, `MIN_CAPTION_SEC`
583
+ // is declared further down, and a template literal here would read it
584
+ // in its temporal dead zone (the refine CALLBACK runs at parse time,
585
+ // long after, so it may name it).
586
+ .refine((w) => w.srcEnd - w.srcStart >= MIN_CAPTION_SEC, {
587
+ message: "caption window must be at least 50ms (MIN_CAPTION_SEC) wide, ordered start → end",
588
+ }),
589
+ )
590
+ .default({}),
591
+ /**
592
+ * Scene split points (R16 §61 — Cmd/Ctrl+B at the playhead). Since the
593
+ * cut-review rework (2026-08-26) `src` — SOURCE seconds — is the
594
+ * authoritative anchor when present (recut-immune, the `cleanup.kept`
595
+ * trick; see SplitSchema for the three shapes and the editor-may-write-src
596
+ * divergence). `at` is the old-clock output second: authoritative only on
597
+ * src-less legacy entries, where it still moves under
598
+ * `remapOverridesThroughRecut`. `id` is minted once when the split is
599
+ * created and NEVER recomputed (§137). The split half is named
600
+ * `${rootId}@${id}`, so re-anchoring cannot rename the half out from under
601
+ * a `hidden` (or any other) override on it — the bug that resurrected a
602
+ * deleted scene in the field case.
603
+ *
604
+ * Time-anchored rather than scene-anchored on purpose: a re-plan can
605
+ * rename or move scenes, and WHERE to cut is a decision about a MOMENT of
606
+ * the footage. Applied by `splitCues` (over `resolveSplitPoints` /
607
+ * `atSplitPoints`) after the plain fill, so a split lands on graphic cues
608
+ * and takes alike.
392
609
  */
393
610
  splits: z.array(SplitSchema).default([]),
394
611
  /**
@@ -415,15 +632,38 @@ export const OverrideDocSchema = z.object({
415
632
  * a historical record of what render-props they were looking at, never
416
633
  * authoritative again once `src` is present.
417
634
  *
418
- * The editor (PLAN 2026-08-04 Task 4c) MUST NEVER WRITE OR
419
- * PRESERVE-AND-MODIFY `src` ITSELF — resolving it is produce's job alone.
420
- * Creating a cut writes ONLY `{startSec, endSec}`; if a cut's range is
421
- * ever edited/moved (not currently exposed, but the rule holds for any
422
- * future gesture that would), its `src` is DELETED rather than carried
423
- * forward, so the next produce re-resolves it against the render-props
424
- * current at that point rather than an anchor drawn for a range that no
425
- * longer means the same thing; Restore removes the WHOLE entry, `src`
426
- * included — there is no "not cut" state for one array entry to hold.
635
+ * The editor MAY write `src` (cut-review rework, 2026-08-26 — the same
636
+ * divergence `SplitSchema` documents for `splits[].src`, and it retires
637
+ * this field's original editor-never-writes-src rule). The premise behind
638
+ * that rule was that only produce can resolve a cut's source range; that
639
+ * died with the client-side clock: `livePreviewMap`/`previewClockMappers`
640
+ * (retime-preview.ts) build the SAME `TimeMap` over the render-props' own
641
+ * `spans` that produce would resolve through, so the writer converts its
642
+ * window at the gesture and stores the answer. Produce then consumes it
643
+ * VERBATIM (`resolveCutSourceRanges`, recut.ts) — the arriving `src` wins,
644
+ * exactly as it does for a src produce itself resolved — and the priorMap
645
+ * fallback stays for legacy src-less entries, which is what keeps every
646
+ * overrides.json written before this reading identically.
647
+ *
648
+ * Three shapes, all meaningful:
649
+ * {startSec, endSec} — legacy/no-mapper: marked-only, produce
650
+ * resolves `src` on the next run.
651
+ * {startSec, endSec, src} — the editor's normal write: the record plus
652
+ * the anchor, resolved at write time.
653
+ * The record may be CLAMPED (or absent-in-spirit) when the window sits
654
+ * in revived material with no old-clock image — historical, never
655
+ * authoritative once `src` exists, so a clamped record is honest.
656
+ *
657
+ * `src` present also means LIVE-APPLIED in the editor: the preview
658
+ * subtracts those ranges and genuinely stops playing the material
659
+ * (retime-preview.ts's module doc). The rest of the original rule stands:
660
+ * if a cut's range is ever edited/moved (not currently exposed, but the
661
+ * rule holds for any future gesture that would), its `src` is DELETED
662
+ * rather than carried forward, so the next produce re-resolves it against
663
+ * the render-props current at that point rather than an anchor drawn for a
664
+ * range that no longer means the same thing; Restore removes the WHOLE
665
+ * entry, `src` included — there is no "not cut" state for one array entry
666
+ * to hold.
427
667
  */
428
668
  cuts: z
429
669
  .array(
@@ -470,8 +710,25 @@ export const OverrideDocSchema = z.object({
470
710
  kept: z
471
711
  .array(z.object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() }))
472
712
  .default([]),
713
+ /**
714
+ * DISMISSED proposals ("not a retake", cut-review rework 2026-08-26) —
715
+ * the user says the classification itself was wrong: the marker leaves
716
+ * the lane, the material is ordinary footage, and the state survives
717
+ * re-produce (buildCutlist re-proposes, the overlap match suppresses).
718
+ * SOURCE seconds and overlap-matched exactly like `kept` — recut-immune
719
+ * by construction. Distinct from `kept` on purpose: a veto says "remove
720
+ * proposed, I decline it (this once)", a dismissal says "there was
721
+ * nothing to remove"; `vetoedRemovals` must not paint a dismissed
722
+ * marker as "kept · retake". One state per range: the editor's dismiss
723
+ * action deletes any overlapping `kept` entry. `user`/`clip` spans are
724
+ * never dismissible (`cleanupVetoable`). Optional-with-default like
725
+ * `kept` — absent parses byte-identically to today.
726
+ */
727
+ dismissed: z
728
+ .array(z.object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() }))
729
+ .default([]),
473
730
  })
474
- .default({ reasons: {}, kept: [] }),
731
+ .default({ reasons: {}, kept: [], dismissed: [] }),
475
732
  });
476
733
  export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
477
734
 
@@ -554,6 +811,77 @@ function effectiveOverride(
554
811
  };
555
812
  }
556
813
 
814
+ /**
815
+ * A pinned window resolved onto the clock `map` defines — `resolveSplitPoints`'
816
+ * posture, for windows instead of points.
817
+ *
818
+ * A LEGACY entry passes through verbatim and needs no map at all: its numbers
819
+ * are already output seconds, meaningful exactly when `map` IS that clock
820
+ * (produce's situation after `remapOverridesThroughRecut` re-anchored them,
821
+ * and the editor's pass-1). A SRC entry maps both edges; `null` on EITHER
822
+ * means the material this pin names is removed on this clock, so the pin is
823
+ * INERT this run — never half-resolved and never clamped onto a seam, the
824
+ * `cleanup.kept`/`resolveSplitPoints` inert-entry posture: the doc keeps the
825
+ * entry and it wakes up when that material is kept again.
826
+ */
827
+ export function resolveTimingPin(
828
+ timing: SceneTiming,
829
+ map: TimeMap,
830
+ ): { startSec: number; endSec: number } | null {
831
+ if (!isSrcTiming(timing)) return { startSec: timing.startSec, endSec: timing.endSec };
832
+ const startSec = map.toOutput(timing.srcStart);
833
+ const endSec = map.toOutput(timing.srcEnd);
834
+ if (startSec === null || endSec === null) return null;
835
+ return { startSec, endSec };
836
+ }
837
+
838
+ /**
839
+ * Resolve every SRC-anchored pin in `doc` onto the clock `map` defines,
840
+ * leaving a doc whose `timing` entries are all output-clock — what
841
+ * `applyOverrides` (and, downstream of it, `reclampPinnedTiming`) consumes.
842
+ *
843
+ * A PRE-PASS rather than a `map` parameter on `applyOverrides` deliberately:
844
+ * that function is called four times across two callers, on three different
845
+ * clocks, and an optional map would let a src pin silently no-op wherever the
846
+ * argument was forgotten. This shape makes the clock an explicit decision at
847
+ * every call site — the caller states which cue list, and therefore which
848
+ * clock, it is about to merge onto.
849
+ *
850
+ * The returned doc is for THAT merge only and must never be written back:
851
+ * persisting the resolved numbers would spend the source anchor and put the
852
+ * pin back on an old clock, which is the whole bug this schema arm fixes.
853
+ * Docs with no src pin get the SAME object back, so a session that has never
854
+ * written one computes bit-identical values to before this existed.
855
+ */
856
+ export function resolveSrcTimingPins(
857
+ doc: OverrideDoc,
858
+ map: TimeMap,
859
+ ): { doc: OverrideDoc; reports: string[] } {
860
+ const pinned = Object.keys(doc.scenes).filter((id) => {
861
+ const t = doc.scenes[id]?.timing;
862
+ return t !== undefined && isSrcTiming(t);
863
+ });
864
+ if (pinned.length === 0) return { doc, reports: [] };
865
+ const reports: string[] = [];
866
+ const scenes = { ...doc.scenes };
867
+ for (const id of pinned) {
868
+ const scene = scenes[id]!;
869
+ const window = resolveTimingPin(scene.timing!, map);
870
+ if (window === null) {
871
+ // The pin LEAVES this doc rather than being carried unresolved: an
872
+ // unresolved src entry reaching `applyOverrides` is ignored there
873
+ // anyway, and dropping it here is what makes that ignoring provable
874
+ // rather than incidental.
875
+ const { timing: _inert, ...rest } = scene;
876
+ scenes[id] = rest;
877
+ reports.push(`pinned timing for "${id}" is not in this cut — pin inert`);
878
+ continue;
879
+ }
880
+ scenes[id] = { ...scene, timing: window };
881
+ }
882
+ return { doc: { ...doc, scenes }, reports };
883
+ }
884
+
557
885
  export function applyOverrides(cues: readonly SceneCue[], doc: OverrideDoc): AppliedOverrides {
558
886
  const ids = new Set(cues.map((c) => c.id));
559
887
  const orphans = Object.keys(doc.scenes).filter((id) => !ids.has(id));
@@ -592,7 +920,13 @@ export function applyOverrides(cues: readonly SceneCue[], doc: OverrideDoc): App
592
920
  // window to its first half — which kept the scene's id — would undo
593
921
  // the cut and overlap the second half. An unsplit pinned cue skips a
594
922
  // byte-identical re-application; a not-yet-pinned cue pins as before.
595
- ...(o.timing && !cue.pinned
923
+ // A SRC-anchored pin is NOT applied here: this function has no map, and
924
+ // the numbers it would need depend on which clock `cues` is on. The
925
+ // caller resolves them first (`resolveSrcTimingPins`, above) — reaching
926
+ // this branch with a src entry means no pre-pass ran, and doing nothing
927
+ // is the only honest answer available (guessing an output window from a
928
+ // source second is exactly the old-clock lie this arm removes).
929
+ ...(o.timing && !isSrcTiming(o.timing) && !cue.pinned
596
930
  ? { startSec: o.timing.startSec, endSec: o.timing.endSec, pinned: true }
597
931
  : {}),
598
932
  };
@@ -626,7 +960,10 @@ export const SPLIT_MIN_PIECE_SEC = 0.3;
626
960
  * intro animation (a Sequence restarts at its own frame 0) — acceptable for
627
961
  * the feature's real use, cutting takes and re-timing halves.
628
962
  */
629
- export function splitCues(cues: readonly SceneCue[], splits: readonly Split[]): SceneCue[] {
963
+ export function splitCues(
964
+ cues: readonly SceneCue[],
965
+ splits: readonly ResolvedSplitPoint[],
966
+ ): SceneCue[] {
630
967
  const out = [...cues];
631
968
  for (const s of [...splits].sort((a, b) => a.at - b.at)) {
632
969
  const i = out.findIndex(
@@ -697,8 +1034,245 @@ export function dropHiddenCues(cues: readonly SceneCue[], doc: OverrideDoc): Dro
697
1034
  export function splitThenDropHidden(
698
1035
  cues: readonly SceneCue[],
699
1036
  doc: OverrideDoc,
1037
+ // Resolved points, when the caller holds a map (produce, the editor's
1038
+ // post-carve pass). Default: the doc's old-clock `at` entries — src-only
1039
+ // splits have no image on that clock and are correctly absent.
1040
+ points: readonly ResolvedSplitPoint[] = atSplitPoints(doc.splits),
700
1041
  ): DropHiddenResult {
701
- return dropHiddenCues(splitCues(cues, doc.splits), doc);
1042
+ return dropHiddenCues(splitCues(cues, points), doc);
1043
+ }
1044
+
1045
+ /**
1046
+ * Stamp every scene override with the anchor of the cue it currently targets
1047
+ * — the edit's identity across a re-plan (see SceneOverrideSchema.anchor).
1048
+ * Called by the EDITOR at save time, from the cues in its memory, never from
1049
+ * render-props.json on disk: after a mid-session re-render the disk can
1050
+ * describe a newer plan than the one the user is looking at, and stamping
1051
+ * from it would record the wrong identity — the misapply this exists to stop.
1052
+ * Cues without an anchor (plain takes, pre-anchor render-props) stamp nothing.
1053
+ *
1054
+ * RE-stamps on every save, deliberately: the cue on screen is always the
1055
+ * freshest truth about what the user is editing, so a stale stamp from an
1056
+ * earlier plan is overwritten rather than preserved. A split half's cue
1057
+ * carries its root's anchor verbatim (`splitCues` spreads the root cue), so
1058
+ * the `id@<split id>` entry stamps through the same by-id lookup as its root.
1059
+ *
1060
+ * `doc` must have been through `OverrideDocSchema` — the `captionEditsToKeep`
1061
+ * rule: a literal `"__proto__"` key surviving `JSON.parse` as an own property
1062
+ * would assign through the prototype in the record rebuild below.
1063
+ */
1064
+ export function stampSceneAnchors(doc: OverrideDoc, cues: readonly SceneCue[]): OverrideDoc {
1065
+ const anchorById = new Map<string, SceneAnchor>();
1066
+ for (const c of cues) {
1067
+ if (c.anchor) anchorById.set(c.id, c.anchor);
1068
+ }
1069
+ const scenes: Record<string, SceneOverride> = {};
1070
+ for (const [id, entry] of Object.entries(doc.scenes)) {
1071
+ const anchor = anchorById.get(id);
1072
+ scenes[id] = anchor ? { ...entry, anchor } : entry;
1073
+ }
1074
+ return { ...doc, scenes };
1075
+ }
1076
+
1077
+ /** Shared word count of two anchors — inclusive word-index ranges, so
1078
+ * touching at a single word counts as 1 and disjoint ranges go ≤ 0. */
1079
+ const wordOverlap = (a: SceneAnchor, b: SceneAnchor): number =>
1080
+ Math.min(a.endWord, b.endWord) - Math.max(a.startWord, b.startWord) + 1;
1081
+
1082
+ /**
1083
+ * The inert suffix a misapply-blocked edit is parked under. `#` never
1084
+ * appears in a cue id (`scene-${i}`, `take-*`, split halves use `@`), so a
1085
+ * parked key matches no cue and the edit sits harmless — data and anchor
1086
+ * intact — until a later plan brings its words back and rescues it.
1087
+ */
1088
+ const PARKED_SUFFIX = "#orphaned";
1089
+
1090
+ /** The one exported spelling of the parked-key convention: produce's orphan
1091
+ * warning must ask this, never re-spell the literal, or the two sides drift. */
1092
+ export function isParkedOverrideKey(key: string): boolean {
1093
+ return key.endsWith(PARKED_SUFFIX);
1094
+ }
1095
+
1096
+ /** The key a parked entry was parked FROM — what the user's warning should
1097
+ * name, since the suffix is bookkeeping, not something they ever typed. */
1098
+ export function parkedOverrideBaseKey(key: string): string {
1099
+ return isParkedOverrideKey(key) ? key.slice(0, -PARKED_SUFFIX.length) : key;
1100
+ }
1101
+
1102
+ export interface SceneRemapResult {
1103
+ doc: OverrideDoc;
1104
+ /** Human sentences for produce to print — one per re-keyed, parked, or blocked entry. */
1105
+ notes: string[];
1106
+ }
1107
+
1108
+ /**
1109
+ * Re-key scene overrides onto the cues that carry their WORDS — the
1110
+ * produce-side counterpart of `stampSceneAnchors` above
1111
+ * (handoff-edit-anchoring; §137 is the caption-side precedent).
1112
+ *
1113
+ * Ids are positional (`scene-${i}`) and a re-plan renumbers them freely: in
1114
+ * the two plan pairs measured for this change, 8/11 and 2/10 ids moved while
1115
+ * every anchor still found its moment at 100% overlap — and in the field,
1116
+ * `scene-4` was a TerminalMock over words 85..116 in one plan and a
1117
+ * FlowDiagram over words 47..57 in the next. An edit keyed by id alone lands
1118
+ * on that impostor silently. So the stored anchor, not the key, is the
1119
+ * edit's identity: an entry whose id still means the same moment (any word
1120
+ * overlap) is untouched; one whose words moved follows them to their new id;
1121
+ * one whose words are GONE while its id points at a different moment is
1122
+ * parked under `${key}#orphaned` rather than left to join the impostor. A
1123
+ * parked entry's root id is historical, not a claim on today's cue, so it
1124
+ * skips the id-agreement shortcut and matches purely by anchor. A parked
1125
+ * entry whose words are STILL gone stays quiet on later runs; one whose
1126
+ * words are back while its target key is held re-parks and re-notes on
1127
+ * EVERY run — deliberately, because that collision is actionable.
1128
+ *
1129
+ * Anchor-less (pre-migration) entries pass through byte-identical with no
1130
+ * note — the same no-retroactive-protection posture §137 took for captions.
1131
+ * Total on any parsed doc: conflicts resolve deterministically (kept entries
1132
+ * are immovable; contending re-keys go to the larger overlap; an occupied
1133
+ * park slot keeps its incumbent), never a throw.
1134
+ *
1135
+ * Runs on the post-fill, PRE-`splitCues` cue list, so a split-half key
1136
+ * (`id@splitId`) re-keys by its ROOT and keeps its suffix — the half cue it
1137
+ * must match only exists after `splitCues` runs. `doc` must have been
1138
+ * through `OverrideDocSchema` — the `captionEditsToKeep` rule: a literal
1139
+ * `"__proto__"` key surviving `JSON.parse` as an own property would assign
1140
+ * through the prototype in the record rebuild below.
1141
+ */
1142
+ export function remapSceneOverrides(
1143
+ doc: OverrideDoc,
1144
+ cues: readonly SceneCue[],
1145
+ ): SceneRemapResult {
1146
+ // Anchor-bearing root cues only. Plain fill takes carry no anchor by
1147
+ // construction, and the `@` filter guards against a caller passing a
1148
+ // POST-split list — a half carries its root's anchor verbatim
1149
+ // (`splitCues` spreads the root cue), and matching a half's id here would
1150
+ // mint double-suffixed keys like `scene-1@2000@abc`.
1151
+ const anchored = cues.filter(
1152
+ (c): c is SceneCue & { anchor: SceneAnchor } =>
1153
+ c.anchor !== undefined && !c.id.includes("@"),
1154
+ );
1155
+ const notes: string[] = [];
1156
+ const scenes: Record<string, SceneOverride> = {};
1157
+ interface Claim {
1158
+ key: string;
1159
+ entry: SceneOverride;
1160
+ /** Where this entry lands if it loses its target: `${baseKey}#orphaned`. */
1161
+ parkKey: string;
1162
+ ov: number;
1163
+ }
1164
+ /** Entries headed for a park slot, with the sentence explaining why. */
1165
+ const parks: Array<{ key: string; entry: SceneOverride; parkKey: string; note: string }> = [];
1166
+ /** Re-keying entries, grouped by the key they want — collisions resolve below. */
1167
+ const rekeys = new Map<string, Claim[]>();
1168
+
1169
+ for (const [key, entry] of Object.entries(doc.scenes)) {
1170
+ const stored = entry.anchor;
1171
+ if (!stored) {
1172
+ // Pre-migration entry: exactly today's id-only behaviour, silently.
1173
+ scenes[key] = entry;
1174
+ continue;
1175
+ }
1176
+ const isParked = key.endsWith(PARKED_SUFFIX);
1177
+ const baseKey = isParked ? key.slice(0, -PARKED_SUFFIX.length) : key;
1178
+ const at = baseKey.indexOf("@");
1179
+ const rootId = at === -1 ? baseKey : baseKey.slice(0, at);
1180
+ const parkKey = `${baseKey}${PARKED_SUFFIX}`;
1181
+ const current = anchored.find((c) => c.id === rootId);
1182
+ if (!isParked && current && wordOverlap(stored, current.anchor) > 0) {
1183
+ scenes[key] = entry; // the id still means the same moment
1184
+ continue;
1185
+ }
1186
+ // Id missing, pointing at a different moment, or historical (parked):
1187
+ // follow the anchor.
1188
+ const best = anchored
1189
+ .map((c) => ({ c, ov: wordOverlap(stored, c.anchor) }))
1190
+ .filter((x) => x.ov > 0)
1191
+ // Larger overlap first; on a tie, the cue whose id matches the stored
1192
+ // root (reachable only for parked entries — an unparked id match with
1193
+ // overlap was kept above), then the earlier cue.
1194
+ .sort(
1195
+ (a, b) =>
1196
+ b.ov - a.ov ||
1197
+ Number(b.c.id === rootId) - Number(a.c.id === rootId) ||
1198
+ a.c.startSec - b.c.startSec,
1199
+ )[0];
1200
+ if (!best) {
1201
+ if (!isParked && current) {
1202
+ // The words are gone AND the id now belongs to a different moment.
1203
+ // Leaving the entry under `key` would join the impostor — the exact
1204
+ // silent misapply this pass exists to prevent. Park it.
1205
+ parks.push({
1206
+ key,
1207
+ entry,
1208
+ parkKey,
1209
+ note: `edit for ${key} parked — its words left the plan, and ${rootId} now shows a different moment`,
1210
+ });
1211
+ } else {
1212
+ // The old id matches nothing (or the entry is already parked):
1213
+ // today's orphan path — `applyOverrides` reports it, nothing can
1214
+ // misapply, so no note either.
1215
+ scenes[key] = entry;
1216
+ }
1217
+ continue;
1218
+ }
1219
+ const newKey = at === -1 ? best.c.id : `${best.c.id}${baseKey.slice(at)}`;
1220
+ const list = rekeys.get(newKey) ?? [];
1221
+ list.push({ key, entry, parkKey, ov: best.ov });
1222
+ rekeys.set(newKey, list);
1223
+ }
1224
+
1225
+ for (const [newKey, contenders] of rekeys) {
1226
+ if (scenes[newKey] !== undefined) {
1227
+ // Kept entries are immovable: an anchor-less one must behave exactly
1228
+ // as today, and an id-plus-anchor match is the strongest claim there
1229
+ // is. A re-keyer arriving at a held key parks instead of evicting.
1230
+ for (const c of contenders) {
1231
+ parks.push({
1232
+ key: c.key,
1233
+ entry: c.entry,
1234
+ parkKey: c.parkKey,
1235
+ note: `edit for ${c.key} parked — ${newKey} already carries its own edit`,
1236
+ });
1237
+ }
1238
+ continue;
1239
+ }
1240
+ // Two entries re-keying onto one cue: the larger overlap wins, the loser
1241
+ // parks, both get a sentence. On an exact tie, doc order — deterministic,
1242
+ // and as good as any claim two different stored anchors can make on the
1243
+ // same cue. (`sort` is stable, so equal overlaps keep insertion order.)
1244
+ const [winner, ...losers] = [...contenders].sort((a, b) => b.ov - a.ov);
1245
+ scenes[newKey] = winner!.entry;
1246
+ notes.push(
1247
+ winner!.key.endsWith(PARKED_SUFFIX)
1248
+ ? `edit for ${winner!.key} rescued to ${newKey} — its words are back in the plan`
1249
+ : `edit for ${winner!.key} re-keyed to ${newKey} — the plan renumbered, its words moved there`,
1250
+ );
1251
+ for (const l of losers) {
1252
+ parks.push({
1253
+ key: l.key,
1254
+ entry: l.entry,
1255
+ parkKey: l.parkKey,
1256
+ note: `edit for ${l.key} parked — the edit from ${winner!.key} overlaps ${newKey}'s words more (${winner!.ov} vs ${l.ov})`,
1257
+ });
1258
+ }
1259
+ }
1260
+
1261
+ for (const p of parks) {
1262
+ if (scenes[p.parkKey] !== undefined) {
1263
+ // Doubly-pathological: the slot already holds a still-parked edit for
1264
+ // the same base key (edit → re-plan parks it → edit again → re-plan
1265
+ // again). One inert slot, two edits — keep the incumbent, like every
1266
+ // other hold, and say the loss out loud rather than overwriting
1267
+ // silently.
1268
+ notes.push(`edit for ${p.key} dropped — ${p.parkKey} already holds an earlier parked edit`);
1269
+ continue;
1270
+ }
1271
+ scenes[p.parkKey] = p.entry;
1272
+ notes.push(p.note);
1273
+ }
1274
+
1275
+ return { doc: { ...doc, scenes }, notes };
702
1276
  }
703
1277
 
704
1278
  /**
@@ -1392,8 +1966,9 @@ export function applyCaptionRangeEdits(
1392
1966
  * only the rendered caption stream loses the word.
1393
1967
  *
1394
1968
  * Line WINDOWS are recomputed here, deliberately: `buildCaptionLines` derives
1395
- * `start` from the first word and `end` from the last word plus a hold
1396
- * (captions.ts:203-213), so hiding a boundary word would otherwise leave the
1969
+ * `start` from the first word and `end` from the last word plus a hold (its
1970
+ * hold/breakpoint clamp loop, then `enforceLineDwell`), so hiding a boundary
1971
+ * word would otherwise leave the
1397
1972
  * line lingering on screen over silence — up for the hidden first word's
1398
1973
  * duration, or held past the hidden last word's end. A hidden FIRST word moves
1399
1974
  * `start` to the first survivor; a hidden LAST word re-bases the packer's hold
@@ -1456,7 +2031,7 @@ export function applyCaptionWordHides(
1456
2031
  const firstKept = kept[0]!;
1457
2032
  const lastKept = kept[kept.length - 1]!;
1458
2033
  const start = firstKept === line.words[0] ? line.start : firstKept.start;
1459
- // The packer's hold delta (captions.ts:203-213) rides on whichever word
2034
+ // The packer's hold delta (`buildCaptionLines`) rides on whichever word
1460
2035
  // is now last; clamped so the line never ends before its own last word
1461
2036
  // (the delta can be negative when the hold was clamped to outputDuration).
1462
2037
  const end =
@@ -1539,7 +2114,7 @@ export function scaleWordsIntoWindow(
1539
2114
  * line's END and the next line's START are two separate numbers here — even
1540
2115
  * though on a real transcript they are always equal, because the packer chains
1541
2116
  * words (`transcribe.ts`: `next.start = w.end`) and clamps each line's end to
1542
- * the next line's start (`captions.ts:203-213`), giving inter-line gaps of
2117
+ * the next line's start (`buildCaptionLines`), giving inter-line gaps of
1543
2118
  * exactly zero (measured 116/116, see `captionLineTiming`). A nudge CLOSES the
1544
2119
  * two onto one value only when they were already COINCIDENT: that is what
1545
2120
  * makes a lead on the packed stream move both sides of the boundary, one edit
@@ -1547,8 +2122,10 @@ export function scaleWordsIntoWindow(
1547
2122
  *
1548
2123
  * They are two numbers because GAPS ARE REAL: `applyCaptionWordHides` re-bases
1549
2124
  * a line's window onto its surviving words, `MAX_CAPTION_WORD_LEAD_SEC`
1550
- * (captions.ts:147, 169) clamps a word's display start, and an overrides.json
1551
- * can be hand-edited. This code
2125
+ * (`captions.ts`) clamps a word's display start, and an overrides.json
2126
+ * can be hand-edited. (Citations here name the constant or function
2127
+ * deliberately — the line numbers they used to carry went stale the first
2128
+ * time `captions.ts` grew a constant.) This code
1552
2129
  * used to hold ONE `seams` array whose interior entry was read off the later
1553
2130
  * line's start, conflating the two: with lines `[0,2] [2,4] [5,6]`, a
1554
2131
  * lead-only drag of the middle line (`{lead: -0.05, tail: 0}`, exactly what
@@ -1695,11 +2272,100 @@ export function applyCaptionLineTiming(
1695
2272
  return { lines: out, dropped };
1696
2273
  }
1697
2274
 
2275
+ /**
2276
+ * Apply per-LINE caption display WINDOWS (`captionLineWindows`) — the LAST
2277
+ * layer of all, after the nudges, because a window is the user's FINAL answer
2278
+ * about when a caption is on screen: it is stated absolutely, so anything that
2279
+ * ran before it (a derived window, a nudge on top of that window) is exactly
2280
+ * what it is overriding. A line carrying both records therefore shows its
2281
+ * window, and the nudge is inert rather than compounded — the audio-first tool
2282
+ * that writes windows replaced the nudge popover, so the pair only coexists on
2283
+ * docs edited across the change.
2284
+ *
2285
+ * SOURCE SECONDS IN, OUTPUT SECONDS OUT. The window is stored against the
2286
+ * waveform's clock (`captionLineWindows`' docstring: the one clock a re-cut
2287
+ * cannot move) and the caption track speaks output seconds, so each edge goes
2288
+ * through `map.toOutputClamped` — the same conversion `buildCaptionLines` uses
2289
+ * for the derived windows this replaces, with the same documented behaviour on
2290
+ * material that was cut: the edge lands on the nearest KEPT edge rather than
2291
+ * disappearing. A window whose material was removed ENTIRELY collapses both
2292
+ * edges onto one instant and comes back out at the floor below, sitting on the
2293
+ * seam: the user's window is kept and made visible, not silently dropped,
2294
+ * because the cut is the thing they are more likely to be about to undo.
2295
+ *
2296
+ * NO SWEEP, DELIBERATELY — see the field's docstring. Windows may overlap;
2297
+ * ordering against neighbours is not this layer's business, and every line
2298
+ * whose key carries no entry comes back VERBATIM (same reference, same word
2299
+ * stamps) so one placed caption cannot perturb the rest of the track. The
2300
+ * MIN_CAPTION_SEC floor is the ONE clamp, applied in output seconds because a
2301
+ * cut inside the window can narrow it however wide the source span was.
2302
+ *
2303
+ * Words are re-stamped into the new window by `scaleWordsIntoWindow` — the
2304
+ * karaoke highlight reads word stamps INSIDE the line's `<Sequence>`
2305
+ * (`CaptionTrack.tsx`), so a window moved without them lights the wrong words
2306
+ * or none.
2307
+ *
2308
+ * Same reporting contract as every other per-line layer: an anchor no line
2309
+ * starts on is `found: null` (a cut removed the word, or a hide emptied the
2310
+ * line), a second claimant is `duplicate-anchor`, and `expected` is always
2311
+ * `""` because timing is text-orthogonal (`applyCaptionLineTiming`'s no-`was`
2312
+ * reasoning applies unchanged).
2313
+ */
2314
+ export function applyCaptionLineWindows(
2315
+ lines: readonly CaptionLine[],
2316
+ windows: Record<string, { srcStart: number; srcEnd: number }>,
2317
+ map: TimeMap,
2318
+ ): AppliedCaptionEdits {
2319
+ const dropped: AppliedCaptionEdits["dropped"] = [];
2320
+ // NO LINES is not "no windows to report" — `applyCaptionLineTiming`'s own
2321
+ // note owns the why (silence here let produce report placements that never
2322
+ // happened); the editor's false-banner guard lives at the caller.
2323
+ if (lines.length === 0) {
2324
+ for (const key of Object.keys(windows)) dropped.push({ key, expected: "", found: null });
2325
+ return { lines: [], dropped };
2326
+ }
2327
+ if (Object.keys(windows).length === 0) return { lines: [...lines], dropped };
2328
+
2329
+ const seen = new Set<string>();
2330
+ const out = lines.map((line) => {
2331
+ // No anchor, no window — the same boundary rule as `applyCaptionEdits`: a
2332
+ // pre-§137 word cannot be addressed, and the stored windows then fall out
2333
+ // of the sweep below as `found: null`.
2334
+ const key = captionAnchorOf(line.words[0]);
2335
+ const entry = key === null ? undefined : windows[key];
2336
+ if (key === null || !entry) return line;
2337
+ // An earlier line already answered for this anchor — placing here too
2338
+ // would fan one window onto a second line (captions.ts:44-50: ms-quantised
2339
+ // anchors CAN collide).
2340
+ if (seen.has(key)) {
2341
+ dropped.push({ key, expected: "", found: line.words[0]!.text, reason: "duplicate-anchor" });
2342
+ return line;
2343
+ }
2344
+ seen.add(key);
2345
+ const start = map.toOutputClamped(entry.srcStart);
2346
+ const end = Math.max(map.toOutputClamped(entry.srcEnd), start + MIN_CAPTION_SEC);
2347
+ if (start === line.start && end === line.end) return line;
2348
+ return {
2349
+ ...line,
2350
+ start,
2351
+ end,
2352
+ words: scaleWordsIntoWindow(line.words, line.start, line.end, start, end),
2353
+ };
2354
+ });
2355
+
2356
+ for (const key of Object.keys(windows)) {
2357
+ if (!seen.has(key)) dropped.push({ key, expected: "", found: null });
2358
+ }
2359
+ return { lines: out, dropped };
2360
+ }
2361
+
1698
2362
  export interface AppliedCaptionLayers {
1699
2363
  lines: CaptionLine[];
1700
2364
  /** Every layer's drop reports, tagged with which layer refused them. */
1701
2365
  dropped: Array<
1702
- AppliedCaptionEdits["dropped"][number] & { layer: "edit" | "range" | "hide" | "timing" }
2366
+ AppliedCaptionEdits["dropped"][number] & {
2367
+ layer: "edit" | "range" | "hide" | "timing" | "window";
2368
+ }
1703
2369
  >;
1704
2370
  }
1705
2371
 
@@ -1723,22 +2389,34 @@ export interface AppliedCaptionLayers {
1723
2389
  * window to move (`applyCaptionLineTiming`). Drop reports carry which layer
1724
2390
  * refused them, since "the retype missed", "the rewrite missed" and "the
1725
2391
  * delete missed" send the user to different gestures.
2392
+ *
2393
+ * WINDOWS run after the nudges, last of all — `applyCaptionLineWindows` owns
2394
+ * the why (an absolute answer overrides a relative one, never compounds with
2395
+ * it). That layer is the reason this composer takes a `map`: a window is
2396
+ * stored in SOURCE seconds and the caption track speaks output seconds, so
2397
+ * placing one needs the same cutlist the lines were built through. Required,
2398
+ * not optional — a caller without a map would silently skip the layer, which
2399
+ * is how the editor preview and the render would come to disagree about when
2400
+ * a caption is on screen (the one thing this chokepoint exists to prevent).
1726
2401
  */
1727
2402
  export function applyCaptionLayers(
1728
2403
  lines: readonly CaptionLine[],
1729
2404
  doc: OverrideDoc,
2405
+ map: TimeMap,
1730
2406
  ): AppliedCaptionLayers {
1731
2407
  const edited = applyCaptionEdits(lines, doc.captions);
1732
2408
  const ranged = applyCaptionRangeEdits(edited.lines, doc.captionRangeEdits);
1733
2409
  const hidden = applyCaptionWordHides(ranged.lines, doc.captionWordsHidden);
1734
2410
  const timed = applyCaptionLineTiming(hidden.lines, doc.captionLineTiming);
2411
+ const windowed = applyCaptionLineWindows(timed.lines, doc.captionLineWindows, map);
1735
2412
  return {
1736
- lines: timed.lines,
2413
+ lines: windowed.lines,
1737
2414
  dropped: [
1738
2415
  ...edited.dropped.map((d) => ({ ...d, layer: "edit" as const })),
1739
2416
  ...ranged.dropped.map((d) => ({ ...d, layer: "range" as const })),
1740
2417
  ...hidden.dropped.map((d) => ({ ...d, layer: "hide" as const })),
1741
2418
  ...timed.dropped.map((d) => ({ ...d, layer: "timing" as const })),
2419
+ ...windowed.dropped.map((d) => ({ ...d, layer: "window" as const })),
1742
2420
  ],
1743
2421
  };
1744
2422
  }