@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.
- package/package.json +1 -1
- package/src/browser.ts +63 -2
- package/src/config.ts +15 -0
- package/src/cover-headline.ts +8 -1
- package/src/cutlist.ts +109 -0
- package/src/index.ts +1 -0
- package/src/overrides.ts +37 -0
- package/src/producer/antigravity.ts +237 -25
- package/src/producer/beats.ts +45 -4
- package/src/producer/claude-cli.ts +82 -2
- package/src/producer/failure.ts +40 -0
- package/src/producer/fallback.ts +91 -0
- package/src/producer/index.ts +65 -5
- package/src/producer/usage.ts +31 -1
- package/src/recut.ts +6 -1
- package/src/retime-preview.ts +331 -0
- package/src/schema.ts +19 -0
|
@@ -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
|