@ossclip/core 0.1.27 → 0.1.28

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.
@@ -0,0 +1,331 @@
1
+ /**
2
+ * The editor's live post-veto preview (cut review step 4): when the user
3
+ * declines a removal produce proposed, the preview's own timeline is re-cut
4
+ * so the player actually PLAYS the revived material, immediately, instead of
5
+ * marking a seam that only the next render honours.
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.
16
+ *
17
+ * Pure and browser-safe by construction (the cover-headline.ts split): this
18
+ * module's whole import graph — cutlist, recut, timemap, and types — has
19
+ * zero node built-ins, so it rides `@ossclip/core/browser` into the editor
20
+ * bundle, and every function here is testable without a TTY or a filesystem.
21
+ */
22
+
23
+ import type { CaptionLine } from "./captions";
24
+ import { applyCleanupChoices, type CleanupChoices } from "./cutlist";
25
+ import { remapPoint, subtractRangesFromCutlist, type UserCut } from "./recut";
26
+ import type { SceneCue } from "./scene-schema";
27
+ import type { Segment } from "./schema";
28
+ import { mapFromKeptSpans, mapsClose, TimeMap, type KeptSpan } from "./timemap";
29
+ import type { ZoomSegment } from "./zoom";
30
+
31
+ /** `applyUserCuts`'s EPS — a JSON round-trip plus TimeMap arithmetic is
32
+ * noise, a real veto is never under a millisecond. */
33
+ const EPS = 1e-6;
34
+
35
+ /** Both clocks the retime needs: the one the current render-props are timed
36
+ * against, and the one the user's cleanup choices produce. */
37
+ export interface LivePreviewClocks {
38
+ oldMap: TimeMap;
39
+ newMap: TimeMap;
40
+ }
41
+
42
+ /**
43
+ * Whether the current cleanup choices change the timeline at all — and the
44
+ * two clocks to retime through when they do. `null` is the identity signal:
45
+ * the caller must hand the props through UNTOUCHED (the regression anchor —
46
+ * a doc with no live veto must leave the preview byte-identical to today's).
47
+ *
48
+ * The new clock is produce's own sequence, same functions, same order:
49
+ * `applyCleanupChoices(proposal, choices)` then user cuts subtract from the
50
+ * 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.
58
+ *
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.
64
+ */
65
+ export function livePreviewMap(
66
+ proposal: readonly Segment[],
67
+ choices: CleanupChoices | undefined,
68
+ cuts: readonly UserCut[],
69
+ oldSpans: readonly KeptSpan[],
70
+ ): LivePreviewClocks | null {
71
+ // "Non-empty" means a veto actually present — a `reasons` map of tolerated
72
+ // `true` entries restates the default (the schema comment) and must take
73
+ // the cheap exact exit, not a float comparison of two equal maps.
74
+ const hasVeto =
75
+ 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;
79
+ 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
+ );
86
+ const newMap = new TimeMap(subtractRangesFromCutlist(rekept, ranges));
87
+ const oldMap = mapFromKeptSpans(oldSpans);
88
+ // Choices that change nothing (a veto already baked into the last
89
+ // produce's spans, a kept range overlapping no removal) are identity.
90
+ if (mapsClose(oldMap, newMap, EPS)) return null;
91
+ return { oldMap, newMap };
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The two clocks as POINT mappers, one per direction. `retimeForPreview`
99
+ * below moves the player's PROPS onto the new clock in one batch, but the
100
+ * editor also has surfaces that read or write a SINGLE instant at a gesture
101
+ * — the transcript's click-to-seek, the timeline's ghost bands, the cover
102
+ * panel's playhead — and each of those needs the same old-output → source →
103
+ * new-output walk as a plain function it can be handed without knowing the
104
+ * recut machinery behind it.
105
+ */
106
+ export interface PreviewClockMappers {
107
+ /** OLD-clock output seconds (the last render's own timeline — what the
108
+ * 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. */
113
+ toLive: (sec: number) => number;
114
+ /** The reverse: the player's clock → the last render's own output seconds.
115
+ * A live moment inside REVIVED material has no old-clock preimage at all —
116
+ * the rendered mp4 never contained that frame — so it clamps to the
117
+ * nearest kept edge (`toOutputClamped`'s documented role), the closest
118
+ * moment the old clock can honestly name. */
119
+ fromLive: (sec: number) => number;
120
+ /** Whether a live instant EXISTS on the old clock at all — false exactly
121
+ * when `fromLive` would have to clamp: the moment sits inside REVIVED
122
+ * material (a vetoed removal the last render cut away). The WRITE-direction
123
+ * guard (the follow-up to `fromLive`'s read direction): the doc's own time
124
+ * slots speak the OLD clock (`splits[].at` per SplitSchema, a fresh cut's
125
+ * `startSec`/`endSec` per the `cuts` schema comment — overrides.ts), and a
126
+ * writer facing a moment this answers false for must refuse OUT LOUD
127
+ * rather than let the clamp silently relocate the user's gesture to the
128
+ * seam — the recut.ts "reported, never silently dropped" rule, applied
129
+ * before the write instead of after. Asked as its own question, not an ad
130
+ * hoc float comparison of `toLive(fromLive(sec))` against `sec` at some
131
+ * caller-invented tolerance. An instant exactly AT a seam counts as HAVING
132
+ * a preimage: `toOutput`'s containment is inclusive of both span edges
133
+ * (timemap.ts), so the seam moment is one the last render still contained.
134
+ * Always true for the identity pair — no veto, nothing revived. */
135
+ hasOldClockPreimage: (sec: number) => boolean;
136
+ }
137
+
138
+ /**
139
+ * The mappers for the current live re-cut — or the IDENTITY pair when there
140
+ * is none (`clocks === null`, `livePreviewMap`'s own identity signal). The
141
+ * identity is literally `(sec) => sec`, so a consumer's no-veto path computes
142
+ * bit-identical values to what it computed before the mapping existed — the
143
+ * same regression anchor the `live` memo's null branch holds to.
144
+ */
145
+ export function previewClockMappers(clocks: LivePreviewClocks | null): PreviewClockMappers {
146
+ if (clocks === null) {
147
+ const identity = (sec: number): number => sec;
148
+ return { toLive: identity, fromLive: identity, hasOldClockPreimage: () => true };
149
+ }
150
+ const { oldMap, newMap } = clocks;
151
+ return {
152
+ toLive: (sec) => {
153
+ const src = oldMap.toSource(sec);
154
+ return newMap.toOutput(src) ?? newMap.toOutputClamped(src);
155
+ },
156
+ fromLive: (sec) => {
157
+ const src = newMap.toSource(sec);
158
+ return oldMap.toOutput(src) ?? oldMap.toOutputClamped(src);
159
+ },
160
+ // `fromLive`'s exact half, asked as a question: `toOutput` is null
161
+ // precisely when the source instant fell in a region the old map removed
162
+ // — i.e. the live moment is inside revived material (its own doc comment
163
+ // above pins the inclusive-seam semantics).
164
+ hasOldClockPreimage: (sec) => oldMap.toOutput(newMap.toSource(sec)) !== null,
165
+ };
166
+ }
167
+
168
+ /** `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. */
173
+ export type OldClockCutRange =
174
+ | { kind: "exact"; startSec: number; endSec: number }
175
+ | { kind: "shrunk"; startSec: number; endSec: number; report: string }
176
+ | { kind: "degenerate" };
177
+
178
+ /**
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).
184
+ *
185
+ * Endpoints inside revived material clamp to the nearest kept edge
186
+ * (`fromLive`'s doc): when only ONE edge clamps the range SHRINKS there and
187
+ * the cut proceeds on what the old clock can express — the source range
188
+ * produce resolves from the shrunk window still spans the revived material
189
+ * BETWEEN the endpoints (a contiguous source interval), so only the revived
190
+ * sliver past the clamped edge is lost, and the report says so. When the
191
+ * whole window collapses to one point — both endpoints inside one revived
192
+ * 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
197
+ * mapped ends ride TimeMap arithmetic, and a real cut is never under a
198
+ * microsecond.
199
+ *
200
+ * Identity mappers (no live veto) always answer `exact` with the input
201
+ * values untouched — the no-veto regression anchor.
202
+ */
203
+ export function cutRangeToOldClock(
204
+ mappers: Pick<PreviewClockMappers, "fromLive" | "hasOldClockPreimage">,
205
+ startSec: number,
206
+ endSec: number,
207
+ ): OldClockCutRange {
208
+ const mappedStart = mappers.fromLive(startSec);
209
+ const mappedEnd = mappers.fromLive(endSec);
210
+ if (mappedEnd - mappedStart < EPS) return { kind: "degenerate" };
211
+ if (mappers.hasOldClockPreimage(startSec) && mappers.hasOldClockPreimage(endSec)) {
212
+ return { kind: "exact", startSec: mappedStart, endSec: mappedEnd };
213
+ }
214
+ return {
215
+ kind: "shrunk",
216
+ startSec: mappedStart,
217
+ endSec: mappedEnd,
218
+ report:
219
+ `cut ${startSec.toFixed(3)}s–${endSec.toFixed(3)}s trimmed to the last render's ` +
220
+ `${mappedStart.toFixed(3)}s–${mappedEnd.toFixed(3)}s — the revived material at its ` +
221
+ `edge isn't in the last render yet`,
222
+ };
223
+ }
224
+
225
+ /** The output-timed subset of the render props the retime reads. Structural
226
+ * on purpose — the renderer's `ProductionCompProps` satisfies it without
227
+ * core importing the renderer package. */
228
+ export interface RetimeablePreviewProps {
229
+ outputDurationSec: number;
230
+ captionLines: readonly CaptionLine[];
231
+ sceneCues: readonly SceneCue[];
232
+ zoomPlan?: readonly ZoomSegment[];
233
+ ctaWindow?: { startSec: number; endSec: number };
234
+ sourceTextRegions?: readonly { y: number; h: number; startSec: number; endSec: number }[];
235
+ }
236
+
237
+ /** Exactly the fields `retimeForPreview` re-timed — the caller spreads them
238
+ * over the full props (`{ ...props, ...fields }`), so fields this function
239
+ * never touches (theme, face, framingTimeline — all source-timed or
240
+ * timeless) cannot be accidentally rewritten here. */
241
+ export interface RetimedPreviewFields {
242
+ spans: KeptSpan[];
243
+ outputDurationSec: number;
244
+ captionLines: CaptionLine[];
245
+ sceneCues: SceneCue[];
246
+ zoomPlan?: ZoomSegment[];
247
+ ctaWindow?: { startSec: number; endSec: number };
248
+ sourceTextRegions?: { y: number; h: number; startSec: number; endSec: number }[];
249
+ punch: { scale: number; allowed: boolean[] };
250
+ }
251
+
252
+ export interface RetimedPreview {
253
+ fields: RetimedPreviewFields;
254
+ reports: string[];
255
+ }
256
+
257
+ /**
258
+ * Re-time every output-timed render prop from `oldMap`'s clock onto
259
+ * `newMap`'s: old-output → source → new-output, `remapPoint`'s exact
260
+ * algorithm — the same one produce re-anchors splits and pins with.
261
+ *
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.
268
+ *
269
+ * Word `srcStart` is already SOURCE time (§137's recut-immune key) and is
270
+ * carried untouched. `punch` comes back provably inert — `{scale: 1,
271
+ * allowed: []}`: `punchScalesFor` (punch-plan.ts) renders an allowed span's
272
+ * punched turn at `scale`, and scale 1 is no visible punch; an empty mask
273
+ * reads all-allowed, which is exactly what makes scale the only knob. It
274
+ * cannot pass through: `punch.allowed` is INDEXED PER SPAN, the new span
275
+ * list has different indices, and the face-only verdict that built the mask
276
+ * cannot be recomputed client-side — a punch on the wrong span (a screen
277
+ * share sliding) is worse than no punch for the preview's duration. The
278
+ * zoom plan, by contrast, IS remapped: its segments are pure output time
279
+ * (`zoomScaleAt` consults nothing but `startSec`/`endSec`), and a revived
280
+ * stretch simply falls outside every segment, which `zoomScaleAt` already
281
+ * renders as the static camera.
282
+ */
283
+ export function retimeForPreview(
284
+ props: RetimeablePreviewProps,
285
+ oldMap: TimeMap,
286
+ newMap: TimeMap,
287
+ ): RetimedPreview {
288
+ const reports: string[] = [];
289
+ const at = (label: string, t: number): number => remapPoint(label, t, oldMap, newMap, reports);
290
+ const fields: RetimedPreviewFields = {
291
+ spans: newMap.spans.map((s) => ({ ...s })),
292
+ outputDurationSec: newMap.outputDuration,
293
+ captionLines: props.captionLines.map((line, i) => ({
294
+ ...line,
295
+ start: at(`caption line ${i + 1} start`, line.start),
296
+ end: at(`caption line ${i + 1} end`, line.end),
297
+ words: line.words.map((w) => ({
298
+ ...w,
299
+ start: at(`caption word "${w.text}" start`, w.start),
300
+ end: at(`caption word "${w.text}" end`, w.end),
301
+ })),
302
+ })),
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
+ })),
308
+ punch: { scale: 1, allowed: [] },
309
+ };
310
+ if (props.zoomPlan) {
311
+ fields.zoomPlan = props.zoomPlan.map((seg, i) => ({
312
+ ...seg,
313
+ startSec: at(`zoom segment ${i + 1} start`, seg.startSec),
314
+ endSec: at(`zoom segment ${i + 1} end`, seg.endSec),
315
+ }));
316
+ }
317
+ if (props.ctaWindow) {
318
+ fields.ctaWindow = {
319
+ startSec: at("CTA window start", props.ctaWindow.startSec),
320
+ endSec: at("CTA window end", props.ctaWindow.endSec),
321
+ };
322
+ }
323
+ if (props.sourceTextRegions) {
324
+ fields.sourceTextRegions = props.sourceTextRegions.map((r, i) => ({
325
+ ...r,
326
+ startSec: at(`source text region ${i + 1} start`, r.startSec),
327
+ endSec: at(`source text region ${i + 1} end`, r.endSec),
328
+ }));
329
+ }
330
+ return { fields, reports };
331
+ }
package/src/schema.ts CHANGED
@@ -147,7 +147,26 @@ export const ProductionSchema = z.object({
147
147
  )
148
148
  .optional(),
149
149
  analysis: AnalysisSchema.optional(),
150
+ /**
151
+ * What this run ACTUALLY cut — post cleanup-choices, post user cuts. Every
152
+ * consumer that treats the cutlist as the applied truth (`formatCutReport`,
153
+ * the four NLE exporters, `analyze`'s marker count) reads this one, which
154
+ * is why it stays the resolved list rather than the proposal: recording the
155
+ * proposal here would make each of them re-apply the choices or lie.
156
+ */
150
157
  cutlist: z.array(SegmentSchema).optional(),
158
+ /**
159
+ * The automatic PROPOSAL (cut review step 3) — `buildCutlist`'s output
160
+ * before `applyCleanupChoices` vetoes and before user cuts subtract. Kept
161
+ * alongside because the resolution is lossy: a vetoed removal merges into
162
+ * a plain keep, so `cutlist` alone cannot tell the editor which categories
163
+ * the user declined — its checkboxes and seams re-derive the veto state
164
+ * from THIS list + `overrides.json`'s `cleanup`, through the same
165
+ * `applyCleanupChoices` produce ran. Optional: pre-step-3 files predate it
166
+ * and must still parse (readers fall back to `cutlist`, which back then
167
+ * WAS the proposal plus user cuts).
168
+ */
169
+ cutlistProposed: z.array(SegmentSchema).optional(),
151
170
  /**
152
171
  * Present on a `--clip` run (R19 §93): the target and the resolved window.
153
172
  * `startWord`/`endWord` are indices into the PRE-slice repaired transcript