@ossclip/core 0.1.31 → 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.
@@ -4,15 +4,17 @@
4
4
  * so the player actually PLAYS the revived material, immediately, instead of
5
5
  * marking a seam that only the next render honours.
6
6
  *
7
- * Cleanup vetoes ONLY. User `cuts[]` stay marked-not-applied, exactly the
8
- * step-3 posture, for two reasons that are not the retired "no client-side
9
- * TimeMap" one:
10
- * - produce alone resolves a fresh cut's `src` (the `cuts[].src` schema
11
- * contract, overrides.ts) — the editor must never apply a cut whose
12
- * source range only produce can resolve;
13
- * - a veto RESTORES content the mezzanine already has, so the editor can
14
- * honestly play it; a cut REMOVES content, and the struck band already
15
- * communicates that honestly.
7
+ * Vetoes ADD time back and live cuts REMOVE it, and since the cut-review
8
+ * rework (2026-08-26) BOTH play immediately. The old "the editor never
9
+ * applies a cut" premise is retired with the ban it rested on: a fresh cut's
10
+ * `src` is no longer produce's alone to resolve — the writer resolves it at
11
+ * the gesture, on the very clock this module hands it (`toSourceSec` /
12
+ * `oldToSourceSec`), and the schema now says the editor MAY write it
13
+ * (`OverrideDocSchema.cuts`, overrides.ts). So `src` present doubles as
14
+ * "live-applied": those ranges subtract here and the material genuinely
15
+ * stops playing. A `src`-LESS entry is the legacy marked-only shape and is
16
+ * still never applied — the struck band communicates it, byte-identically to
17
+ * before.
16
18
  *
17
19
  * Pure and browser-safe by construction (the cover-headline.ts split): this
18
20
  * module's whole import graph — cutlist, recut, timemap, and types — has
@@ -48,19 +50,29 @@ export interface LivePreviewClocks {
48
50
  * The new clock is produce's own sequence, same functions, same order:
49
51
  * `applyCleanupChoices(proposal, choices)` then user cuts subtract from the
50
52
  * result (`subtractRangesFromCutlist`), so a user cut drawn over a vetoed
51
- * pause still cuts here exactly as it does in produce. Only cuts whose `src`
52
- * is already resolved subtract — a fresh cut's source range is produce's
53
- * alone to resolve (`resolveCutSourceRanges` needs the prior render-props
54
- * frame), and that cut is still marked-not-applied on the timeline anyway.
55
- * Skipping the subtraction entirely would be worse than incomplete: every
56
- * ALREADY-APPLIED cut (src resolved by a past produce, absent from
57
- * `oldSpans`) would silently come back the moment any veto went live.
53
+ * pause still cuts here exactly as it does in produce. Only cuts carrying a
54
+ * `src` subtract, and since the cut-review rework that is the LIVE-APPLIED
55
+ * set, not just produce's own past resolutions: the editor's cut writers now
56
+ * resolve `src` at the gesture (module doc), so a fresh cut removes its
57
+ * material from the preview the moment it is made. A src-LESS entry is the
58
+ * legacy marked-only shape and never subtracts. Skipping the subtraction
59
+ * entirely would be worse than incomplete: every ALREADY-APPLIED cut (src
60
+ * resolved by a past produce, absent from `oldSpans`) would silently come
61
+ * back the moment any veto went live.
58
62
  *
59
- * `null` on any degenerate input — no proposal, no old spans, choices with
60
- * no actual veto — and on a proposal `TimeMap`'s constructor rejects (a
61
- * hand-mangled production.json): the preview degrades to step 3's honest
62
- * marks-rather-than-applies, never a crash, the same lenient posture as
63
- * GET /api/cleanup itself.
63
+ * A src cut ALONE opens the clocks (`hasLiveEdit`), which is what makes a cut
64
+ * inside revived material previewable at all. With no cleanup proposal on
65
+ * disk there is no partition to re-keep from, so the base cutlist becomes the
66
+ * LAST RENDER's own spans as keep-only segments and the cuts subtract from
67
+ * that — the honest base, and identity-safe: a cut a past produce already
68
+ * applied is absent from those spans, subtracts nothing, and `mapsClose`
69
+ * takes the null exit (`subtractRangesFromCutlist` is set-like).
70
+ *
71
+ * `null` on any degenerate input — no old spans, a veto with no proposal to
72
+ * apply it to, choices with no actual veto and no src cut — and on a proposal
73
+ * `TimeMap`'s constructor rejects (a hand-mangled production.json): the
74
+ * preview degrades to step 3's honest marks-rather-than-applies, never a
75
+ * crash, the same lenient posture as GET /api/cleanup itself.
64
76
  */
65
77
  export function livePreviewMap(
66
78
  proposal: readonly Segment[],
@@ -73,16 +85,33 @@ export function livePreviewMap(
73
85
  // the cheap exact exit, not a float comparison of two equal maps.
74
86
  const hasVeto =
75
87
  Object.values(choices?.reasons ?? {}).some((v) => v === false) ||
76
- (choices?.kept?.length ?? 0) > 0;
77
- if (!hasVeto) return null;
78
- if (proposal.length === 0 || oldSpans.length === 0) return null;
88
+ (choices?.kept?.length ?? 0) > 0 ||
89
+ // A dismissal re-keeps content exactly like a veto does — the live
90
+ // preview must play it (dismissedRemovals' doc: same render outcome,
91
+ // different display state).
92
+ (choices?.dismissed?.length ?? 0) > 0;
93
+ const ranges = cuts.flatMap((c) =>
94
+ c.src !== undefined && c.src.endSec > c.src.startSec
95
+ ? [{ start: c.src.startSec, end: c.src.endSec }]
96
+ : [],
97
+ );
98
+ // A src cut is a live edit in its own right now (the doc above) — the gate
99
+ // is no longer "is a veto live" but "is ANY of this applied live".
100
+ const hasLiveEdit = hasVeto || ranges.length > 0;
101
+ if (!hasLiveEdit) return null;
102
+ if (oldSpans.length === 0) return null;
103
+ // A veto with no proposal to apply it to is still nothing to show — the
104
+ // pre-rework early exit, kept explicit so that path stays byte-identical
105
+ // rather than relying on the `mapsClose` exit below to reach the same null.
106
+ if (proposal.length === 0 && ranges.length === 0) return null;
79
107
  try {
80
- const rekept = applyCleanupChoices(proposal, choices);
81
- const ranges = cuts.flatMap((c) =>
82
- c.src && c.src.endSec > c.src.startSec
83
- ? [{ start: c.src.startSec, end: c.src.endSec }]
84
- : [],
85
- );
108
+ // No proposal on disk → the last render's spans ARE the base partition
109
+ // (keep-only): the cuts have to subtract from something, and this is the
110
+ // one honest description of what is currently kept.
111
+ const rekept =
112
+ proposal.length > 0
113
+ ? applyCleanupChoices(proposal, choices)
114
+ : oldSpans.map((s) => ({ srcIn: s.srcIn, srcOut: s.srcOut, kind: "keep" as const }));
86
115
  const newMap = new TimeMap(subtractRangesFromCutlist(rekept, ranges));
87
116
  const oldMap = mapFromKeptSpans(oldSpans);
88
117
  // Choices that change nothing (a veto already baked into the last
@@ -106,10 +135,16 @@ export function livePreviewMap(
106
135
  export interface PreviewClockMappers {
107
136
  /** OLD-clock output seconds (the last render's own timeline — what the
108
137
  * render props, the ghost cues and the pre-retime caption lines are timed
109
- * in) → the clock the player is actually on. Exact for every live veto:
110
- * vetoes only ever ADD time back, so every old moment survives on the new
111
- * clock (`retimeForPreview`'s direction argument); the clamp behind it
112
- * only fires for the retracted-veto shape the retime already reports. */
138
+ * in) → the clock the player is actually on. Exact for every VETO: those
139
+ * only ever ADD time back, so every old moment survives on the new clock
140
+ * (`retimeForPreview`'s direction argument). The clamp behind it is real,
141
+ * not theoretical, in the two directions that REMOVE time: the
142
+ * retracted-veto shape the retime already reports, and — since the
143
+ * cut-review rework — an old instant a LIVE cut removed, which snaps to
144
+ * the nearest surviving edge. The documented consumer of that clamp is
145
+ * App.tsx's playhead-continuity effect (~:1204-1225): the playhead sitting
146
+ * inside material the user just cut has to land SOMEWHERE, and the seam is
147
+ * the closest honest answer. */
113
148
  toLive: (sec: number) => number;
114
149
  /** The reverse: the player's clock → the last render's own output seconds.
115
150
  * A live moment inside REVIVED material has no old-clock preimage at all —
@@ -133,6 +168,21 @@ export interface PreviewClockMappers {
133
168
  * (timemap.ts), so the seam moment is one the last render still contained.
134
169
  * Always true for the identity pair — no veto, nothing revived. */
135
170
  hasOldClockPreimage: (sec: number) => boolean;
171
+ /** Live-output → SOURCE seconds, or null when no conversion exists (the
172
+ * identity case with no spans-backed fallback supplied). Exact under a
173
+ * live veto (`newMap.toSource` is total on the player's own clock). The
174
+ * split writer's anchor (`splits[].src`) and, since the cut-review rework,
175
+ * the cut writers' too (`cuts[].src`, resolved at the gesture). */
176
+ toSourceSec: ((sec: number) => number) | null;
177
+ /** OLD-clock output → SOURCE seconds, or null when no conversion exists
178
+ * (the identity case with no spans-backed fallback). `toSourceSec`'s
179
+ * sibling for the surfaces whose windows are still timed against the LAST
180
+ * RENDER rather than the player — the transcript panel's word windows, and
181
+ * therefore `cutWords`' `src`. Exact under a live veto (`oldMap.toSource`
182
+ * is total on the old clock). Reaching for `toSourceSec` there would
183
+ * resolve the wrong source instant by exactly the revived seconds, which
184
+ * is the whole class of bug the cut-review audit found. */
185
+ oldToSourceSec: ((sec: number) => number) | null;
136
186
  }
137
187
 
138
188
  /**
@@ -142,10 +192,30 @@ export interface PreviewClockMappers {
142
192
  * bit-identical values to what it computed before the mapping existed — the
143
193
  * same regression anchor the `live` memo's null branch holds to.
144
194
  */
145
- export function previewClockMappers(clocks: LivePreviewClocks | null): PreviewClockMappers {
195
+ export function previewClockMappers(
196
+ clocks: LivePreviewClocks | null,
197
+ opts: {
198
+ /** Live-output → source when NO veto is live (`clocks === null`): the
199
+ * identity clocks carry no map, and output seconds are NOT source
200
+ * seconds, so the caller supplies the spans-backed conversion
201
+ * (`mapFromKeptSpans(renderProps.spans).toSource`). Absent means
202
+ * `toSourceSec` is null — a writer that needs source time then falls
203
+ * back to old-clock-only behaviour rather than storing a lie. */
204
+ identityToSource?: (sec: number) => number;
205
+ } = {},
206
+ ): PreviewClockMappers {
146
207
  if (clocks === null) {
147
208
  const identity = (sec: number): number => sec;
148
- return { toLive: identity, fromLive: identity, hasOldClockPreimage: () => true };
209
+ return {
210
+ toLive: identity,
211
+ fromLive: identity,
212
+ hasOldClockPreimage: () => true,
213
+ toSourceSec: opts.identityToSource ?? null,
214
+ // With no live re-cut the player's clock IS the last render's, so the
215
+ // two source conversions are the same function — the same fallback,
216
+ // never a second, differently-derived one.
217
+ oldToSourceSec: opts.identityToSource ?? null,
218
+ };
149
219
  }
150
220
  const { oldMap, newMap } = clocks;
151
221
  return {
@@ -162,25 +232,41 @@ export function previewClockMappers(clocks: LivePreviewClocks | null): PreviewCl
162
232
  // — i.e. the live moment is inside revived material (its own doc comment
163
233
  // above pins the inclusive-seam semantics).
164
234
  hasOldClockPreimage: (sec) => oldMap.toOutput(newMap.toSource(sec)) !== null,
235
+ // The SOURCE second under the live playhead — exact: `newMap` is the
236
+ // very clock the player is on, and `toSource` is total. This is what
237
+ // lets the editor write `splits[].src` directly (SplitSchema's
238
+ // documented divergence from the cuts rule).
239
+ toSourceSec: (sec) => newMap.toSource(sec),
240
+ // The OLD clock's own source conversion — `oldMap`, not `newMap`: a
241
+ // window that has not been retimed onto the player's clock (the
242
+ // transcript panel's) must resolve through the map it was timed against.
243
+ oldToSourceSec: (sec) => oldMap.toSource(sec),
165
244
  };
166
245
  }
167
246
 
168
247
  /** `cutRangeToOldClock`'s verdict on a live-clock window headed for a doc
169
- * `cuts[]` slot. `exact`/`shrunk` carry OLD-clock seconds ready to store;
170
- * `shrunk` also carries a report (the `remapPoint` posture — a moved value
171
- * says so) for the caller's feedback channel; `degenerate` means the window
172
- * has NO old-clock extent at all and the write must be refused out loud. */
248
+ * `cuts[]` slot's HISTORICAL record. `exact`/`shrunk` carry OLD-clock seconds
249
+ * ready to store; `shrunk` also carries a report (the `remapPoint` posture —
250
+ * a moved value says so) for the caller's feedback channel; `degenerate`
251
+ * means the window has NO old-clock extent at all: a writer that can resolve
252
+ * `src` stores it with a clamped record anyway, one that cannot must refuse
253
+ * out loud. */
173
254
  export type OldClockCutRange =
174
255
  | { kind: "exact"; startSec: number; endSec: number }
175
256
  | { kind: "shrunk"; startSec: number; endSec: number; report: string }
176
257
  | { kind: "degenerate" };
177
258
 
178
259
  /**
179
- * Convert a cut gesture's LIVE-clock window into the OLD-clock window the
180
- * doc's `cuts[]` slots speak (the schema comment on `OverrideDocSchema.cuts`:
181
- * a fresh cut's `startSec`/`endSec` are drawn against the LAST render-props'
182
- * frame — produce resolves `src` by mapping them through the PRIOR TimeMap,
183
- * so a new-clock number stored there lands the cut the revived seconds off).
260
+ * Convert a cut gesture's LIVE-clock window into the OLD-clock window a
261
+ * `cuts[]` entry's `startSec`/`endSec` speak — since the cut-review rework
262
+ * that is the HISTORICAL RECORD half of the write (the schema comment on
263
+ * `OverrideDocSchema.cuts`: those two numbers describe the render-props the
264
+ * user was looking at, and `src` is what is authoritative once present). It
265
+ * is still the whole write for the two paths that have no `src` to offer:
266
+ * a legacy src-less entry, whose range produce resolves through the PRIOR
267
+ * TimeMap (so a new-clock number stored there would land the cut the revived
268
+ * seconds off), and a writer whose source mapper is null (no spans, no live
269
+ * map) — that one keeps the refusal, the pre-rework flow verbatim.
184
270
  *
185
271
  * Endpoints inside revived material clamp to the nearest kept edge
186
272
  * (`fromLive`'s doc): when only ONE edge clamps the range SHRINKS there and
@@ -190,10 +276,12 @@ export type OldClockCutRange =
190
276
  * sliver past the clamped edge is lost, and the report says so. When the
191
277
  * whole window collapses to one point — both endpoints inside one revived
192
278
  * region, or the window exactly covering it seam to seam (each seam HAS a
193
- * preimage, but the same one twice) — there is nothing left to cut and the
194
- * verdict is `degenerate`: refuse, the ⌘B-split posture, never a silent
195
- * zero-length entry. Checked on the mapped WIDTH first, before the preimage
196
- * question, for exactly that seam-to-seam case. The module `EPS`, not 0: the
279
+ * preimage, but the same one twice) — the old clock has no record to give:
280
+ * the verdict is `degenerate`, and what the caller does with it depends on
281
+ * whether it holds a source mapper (the type's own doc) — never a silent
282
+ * zero-length entry that pretends the old clock said something. Checked on
283
+ * the mapped WIDTH first, before the preimage question, for exactly that
284
+ * seam-to-seam case. The module `EPS`, not 0: the
197
285
  * mapped ends ride TimeMap arithmetic, and a real cut is never under a
198
286
  * microsecond.
199
287
  *
@@ -259,12 +347,23 @@ export interface RetimedPreview {
259
347
  * `newMap`'s: old-output → source → new-output, `remapPoint`'s exact
260
348
  * algorithm — the same one produce re-anchors splits and pins with.
261
349
  *
262
- * Vetoes only ever ADD time back (a removal becomes a keep), so every moment
263
- * the old clock could express survives on the new one and maps exactly. The
264
- * clamped fallback still stands behind each point (`toOutputClamped`'s
265
- * documented role) for the one direction that can remove time: the old spans
266
- * carrying a veto the doc no longer holds — a moment inside it snaps to the
267
- * nearest kept edge and is reported, never silently dropped.
350
+ * A veto only ever ADDS time back (a removal becomes a keep), so under vetoes
351
+ * alone every moment the old clock could express survives on the new one and
352
+ * maps exactly. Two directions REMOVE time and need the clamped fallback
353
+ * behind each point (`toOutputClamped`'s documented role): old spans carrying
354
+ * a veto the doc no longer holds, and — since the cut-review rework — a LIVE
355
+ * user cut (`cuts[].src`, subtracted by `livePreviewMap`). A moment inside
356
+ * either snaps to the nearest kept edge and is reported, never silently
357
+ * dropped.
358
+ *
359
+ * Removing time can also COLLAPSE a scene cue: a cut covering a whole block
360
+ * leaves both its ends clamped to the same seam. A zero-width cue is dropped
361
+ * from the preview outright, WITH a report — that is the honest rendering of
362
+ * material the user just removed, where a kept sliver would draw a phantom
363
+ * block on the timeline for footage that no longer plays. Only `sceneCues`
364
+ * get this: a collapsed caption line or zoom segment is inert where it sits
365
+ * (nothing renders across a zero window), while a cue is a timeline BLOCK
366
+ * with a label, a hit target and a selection.
268
367
  *
269
368
  * Word `srcStart` is already SOURCE time (§137's recut-immune key) and is
270
369
  * carried untouched. `punch` comes back provably inert — `{scale: 1,
@@ -300,11 +399,18 @@ export function retimeForPreview(
300
399
  end: at(`caption word "${w.text}" end`, w.end),
301
400
  })),
302
401
  })),
303
- sceneCues: props.sceneCues.map((c) => ({
304
- ...c,
305
- startSec: at(`scene "${c.id}" start`, c.startSec),
306
- endSec: at(`scene "${c.id}" end`, c.endSec),
307
- })),
402
+ sceneCues: props.sceneCues.flatMap((c) => {
403
+ const startSec = at(`scene "${c.id}" start`, c.startSec);
404
+ const endSec = at(`scene "${c.id}" end`, c.endSec);
405
+ // Collapsed by a live cut (the doc's own paragraph) — dropped, and
406
+ // said out loud, the `remapPoint` "nothing moves without saying so"
407
+ // rule applied to a block that stopped existing.
408
+ if (endSec - startSec < EPS) {
409
+ reports.push(`scene "${c.id}" removed from the live preview by a cut`);
410
+ return [];
411
+ }
412
+ return [{ ...c, startSec, endSec }];
413
+ }),
308
414
  punch: { scale: 1, allowed: [] },
309
415
  };
310
416
  if (props.zoomPlan) {
@@ -73,6 +73,19 @@ export const SceneCueSchema = z
73
73
  * `=== "graphic"`.
74
74
  */
75
75
  kind: z.enum(["graphic", "plain"]).optional(),
76
+ /**
77
+ * Set on a plain cue carved out of a KEPT (vetoed) cleanup removal
78
+ * (`carveKeptTakes`, cut-review rework 2026-08-26) — the SOURCE range the
79
+ * user chose to keep, so the timeline can render the revived stretch as
80
+ * its own labeled block instead of an indistinguishable part of the
81
+ * neighbouring take. Explicit field over id-prefix sniffing on purpose;
82
+ * optional so every render-props/production.json written before it exists
83
+ * parses byte-identically. A DISMISSED range's carved cue does NOT carry
84
+ * this — dismissed material is ordinary footage.
85
+ */
86
+ kept: z
87
+ .object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() })
88
+ .optional(),
76
89
  /**
77
90
  * The plan anchor this cue was resolved from — the scene's word range,
78
91
  * carried through so an edit made against this cue can be re-keyed when a