@ossclip/core 0.1.7 → 0.1.10

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/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
+ }