@ossclip/core 0.1.31 → 0.1.34

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
@@ -10,6 +10,7 @@ import {
10
10
  type Theme,
11
11
  } from "./scene-schema";
12
12
  import { RemovalReasonSchema } from "./schema";
13
+ import type { TimeMap } from "./timemap";
13
14
  import { resolveSceneProps } from "./scene-registry";
14
15
  import type { CaptionLine, CaptionWord } from "./captions";
15
16
 
@@ -42,16 +43,75 @@ export const ElementTransformSchema = z.object({
42
43
  });
43
44
  export type ElementTransform = z.infer<typeof ElementTransformSchema>;
44
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
+
45
104
  export const SceneOverrideSchema = z.object({
46
105
  /** Merged over the producer's props, key by key. */
47
106
  props: z.record(z.string(), z.unknown()).default({}),
48
107
  /** Per-element nudges, keyed by the component's `data-edit-id`. */
49
108
  elements: z.record(z.string(), ElementTransformSchema).default({}),
50
109
  /**
51
- * 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
52
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.
53
113
  */
54
- timing: z.object({ startSec: z.number().nonnegative(), endSec: z.number().nonnegative() }).optional(),
114
+ timing: SceneTimingSchema.optional(),
55
115
  /**
56
116
  * Component/layout swaps (design spec Scope: v1 in-scope). Optional — most
57
117
  * scenes never touch these — and validated against the same enums the
@@ -261,13 +321,96 @@ export const SplitSchema = z.union([
261
321
  // words. zod v4's `z.number()` already rejects non-finite where v3's did
262
322
  // not, so this is a requirement written down at the site instead of a
263
323
  // default that has already changed once underneath this file.
264
- 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
+ }),
265
352
  // Legacy: a bare number, upgraded in place so every overrides.json written
266
353
  // before §137 parses and keeps its split-half overrides attached.
267
- 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) })),
268
361
  ]);
269
362
  export type Split = z.infer<typeof SplitSchema>;
270
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
+
271
414
  /**
272
415
  * A split id that no split in `existing` already holds.
273
416
  *
@@ -390,17 +533,79 @@ export const OverrideDocSchema = z.object({
390
533
  )
391
534
  .default({}),
392
535
  /**
393
- * Scene split points. `at` is ABSOLUTE output seconds (R16 §61 — Cmd/Ctrl+B
394
- * at the playhead) and moves when a re-cut re-anchors the doc; `id` is
395
- * minted once when the split is created and NEVER recomputed (§137). The
396
- * split half is named `${rootId}@${id}`, so re-anchoring `at` cannot rename
397
- * the half out from under a `hidden` (or any other) override on it — the
398
- * 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.
399
544
  *
400
- * `at` stays time-anchored rather than scene-anchored on purpose: a re-plan
401
- * can rename or move scenes, and WHERE to cut is a decision about a MOMENT
402
- * of the output. Applied by `splitCues` after the plain fill, so a split
403
- * 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.
404
609
  */
405
610
  splits: z.array(SplitSchema).default([]),
406
611
  /**
@@ -427,15 +632,38 @@ export const OverrideDocSchema = z.object({
427
632
  * a historical record of what render-props they were looking at, never
428
633
  * authoritative again once `src` is present.
429
634
  *
430
- * The editor (PLAN 2026-08-04 Task 4c) MUST NEVER WRITE OR
431
- * PRESERVE-AND-MODIFY `src` ITSELF — resolving it is produce's job alone.
432
- * Creating a cut writes ONLY `{startSec, endSec}`; if a cut's range is
433
- * ever edited/moved (not currently exposed, but the rule holds for any
434
- * future gesture that would), its `src` is DELETED rather than carried
435
- * forward, so the next produce re-resolves it against the render-props
436
- * current at that point rather than an anchor drawn for a range that no
437
- * longer means the same thing; Restore removes the WHOLE entry, `src`
438
- * 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.
439
667
  */
440
668
  cuts: z
441
669
  .array(
@@ -482,8 +710,25 @@ export const OverrideDocSchema = z.object({
482
710
  kept: z
483
711
  .array(z.object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() }))
484
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([]),
485
730
  })
486
- .default({ reasons: {}, kept: [] }),
731
+ .default({ reasons: {}, kept: [], dismissed: [] }),
487
732
  });
488
733
  export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
489
734
 
@@ -566,6 +811,77 @@ function effectiveOverride(
566
811
  };
567
812
  }
568
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
+
569
885
  export function applyOverrides(cues: readonly SceneCue[], doc: OverrideDoc): AppliedOverrides {
570
886
  const ids = new Set(cues.map((c) => c.id));
571
887
  const orphans = Object.keys(doc.scenes).filter((id) => !ids.has(id));
@@ -604,7 +920,13 @@ export function applyOverrides(cues: readonly SceneCue[], doc: OverrideDoc): App
604
920
  // window to its first half — which kept the scene's id — would undo
605
921
  // the cut and overlap the second half. An unsplit pinned cue skips a
606
922
  // byte-identical re-application; a not-yet-pinned cue pins as before.
607
- ...(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
608
930
  ? { startSec: o.timing.startSec, endSec: o.timing.endSec, pinned: true }
609
931
  : {}),
610
932
  };
@@ -638,7 +960,10 @@ export const SPLIT_MIN_PIECE_SEC = 0.3;
638
960
  * intro animation (a Sequence restarts at its own frame 0) — acceptable for
639
961
  * the feature's real use, cutting takes and re-timing halves.
640
962
  */
641
- export function splitCues(cues: readonly SceneCue[], splits: readonly Split[]): SceneCue[] {
963
+ export function splitCues(
964
+ cues: readonly SceneCue[],
965
+ splits: readonly ResolvedSplitPoint[],
966
+ ): SceneCue[] {
642
967
  const out = [...cues];
643
968
  for (const s of [...splits].sort((a, b) => a.at - b.at)) {
644
969
  const i = out.findIndex(
@@ -709,8 +1034,12 @@ export function dropHiddenCues(cues: readonly SceneCue[], doc: OverrideDoc): Dro
709
1034
  export function splitThenDropHidden(
710
1035
  cues: readonly SceneCue[],
711
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),
712
1041
  ): DropHiddenResult {
713
- return dropHiddenCues(splitCues(cues, doc.splits), doc);
1042
+ return dropHiddenCues(splitCues(cues, points), doc);
714
1043
  }
715
1044
 
716
1045
  /**
@@ -1637,8 +1966,9 @@ export function applyCaptionRangeEdits(
1637
1966
  * only the rendered caption stream loses the word.
1638
1967
  *
1639
1968
  * Line WINDOWS are recomputed here, deliberately: `buildCaptionLines` derives
1640
- * `start` from the first word and `end` from the last word plus a hold
1641
- * (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
1642
1972
  * line lingering on screen over silence — up for the hidden first word's
1643
1973
  * duration, or held past the hidden last word's end. A hidden FIRST word moves
1644
1974
  * `start` to the first survivor; a hidden LAST word re-bases the packer's hold
@@ -1701,7 +2031,7 @@ export function applyCaptionWordHides(
1701
2031
  const firstKept = kept[0]!;
1702
2032
  const lastKept = kept[kept.length - 1]!;
1703
2033
  const start = firstKept === line.words[0] ? line.start : firstKept.start;
1704
- // The packer's hold delta (captions.ts:203-213) rides on whichever word
2034
+ // The packer's hold delta (`buildCaptionLines`) rides on whichever word
1705
2035
  // is now last; clamped so the line never ends before its own last word
1706
2036
  // (the delta can be negative when the hold was clamped to outputDuration).
1707
2037
  const end =
@@ -1784,7 +2114,7 @@ export function scaleWordsIntoWindow(
1784
2114
  * line's END and the next line's START are two separate numbers here — even
1785
2115
  * though on a real transcript they are always equal, because the packer chains
1786
2116
  * words (`transcribe.ts`: `next.start = w.end`) and clamps each line's end to
1787
- * 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
1788
2118
  * exactly zero (measured 116/116, see `captionLineTiming`). A nudge CLOSES the
1789
2119
  * two onto one value only when they were already COINCIDENT: that is what
1790
2120
  * makes a lead on the packed stream move both sides of the boundary, one edit
@@ -1792,8 +2122,10 @@ export function scaleWordsIntoWindow(
1792
2122
  *
1793
2123
  * They are two numbers because GAPS ARE REAL: `applyCaptionWordHides` re-bases
1794
2124
  * a line's window onto its surviving words, `MAX_CAPTION_WORD_LEAD_SEC`
1795
- * (captions.ts:147, 169) clamps a word's display start, and an overrides.json
1796
- * 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
1797
2129
  * used to hold ONE `seams` array whose interior entry was read off the later
1798
2130
  * line's start, conflating the two: with lines `[0,2] [2,4] [5,6]`, a
1799
2131
  * lead-only drag of the middle line (`{lead: -0.05, tail: 0}`, exactly what
@@ -1940,11 +2272,100 @@ export function applyCaptionLineTiming(
1940
2272
  return { lines: out, dropped };
1941
2273
  }
1942
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
+
1943
2362
  export interface AppliedCaptionLayers {
1944
2363
  lines: CaptionLine[];
1945
2364
  /** Every layer's drop reports, tagged with which layer refused them. */
1946
2365
  dropped: Array<
1947
- AppliedCaptionEdits["dropped"][number] & { layer: "edit" | "range" | "hide" | "timing" }
2366
+ AppliedCaptionEdits["dropped"][number] & {
2367
+ layer: "edit" | "range" | "hide" | "timing" | "window";
2368
+ }
1948
2369
  >;
1949
2370
  }
1950
2371
 
@@ -1968,22 +2389,34 @@ export interface AppliedCaptionLayers {
1968
2389
  * window to move (`applyCaptionLineTiming`). Drop reports carry which layer
1969
2390
  * refused them, since "the retype missed", "the rewrite missed" and "the
1970
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).
1971
2401
  */
1972
2402
  export function applyCaptionLayers(
1973
2403
  lines: readonly CaptionLine[],
1974
2404
  doc: OverrideDoc,
2405
+ map: TimeMap,
1975
2406
  ): AppliedCaptionLayers {
1976
2407
  const edited = applyCaptionEdits(lines, doc.captions);
1977
2408
  const ranged = applyCaptionRangeEdits(edited.lines, doc.captionRangeEdits);
1978
2409
  const hidden = applyCaptionWordHides(ranged.lines, doc.captionWordsHidden);
1979
2410
  const timed = applyCaptionLineTiming(hidden.lines, doc.captionLineTiming);
2411
+ const windowed = applyCaptionLineWindows(timed.lines, doc.captionLineWindows, map);
1980
2412
  return {
1981
- lines: timed.lines,
2413
+ lines: windowed.lines,
1982
2414
  dropped: [
1983
2415
  ...edited.dropped.map((d) => ({ ...d, layer: "edit" as const })),
1984
2416
  ...ranged.dropped.map((d) => ({ ...d, layer: "range" as const })),
1985
2417
  ...hidden.dropped.map((d) => ({ ...d, layer: "hide" as const })),
1986
2418
  ...timed.dropped.map((d) => ({ ...d, layer: "timing" as const })),
2419
+ ...windowed.dropped.map((d) => ({ ...d, layer: "window" as const })),
1987
2420
  ],
1988
2421
  };
1989
2422
  }