@ossclip/core 0.1.7 → 0.1.9
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 +1 -1
- package/src/blooper.ts +82 -6
- package/src/concat.ts +333 -0
- package/src/cutlist.ts +77 -7
- package/src/index.ts +3 -0
- package/src/overrides.ts +157 -2
- package/src/phonetics.ts +6 -1
- package/src/recut.ts +335 -0
- package/src/retake.ts +607 -0
- package/src/scene-schema.ts +9 -1
- package/src/timemap.ts +37 -0
package/src/overrides.ts
CHANGED
|
@@ -23,6 +23,19 @@ export const ElementTransformSchema = z.object({
|
|
|
23
23
|
dx: z.number().optional(),
|
|
24
24
|
dy: z.number().optional(),
|
|
25
25
|
scale: z.number().positive().optional(),
|
|
26
|
+
/**
|
|
27
|
+
* The element is deleted — SOFTLY (PLAN Task 2, one level down from
|
|
28
|
+
* `SceneOverrideSchema.hidden` below): `editStyle`
|
|
29
|
+
* (packages/scenes/src/editable.ts) suppresses it at the one chokepoint
|
|
30
|
+
* every component's leaf renders its edit style through, so no
|
|
31
|
+
* per-component change is needed and the remaining siblings close the
|
|
32
|
+
* gap on their own — the same delete semantics a whole SCENE gets,
|
|
33
|
+
* scoped to one of its elements. The backing array
|
|
34
|
+
* (`props.messages`/`lines`/`nodes`/`items`) is never touched — ids are
|
|
35
|
+
* positional, so hiding `message-1` can't renumber `message-2` out from
|
|
36
|
+
* under its own edits.
|
|
37
|
+
*/
|
|
38
|
+
hidden: z.boolean().optional(),
|
|
26
39
|
});
|
|
27
40
|
export type ElementTransform = z.infer<typeof ElementTransformSchema>;
|
|
28
41
|
|
|
@@ -169,6 +182,51 @@ export const OverrideDocSchema = z.object({
|
|
|
169
182
|
* split lands on graphic cues and takes alike.
|
|
170
183
|
*/
|
|
171
184
|
splits: z.array(z.number().nonnegative()).default([]),
|
|
185
|
+
/**
|
|
186
|
+
* User cuts — ranges of the OUTPUT to remove, in the output seconds of the
|
|
187
|
+
* CURRENT render-props (what the user saw when they cut) (PLAN 2026-08-04
|
|
188
|
+
* Task 4). Optional-with-default like `splits` above, so an `overrides.json`
|
|
189
|
+
* written before this field existed still parses unchanged. Consumed by
|
|
190
|
+
* `produce.ts` (subtracted from the automatic cutlist's keep-spans) and by
|
|
191
|
+
* `recut.ts`'s `remapOverridesThroughRecut`, which re-anchors every OTHER
|
|
192
|
+
* absolute-output-seconds value in this doc through the resulting re-cut —
|
|
193
|
+
* see that module's docstring for why a bare "shift everything after the
|
|
194
|
+
* cut point" is not the actual rule.
|
|
195
|
+
*
|
|
196
|
+
* `src` (review fix wave, PLAN 2026-08-04 Task 4): a cut's `startSec`/
|
|
197
|
+
* `endSec` alone are meaningless without knowing WHICH render-props they
|
|
198
|
+
* were drawn against — a bare output-seconds pair has no faithful
|
|
199
|
+
* representation once its own cut has happened (Task 4b's Bug A: remapping
|
|
200
|
+
* it through the very recut it caused collapses it to a zero-width point).
|
|
201
|
+
* `src` is the cut's resolved SOURCE-time range, computed once — the first
|
|
202
|
+
* produce run that sees a `src`-less cut resolves it against the
|
|
203
|
+
* render-props the user was looking at, and the write-back records it here
|
|
204
|
+
* — and used directly, unconverted, on every run after that. `startSec`/
|
|
205
|
+
* `endSec` are left exactly as the user drew them even once `src` exists:
|
|
206
|
+
* a historical record of what render-props they were looking at, never
|
|
207
|
+
* authoritative again once `src` is present.
|
|
208
|
+
*
|
|
209
|
+
* The editor (PLAN 2026-08-04 Task 4c) MUST NEVER WRITE OR
|
|
210
|
+
* PRESERVE-AND-MODIFY `src` ITSELF — resolving it is produce's job alone.
|
|
211
|
+
* Creating a cut writes ONLY `{startSec, endSec}`; if a cut's range is
|
|
212
|
+
* ever edited/moved (not currently exposed, but the rule holds for any
|
|
213
|
+
* future gesture that would), its `src` is DELETED rather than carried
|
|
214
|
+
* forward, so the next produce re-resolves it against the render-props
|
|
215
|
+
* current at that point rather than an anchor drawn for a range that no
|
|
216
|
+
* longer means the same thing; Restore removes the WHOLE entry, `src`
|
|
217
|
+
* included — there is no "not cut" state for one array entry to hold.
|
|
218
|
+
*/
|
|
219
|
+
cuts: z
|
|
220
|
+
.array(
|
|
221
|
+
z.object({
|
|
222
|
+
startSec: z.number().nonnegative(),
|
|
223
|
+
endSec: z.number().nonnegative(),
|
|
224
|
+
src: z
|
|
225
|
+
.object({ startSec: z.number().nonnegative(), endSec: z.number().nonnegative() })
|
|
226
|
+
.optional(),
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
.default([]),
|
|
172
230
|
});
|
|
173
231
|
export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
|
|
174
232
|
|
|
@@ -364,6 +422,33 @@ export function dropHiddenCues(cues: readonly SceneCue[], doc: OverrideDoc): Dro
|
|
|
364
422
|
return { cues: out, hidden };
|
|
365
423
|
}
|
|
366
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Split at the stored split points BEFORE dropping hidden cues (PLAN
|
|
427
|
+
* 2026-08-04 Task 1, bug 3).
|
|
428
|
+
*
|
|
429
|
+
* `dropHiddenCues` matches by the EXACT id the user deleted, and a split
|
|
430
|
+
* root's `hidden` is meant to apply only to "the root's own post-split
|
|
431
|
+
* segment (the half that still carries the bare id)" per `effectiveOverride`
|
|
432
|
+
* above — but `produce.ts` and the editor's live memo both called
|
|
433
|
+
* `dropHiddenCues` on the ROOT cues, before `splitCues` had run. That erased
|
|
434
|
+
* the ENTIRE pre-split window (both halves at once); the plain fill then
|
|
435
|
+
* covered it with one take, and the later `splitCues` call cut that PLAIN
|
|
436
|
+
* take into two plain pieces — killing the graphic on the half the user
|
|
437
|
+
* never asked to delete (field case: `scene-6` deleted, `scene-6@36400` died
|
|
438
|
+
* with it). Splitting first means a hidden root id only ever matches the
|
|
439
|
+
* post-split cue that kept the bare id, exactly the exception
|
|
440
|
+
* `effectiveOverride` already promises. Safe to call again after the fill's
|
|
441
|
+
* own `splitCues` pass — re-splitting at a boundary that already exists is a
|
|
442
|
+
* no-op (the split point sits exactly on the joint, so neither piece's
|
|
443
|
+
* window contains it).
|
|
444
|
+
*/
|
|
445
|
+
export function splitThenDropHidden(
|
|
446
|
+
cues: readonly SceneCue[],
|
|
447
|
+
doc: OverrideDoc,
|
|
448
|
+
): DropHiddenResult {
|
|
449
|
+
return dropHiddenCues(splitCues(cues, doc.splits), doc);
|
|
450
|
+
}
|
|
451
|
+
|
|
367
452
|
export interface AppliedCaptionEdits {
|
|
368
453
|
lines: CaptionLine[];
|
|
369
454
|
/** Edits whose guard failed — the word at that index is not what they knew. */
|
|
@@ -434,6 +519,43 @@ export function clearElementTransform(
|
|
|
434
519
|
return { ...doc, scenes: { ...doc.scenes, [sceneId]: { ...scene, elements: rest } } };
|
|
435
520
|
}
|
|
436
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Un-hide ONE element: DELETE only the `hidden` key (PLAN Task 2), so a
|
|
524
|
+
* nudge/scale made before the delete survives the restore — the
|
|
525
|
+
* element-level mirror of `restoreScene`'s "delete the key, don't write a
|
|
526
|
+
* false-ish value" rule below. `clearElementTransform` above stays the FULL
|
|
527
|
+
* reset (nudges included); this is the narrower "bring it back as it was"
|
|
528
|
+
* gesture the element panel's Restore button offers.
|
|
529
|
+
*/
|
|
530
|
+
export function restoreElement(
|
|
531
|
+
doc: OverrideDoc,
|
|
532
|
+
sceneId: string,
|
|
533
|
+
elementId: string,
|
|
534
|
+
): OverrideDoc {
|
|
535
|
+
const scene = doc.scenes[sceneId];
|
|
536
|
+
const entry = scene?.elements[elementId];
|
|
537
|
+
if (!scene || !entry?.hidden) return doc;
|
|
538
|
+
const { hidden: _dropped, ...rest } = entry;
|
|
539
|
+
// `elements` merges per ID, not per FIELD (`effectiveOverride` above:
|
|
540
|
+
// `elements: { ...base.elements, ...own.elements }` replaces a shared id
|
|
541
|
+
// WHOLESALE, unlike `video`/`pip`, which merge field by field). Review
|
|
542
|
+
// fix wave, PLAN Task 2: a half whose own entry was ONLY `{hidden:true}`
|
|
543
|
+
// would otherwise be left with the empty leftover `{}` once `hidden` is
|
|
544
|
+
// stripped — and that empty object still wins the wholesale merge,
|
|
545
|
+
// permanently shadowing whatever nudge the split ROOT had for this id,
|
|
546
|
+
// even though "restore keeps nudges" is exactly this function's promise.
|
|
547
|
+
// Dropping the key entirely once nothing but `hidden` was ever on it lets
|
|
548
|
+
// the root's own entry (if any) show through again — same "delete rather
|
|
549
|
+
// than leave an inert key" instinct as clearVideo/clearTiming.
|
|
550
|
+
const { [elementId]: _own, ...withoutEntry } = scene.elements;
|
|
551
|
+
const elements =
|
|
552
|
+
Object.keys(rest).length > 0 ? { ...scene.elements, [elementId]: rest } : withoutEntry;
|
|
553
|
+
return {
|
|
554
|
+
...doc,
|
|
555
|
+
scenes: { ...doc.scenes, [sceneId]: { ...scene, elements } },
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
437
559
|
/**
|
|
438
560
|
* Un-pin: DELETE the `timing` override so the scene goes back to tracking its
|
|
439
561
|
* word anchors. Distinct from setting a timing that happens to match the
|
|
@@ -469,6 +591,25 @@ export interface ReclampResult {
|
|
|
469
591
|
adjusted: string[];
|
|
470
592
|
}
|
|
471
593
|
|
|
594
|
+
/**
|
|
595
|
+
* The scene a cue id belongs to, stripping a split half's `@ms` suffix —
|
|
596
|
+
* same idiom `splitCues` itself uses to derive a later half's id from its
|
|
597
|
+
* root, and the same one `effectiveOverride` above inlines to find a half's
|
|
598
|
+
* root entry. Two cues sharing a root are the SAME scene, cut in two.
|
|
599
|
+
*
|
|
600
|
+
* Exported (PLAN Task 2 review fix) for the editor's own use: a hidden
|
|
601
|
+
* ELEMENT on a split half can be inherited from the root (`elements`
|
|
602
|
+
* merges per id in `effectiveOverride`, and `elements` is NOT in that
|
|
603
|
+
* function's inheritance-exclusion list the way `timing`/`hidden` are) —
|
|
604
|
+
* Inspector.tsx's per-row Restore has to know whether the `hidden` it's
|
|
605
|
+
* offering to undo lives on the half's own doc entry or the root's, and
|
|
606
|
+
* this is the same root-id derivation either side of that decision needs.
|
|
607
|
+
*/
|
|
608
|
+
export function splitRootId(id: string): string {
|
|
609
|
+
const at = id.indexOf("@");
|
|
610
|
+
return at === -1 ? id : id.slice(0, at);
|
|
611
|
+
}
|
|
612
|
+
|
|
472
613
|
/**
|
|
473
614
|
* Re-clamp every PINNED cue's absolute timing against its current neighbours
|
|
474
615
|
* in the array, in order.
|
|
@@ -492,8 +633,22 @@ export function reclampPinnedTiming(cues: readonly SceneCue[]): ReclampResult {
|
|
|
492
633
|
if (!cue.pinned) continue;
|
|
493
634
|
const prev = out[i - 1];
|
|
494
635
|
const next = out[i + 1];
|
|
495
|
-
|
|
496
|
-
|
|
636
|
+
// Two adjacent pinned cues that are split halves of the SAME scene are
|
|
637
|
+
// not independent neighbours (PLAN 2026-08-04 Task 1 follow-up, found in
|
|
638
|
+
// review): `splitThenDropHidden` now runs `splitCues` before this
|
|
639
|
+
// function, so a pinned+split scene reaches here as two entries, both
|
|
640
|
+
// still `pinned: true`, with an EXACT boundary (`left.endSec ===
|
|
641
|
+
// right.startSec`) that `splitCues` already cut. Applying the usual
|
|
642
|
+
// `PINNED_GAP_SEC` buffer between them would carve a sliver out of the
|
|
643
|
+
// seam that `fillPlainCues` then fills with a spurious plain take
|
|
644
|
+
// spliced between the two halves — bug 3 again, one layer up, even
|
|
645
|
+
// though the cue array itself looks correctly split.
|
|
646
|
+
const prevIsSibling = prev?.pinned === true && splitRootId(prev.id) === splitRootId(cue.id);
|
|
647
|
+
const nextIsSibling = next?.pinned === true && splitRootId(next.id) === splitRootId(cue.id);
|
|
648
|
+
const lo = prev ? prev.endSec + (prevIsSibling ? 0 : PINNED_GAP_SEC) : 0;
|
|
649
|
+
const hi = next
|
|
650
|
+
? next.startSec - (nextIsSibling ? 0 : PINNED_GAP_SEC)
|
|
651
|
+
: Number.POSITIVE_INFINITY;
|
|
497
652
|
let s = Math.min(Math.max(cue.startSec, lo), Math.max(lo, hi - MIN_PINNED_SCENE_SEC));
|
|
498
653
|
let e = Math.max(Math.min(cue.endSec, hi), s + MIN_PINNED_SCENE_SEC);
|
|
499
654
|
if (e > hi) {
|
package/src/phonetics.ts
CHANGED
|
@@ -69,7 +69,12 @@ function phraseKey(text: string): string {
|
|
|
69
69
|
.join("");
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
// Exported: blooper.ts reuses this directly for the marker fuzzy-match arm
|
|
73
|
+
// (Task 3, editor-dogfood-fixes plan) instead of a second copy — plain edit
|
|
74
|
+
// distance on the raw normalized word, not the phonetic key `soundsLike`
|
|
75
|
+
// compares, because "blooper"/"looker" fails the onset test below yet is
|
|
76
|
+
// exactly the mishearing the marker match needs to catch.
|
|
77
|
+
export function levenshtein(a: string, b: string): number {
|
|
73
78
|
if (a === b) return 0;
|
|
74
79
|
if (a.length === 0) return b.length;
|
|
75
80
|
if (b.length === 0) return a.length;
|
package/src/recut.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import type { OverrideDoc } from "./overrides";
|
|
2
|
+
import type { Segment } from "./schema";
|
|
3
|
+
import { mapsClose, TimeMap } from "./timemap";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What `remapOverridesThroughRecut` hands back alongside the re-anchored doc.
|
|
7
|
+
* `reports` is never used to gate anything — it exists purely so a value that
|
|
8
|
+
* got pushed onto a cut edge is SAID out loud (PLAN 2026-08-04 Task 4a),
|
|
9
|
+
* rather than the user discovering a pin silently moved next time they open
|
|
10
|
+
* the editor.
|
|
11
|
+
*/
|
|
12
|
+
export interface RecutRemap {
|
|
13
|
+
doc: OverrideDoc;
|
|
14
|
+
reports: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Remap ONE absolute-output-seconds value through a re-cut, via source time.
|
|
19
|
+
*
|
|
20
|
+
* `oldMap.toSource` is total — output time is always contiguous (TimeMap's
|
|
21
|
+
* own doc comment) — so every stored value has a source instant under the
|
|
22
|
+
* map it was recorded against. `newMap.toOutput` of that same source instant
|
|
23
|
+
* returns null exactly when the NEW cut removed it; `toOutputClamped` is the
|
|
24
|
+
* identical "snap to the nearest kept edge" TimeMap already uses for
|
|
25
|
+
* caption/overlay boundaries, so a value pushed onto a cut lands exactly
|
|
26
|
+
* where the timeline's own dead-region rendering shows the cut's edge.
|
|
27
|
+
* `label` is only for the report string — it carries no behavior.
|
|
28
|
+
*/
|
|
29
|
+
function remapPoint(
|
|
30
|
+
label: string,
|
|
31
|
+
t: number,
|
|
32
|
+
oldMap: TimeMap,
|
|
33
|
+
newMap: TimeMap,
|
|
34
|
+
reports: string[],
|
|
35
|
+
): number {
|
|
36
|
+
const src = oldMap.toSource(t);
|
|
37
|
+
const out = newMap.toOutput(src);
|
|
38
|
+
if (out !== null) return out;
|
|
39
|
+
const clamped = newMap.toOutputClamped(src);
|
|
40
|
+
reports.push(
|
|
41
|
+
`${label} at ${t.toFixed(3)}s fell inside the new cut — snapped to ${clamped.toFixed(3)}s`,
|
|
42
|
+
);
|
|
43
|
+
return clamped;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Re-anchor output-second decisions through source time across a re-cut.
|
|
47
|
+
* Every stored absolute-output-seconds value (splits, pinned timing, cuts
|
|
48
|
+
* recorded against an older output) maps old-output → source via the OLD
|
|
49
|
+
* TimeMap, then source → new-output via the NEW TimeMap. A value whose
|
|
50
|
+
* source moment was itself removed by the new cut maps to the cut's edge
|
|
51
|
+
* and is reported, never silently dropped. */
|
|
52
|
+
export function remapOverridesThroughRecut(
|
|
53
|
+
doc: OverrideDoc,
|
|
54
|
+
oldMap: TimeMap,
|
|
55
|
+
newMap: TimeMap,
|
|
56
|
+
): RecutRemap {
|
|
57
|
+
const reports: string[] = [];
|
|
58
|
+
|
|
59
|
+
const splits = doc.splits.map((t) => remapPoint("split", t, oldMap, newMap, reports));
|
|
60
|
+
|
|
61
|
+
// Record-shaped: rebuild key by key rather than mutate, matching every
|
|
62
|
+
// other `OverrideDoc`-shaping function in overrides.ts (e.g.
|
|
63
|
+
// `reclampPinnedTiming`) — the doc is the user's own data, never edited
|
|
64
|
+
// in place.
|
|
65
|
+
const scenes = Object.fromEntries(
|
|
66
|
+
Object.entries(doc.scenes).map(([id, scene]) => {
|
|
67
|
+
if (!scene.timing) return [id, scene];
|
|
68
|
+
const startSec = remapPoint(`"${id}" pinned start`, scene.timing.startSec, oldMap, newMap, reports);
|
|
69
|
+
const endSec = remapPoint(`"${id}" pinned end`, scene.timing.endSec, oldMap, newMap, reports);
|
|
70
|
+
return [id, { ...scene, timing: { startSec, endSec } }];
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// `doc.cuts` is deliberately NOT remapped here (PLAN 2026-08-04 Task 4c
|
|
75
|
+
// prerequisite cleanup; review fix wave finding 1 is what made the old
|
|
76
|
+
// comment here wrong). The design this function was first written against
|
|
77
|
+
// ("a cut is exactly as stale as a split or a pin, remap it the same way")
|
|
78
|
+
// changed under it: `resolveCutSourceRanges` is what interprets a cut now,
|
|
79
|
+
// through `priorMap` — a bare old→source→new point remap through the very
|
|
80
|
+
// recut a cut CAUSED collapses it to a zero-width point at its own edge
|
|
81
|
+
// (Task 4b's Bug A), and doing that here would also silently DROP any
|
|
82
|
+
// resolved `src` the caller's `doc` already carries, since this function
|
|
83
|
+
// has no way to know a cut's `src` is settled and irreplaceable (schema
|
|
84
|
+
// comment on `OverrideDocSchema.cuts`). `applyUserCuts` is the only place
|
|
85
|
+
// `cuts` gets resolved, and its one call into this function passes
|
|
86
|
+
// `cuts: []` specifically so this function is never asked to make that
|
|
87
|
+
// call — the spread below carries the caller's `cuts` through untouched,
|
|
88
|
+
// whatever they are.
|
|
89
|
+
return { doc: { ...doc, splits, scenes }, reports };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One entry of `OverrideDoc.cuts` — see the schema comment on `src`. */
|
|
93
|
+
export type UserCut = OverrideDoc["cuts"][number];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Deep-equal with float tolerance — a "did this actually change" check for
|
|
97
|
+
* values that passed through TimeMap arithmetic and a JSON round-trip, where
|
|
98
|
+
* exact equality flags noise (review fix wave, PLAN 2026-08-04 Task 4,
|
|
99
|
+
* finding "Minor"): a 1-ulp drift is not a user edit, and treating it as one
|
|
100
|
+
* rewrites `overrides.json` — a user-owned file whose timestamp and diffs
|
|
101
|
+
* matter — on every produce run for nothing.
|
|
102
|
+
*/
|
|
103
|
+
function closeEnough(a: unknown, b: unknown, eps: number): boolean {
|
|
104
|
+
if (typeof a === "number" && typeof b === "number") return Math.abs(a - b) <= eps;
|
|
105
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
106
|
+
return a.length === b.length && a.every((v, i) => closeEnough(v, b[i], eps));
|
|
107
|
+
}
|
|
108
|
+
if (a && b && typeof a === "object" && typeof b === "object") {
|
|
109
|
+
const ak = Object.keys(a).sort();
|
|
110
|
+
const bk = Object.keys(b).sort();
|
|
111
|
+
if (ak.length !== bk.length || ak.some((k, i) => k !== bk[i])) return false;
|
|
112
|
+
return ak.every((k) =>
|
|
113
|
+
closeEnough((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k], eps),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return a === b;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Tolerance for every float comparison in this module — generous enough to
|
|
120
|
+
* absorb a JSON round-trip and a chain of TimeMap arithmetic, tight enough
|
|
121
|
+
* that a genuine sub-millisecond user nudge still counts as a change. */
|
|
122
|
+
const EPS = 1e-6;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve each user cut to a SOURCE-time range to remove, and to a `src`
|
|
126
|
+
* value worth persisting.
|
|
127
|
+
*
|
|
128
|
+
* A cut WITH `src` already is stable and settled: `src` is used directly,
|
|
129
|
+
* unconverted, every run, forever — it is never re-derived from
|
|
130
|
+
* `startSec`/`endSec` again, because there is no map left that could
|
|
131
|
+
* re-derive it correctly (review fix wave finding 1's Bug A: a cut's own
|
|
132
|
+
* source range has no faithful representation in any output frame taken
|
|
133
|
+
* AFTER that cut's own removal).
|
|
134
|
+
*
|
|
135
|
+
* A cut WITHOUT `src` is fresh — drawn by the user against `priorMap`, the
|
|
136
|
+
* render-props they were looking at (the schema comment's "output seconds of
|
|
137
|
+
* the CURRENT render-props") — so it converts through `priorMap.toSource`,
|
|
138
|
+
* NOT `map` (this run's freshly-rebuilt automatic cutlist, which has no
|
|
139
|
+
* relationship to what the user was looking at once anything has drifted:
|
|
140
|
+
* confirmed on the dogfood workdir, where an unrelated automatic-cutlist
|
|
141
|
+
* change put "output 31s in `map`" 5.8s away from "output 31s in
|
|
142
|
+
* `priorMap`"). `priorMap` missing entirely (no readable render-props.json —
|
|
143
|
+
* a first-ever produce, or a corrupt workdir) falls back to `map`, WITH a
|
|
144
|
+
* report — never silently, per the same "nothing moves without saying so"
|
|
145
|
+
* rule as `remapPoint`.
|
|
146
|
+
*/
|
|
147
|
+
export interface ResolvedCuts {
|
|
148
|
+
/** Source-time ranges to remove, one per cut with a non-degenerate result. */
|
|
149
|
+
ranges: { start: number; end: number }[];
|
|
150
|
+
/** `cuts`, each carrying a resolved `src` — the only thing about a cut
|
|
151
|
+
* that ever changes on write-back; `startSec`/`endSec` are untouched. */
|
|
152
|
+
cuts: UserCut[];
|
|
153
|
+
reports: string[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function resolveCutSourceRanges(
|
|
157
|
+
cuts: readonly UserCut[],
|
|
158
|
+
priorMap: TimeMap | null,
|
|
159
|
+
map: TimeMap,
|
|
160
|
+
): ResolvedCuts {
|
|
161
|
+
const reports: string[] = [];
|
|
162
|
+
const resolved: UserCut[] = [];
|
|
163
|
+
const ranges: { start: number; end: number }[] = [];
|
|
164
|
+
for (const cut of cuts) {
|
|
165
|
+
let src = cut.src;
|
|
166
|
+
if (!src) {
|
|
167
|
+
let anchor = priorMap;
|
|
168
|
+
if (!anchor) {
|
|
169
|
+
reports.push(
|
|
170
|
+
`cut ${cut.startSec.toFixed(3)}–${cut.endSec.toFixed(3)}s has no render-props to ` +
|
|
171
|
+
"anchor to — used this run's automatic cutlist instead; verify placement",
|
|
172
|
+
);
|
|
173
|
+
anchor = map;
|
|
174
|
+
}
|
|
175
|
+
src = { startSec: anchor.toSource(cut.startSec), endSec: anchor.toSource(cut.endSec) };
|
|
176
|
+
}
|
|
177
|
+
resolved.push(src === cut.src ? cut : { ...cut, src });
|
|
178
|
+
if (src.endSec > src.startSec) ranges.push({ start: src.startSec, end: src.endSec });
|
|
179
|
+
}
|
|
180
|
+
return { ranges, cuts: resolved, reports };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Subtract source-time `ranges` from a cutlist's `keep` segments.
|
|
185
|
+
*
|
|
186
|
+
* `remove` segments already in the cutlist pass through untouched; a `keep`
|
|
187
|
+
* segment is split around every range that overlaps it. New `remove`
|
|
188
|
+
* segments carry `reason: "user"` (`RemovalReasonSchema` already has this
|
|
189
|
+
* value) so `formatCutReport` — which walks `production.cutlist` — lists a
|
|
190
|
+
* user cut exactly like an automatic one, with no separate report path
|
|
191
|
+
* needed for "what got removed."
|
|
192
|
+
*/
|
|
193
|
+
export function subtractRangesFromCutlist(
|
|
194
|
+
cutlist: readonly Segment[],
|
|
195
|
+
ranges: readonly { start: number; end: number }[],
|
|
196
|
+
): Segment[] {
|
|
197
|
+
const sorted = [...ranges].sort((a, b) => a.start - b.start);
|
|
198
|
+
if (sorted.length === 0) return [...cutlist];
|
|
199
|
+
|
|
200
|
+
const out: Segment[] = [];
|
|
201
|
+
for (const seg of cutlist) {
|
|
202
|
+
if (seg.kind !== "keep") {
|
|
203
|
+
out.push(seg);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
// Carve every overlapping range out of this ONE keep segment, left to
|
|
207
|
+
// right, so the result stays sorted and non-overlapping the way
|
|
208
|
+
// `TimeMap`'s constructor requires — a range that also touches a
|
|
209
|
+
// NEIGHBOURING keep segment (separated by an existing automatic cut) is
|
|
210
|
+
// simply considered again there, on its own bounds.
|
|
211
|
+
let cursor = seg.srcIn;
|
|
212
|
+
for (const r of sorted) {
|
|
213
|
+
const overlapStart = Math.max(cursor, r.start);
|
|
214
|
+
const overlapEnd = Math.min(seg.srcOut, r.end);
|
|
215
|
+
if (overlapStart >= overlapEnd) continue;
|
|
216
|
+
if (overlapStart > cursor) out.push({ srcIn: cursor, srcOut: overlapStart, kind: "keep" });
|
|
217
|
+
out.push({ srcIn: overlapStart, srcOut: overlapEnd, kind: "remove", reason: "user", confidence: 1 });
|
|
218
|
+
cursor = overlapEnd;
|
|
219
|
+
}
|
|
220
|
+
if (cursor < seg.srcOut) out.push({ srcIn: cursor, srcOut: seg.srcOut, kind: "keep" });
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** What applying the user's `cuts` to a cutlist hands back to `produce.ts`. */
|
|
226
|
+
export interface ApplyUserCutsResult {
|
|
227
|
+
/** `subtractRangesFromCutlist`'s result — `[...cutlist]` unchanged when
|
|
228
|
+
* `doc.cuts` is empty. */
|
|
229
|
+
cutlist: Segment[];
|
|
230
|
+
/** `new TimeMap(cutlist)`, handed back so the caller doesn't rebuild it. */
|
|
231
|
+
map: TimeMap;
|
|
232
|
+
/**
|
|
233
|
+
* The doc with `cuts[*].src` resolved and `splits`/pinned
|
|
234
|
+
* `scenes[id].timing` re-anchored where drift was found. `cuts[*].startSec`/
|
|
235
|
+
* `endSec` are always the user's original values — see `resolveCutSourceRanges`.
|
|
236
|
+
*/
|
|
237
|
+
doc: OverrideDoc;
|
|
238
|
+
/** Every report worth surfacing — missing-anchor fallbacks (always) plus
|
|
239
|
+
* remap reports (only when a re-anchor actually happened). Shown
|
|
240
|
+
* regardless of `changed`: these are about DECISIONS, not about whether a
|
|
241
|
+
* file got written. */
|
|
242
|
+
reports: string[];
|
|
243
|
+
/**
|
|
244
|
+
* Whether `doc` actually differs from what was read (a cut resolved its
|
|
245
|
+
* `src` for the first time, and/or splits/pins moved). The write-back
|
|
246
|
+
* guard (PLAN 2026-08-04 Task 4 + review fix wave finding 3): an untouched
|
|
247
|
+
* `overrides.json` must not be rewritten on every produce run.
|
|
248
|
+
*/
|
|
249
|
+
changed: boolean;
|
|
250
|
+
/** Total duration this run's cuts removed, for the produce report's headline. */
|
|
251
|
+
removedSec: number;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Subtract the user's `cuts` from `cutlist`, then re-anchor `splits`/pinned
|
|
256
|
+
* timing through whatever drift is found between `priorMap` (the frame the
|
|
257
|
+
* doc's stored values are CURRENTLY anchored to — `produce.ts` reconstructs
|
|
258
|
+
* this from the last-written `render-props.json`) and this run's final map
|
|
259
|
+
* (PLAN 2026-08-04 Task 4). This is the ONE sanctioned write to
|
|
260
|
+
* `overrides.json` — every other file `produce.ts` writes is derived and
|
|
261
|
+
* safe to overwrite every run, but this rewrites the user's OWN decisions,
|
|
262
|
+
* and does so because the timeline those decisions are anchored to keeps
|
|
263
|
+
* moving out from under them. Rewriting them here is what keeps them meaning
|
|
264
|
+
* the same thing, not silently landing somewhere else the next time the
|
|
265
|
+
* editor opens.
|
|
266
|
+
*
|
|
267
|
+
* Two gates, deliberately independent (review fix wave finding 3):
|
|
268
|
+
* - Subtracting cuts from the cutlist only happens when `doc.cuts` is
|
|
269
|
+
* non-empty — nothing to subtract otherwise.
|
|
270
|
+
* - Re-anchoring `splits`/`scenes[*].timing` happens whenever `priorMap` is
|
|
271
|
+
* available AND differs (span-for-span, float-tolerant) from this run's
|
|
272
|
+
* final map — REGARDLESS of whether `cuts` is empty. A doc with cuts
|
|
273
|
+
* already applied, then emptied again (the editor's Restore gesture), has
|
|
274
|
+
* splits/pins still sitting in last run's POST-cut frame with nothing in
|
|
275
|
+
* `cuts` left to drive a re-anchor off of; gating on `cuts.length` alone
|
|
276
|
+
* stranded them. The same gate also catches the automatic cutlist itself
|
|
277
|
+
* drifting for a reason that has nothing to do with the user's cuts at
|
|
278
|
+
* all (a `--cleanup` change, a repair-pass improvement) — confirmed for
|
|
279
|
+
* real on the dogfood workdir during verification.
|
|
280
|
+
*
|
|
281
|
+
* `priorMap === null` (no readable render-props.json: first-ever produce, or
|
|
282
|
+
* a corrupt workdir) skips re-anchoring entirely — there is nothing to
|
|
283
|
+
* compare against — but a `src`-less cut still resolves, falling back to
|
|
284
|
+
* `map` with a report (see `resolveCutSourceRanges`).
|
|
285
|
+
*/
|
|
286
|
+
export function applyUserCuts(
|
|
287
|
+
doc: OverrideDoc,
|
|
288
|
+
cutlist: readonly Segment[],
|
|
289
|
+
map: TimeMap,
|
|
290
|
+
priorMap: TimeMap | null,
|
|
291
|
+
): ApplyUserCutsResult {
|
|
292
|
+
let newCutlist: Segment[] = [...cutlist];
|
|
293
|
+
let cuts = doc.cuts;
|
|
294
|
+
let reports: string[] = [];
|
|
295
|
+
if (doc.cuts.length > 0) {
|
|
296
|
+
const resolved = resolveCutSourceRanges(doc.cuts, priorMap, map);
|
|
297
|
+
newCutlist = subtractRangesFromCutlist(cutlist, resolved.ranges);
|
|
298
|
+
cuts = resolved.cuts;
|
|
299
|
+
reports = resolved.reports;
|
|
300
|
+
}
|
|
301
|
+
const newMap = new TimeMap(newCutlist);
|
|
302
|
+
const removedSec = map.outputDuration - newMap.outputDuration;
|
|
303
|
+
const cutsChanged = !closeEnough(cuts, doc.cuts, EPS);
|
|
304
|
+
|
|
305
|
+
let finalDoc: OverrideDoc = { ...doc, cuts };
|
|
306
|
+
let reanchored = false;
|
|
307
|
+
if (priorMap !== null && !mapsClose(priorMap, newMap, EPS)) {
|
|
308
|
+
// `cuts: []` going IN: this function re-anchors `splits`/pinned timing
|
|
309
|
+
// only — see `resolveCutSourceRanges` above for why `cuts` itself is
|
|
310
|
+
// handled separately and never round-tripped through `remapPoint`
|
|
311
|
+
// (remapping a cut through the very recut it caused collapses it to a
|
|
312
|
+
// zero-width point at its own edge, the identical "landed on a cut
|
|
313
|
+
// edge" case reported for splits/pins — and reporting THAT would tell
|
|
314
|
+
// the user their cut moved when it didn't).
|
|
315
|
+
const { doc: remapped, reports: remapReports } = remapOverridesThroughRecut(
|
|
316
|
+
{ ...doc, cuts: [] },
|
|
317
|
+
priorMap,
|
|
318
|
+
newMap,
|
|
319
|
+
);
|
|
320
|
+
if (!closeEnough(remapped.splits, doc.splits, EPS) || !closeEnough(remapped.scenes, doc.scenes, EPS)) {
|
|
321
|
+
finalDoc = { ...remapped, cuts };
|
|
322
|
+
reports = [...reports, ...remapReports];
|
|
323
|
+
reanchored = true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
cutlist: newCutlist,
|
|
329
|
+
map: newMap,
|
|
330
|
+
doc: finalDoc,
|
|
331
|
+
reports,
|
|
332
|
+
changed: cutsChanged || reanchored,
|
|
333
|
+
removedSec,
|
|
334
|
+
};
|
|
335
|
+
}
|