@ossclip/core 0.1.30 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.30",
3
+ "version": "0.1.33",
4
4
  "description": "ossclip's framework-free pipeline: schema, transcription, analysis, cutlist, captions, framing, and the LLM producer",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/assemble.ts CHANGED
@@ -71,6 +71,9 @@ export function assembleScenes(
71
71
  }
72
72
  resolved.push({
73
73
  id: scene.id,
74
+ // The cue remembers which words it was planned against, so edits keyed
75
+ // to it can survive a re-plan's id renumbering (handoff-edit-anchoring).
76
+ anchor: scene.anchor,
74
77
  layout: scene.layout,
75
78
  component: scene.component,
76
79
  props,
package/src/browser.ts CHANGED
@@ -35,6 +35,11 @@ export {
35
35
  // set need the bundled font" (2026-08-17 — two conditions would drift).
36
36
  export {
37
37
  backfillSrcStart,
38
+ // The editor rebuilds the caption track over REVIVED material with the
39
+ // same builder + packing matrix produce renders with (cut-review rework
40
+ // follow-up) — a second packer is how preview and render would drift.
41
+ buildCaptionLines,
42
+ captionPackingFor,
38
43
  captionsNeedNastaliq,
39
44
  lineDirection,
40
45
  NASTALIQ_FONT_NAME,
@@ -69,9 +74,15 @@ export { COVER_MAX_WORDS, coverHeadline } from "./cover-headline";
69
74
  export {
70
75
  applyCleanupChoices,
71
76
  cleanupVetoable,
77
+ dismissedRemovals,
72
78
  vetoedRemovals,
73
79
  type CleanupChoices,
74
80
  } from "./cutlist";
81
+ // Revived-material carving (cut-review rework 2026-08-26), browser-safe:
82
+ // kept-takes.ts imports only scene-schema, timemap and overrides types —
83
+ // all already in this surface's graph. Editor and produce carve with the
84
+ // SAME function (the applyCleanupChoices pattern).
85
+ export { carveKeptTakes, keptTakeId, type KeptRange, type CarveResult } from "./kept-takes";
75
86
  // The live post-veto preview (cut review step 4), VALUE exports and
76
87
  // browser-safe: retime-preview.ts composes cutlist + recut + timemap — all
77
88
  // already in this surface's runtime graph (recut.ts imports only overrides
@@ -99,6 +110,14 @@ export {
99
110
  type RetimeablePreviewProps,
100
111
  type RetimedPreviewFields,
101
112
  } from "./retime-preview";
113
+ // The caption re-key half of the re-transcribe splice (Phase A 2026-08-26),
114
+ // browser-safe by construction: restamp.ts imports `captionKeyFor` from
115
+ // ./overrides (already on this surface) plus types, and NOTHING else — see its
116
+ // header for why the token normalizer is restated there rather than imported
117
+ // from analyze.ts (which reaches child_process). The `useEdits` reducer owns
118
+ // `overrides.json`; the server that re-decodes the audio never writes it, so
119
+ // the re-key has to run in the browser.
120
+ export { rekeyCaptionRecords, type RekeyResult, type StampMove } from "./restamp";
102
121
  export type {
103
122
  Probe,
104
123
  Production,
package/src/captions.ts CHANGED
@@ -143,8 +143,103 @@ export interface CaptionOptions {
143
143
  * the start toward it. Display-only: cuts, analysis and the transcript
144
144
  * itself never see this, and `srcStart` keeps the RAW source stamp so the
145
145
  * §137 edit anchor does not move.
146
+ *
147
+ * 2.0 → 1.5 (field case 2026-08-26, a revived retake): its smears SLIPPED
148
+ * UNDER the 2.0 bar — `dedicated` was stamped 2.43s, so the clamp still left
149
+ * the word squatting on screen for a full 2.0s with the karaoke highlight
150
+ * stuck on it, and 8 more words in that one transcript were the same shape.
151
+ * 1.5s exceeds any genuinely spoken English word, so the tighter bar cannot
152
+ * truncate real speech, and the 2026-08-17 incident still shows "Okay," for
153
+ * its final 1.5s. ONE lever, by doctrine: the end side is the trustworthy
154
+ * edge and is never clamped, so how hard the start pulls toward it is the
155
+ * only number here — a second constant would just be this one, twice.
156
+ */
157
+ export const MAX_CAPTION_WORD_LEAD_SEC = 1.5;
158
+
159
+ /**
160
+ * The floor on how long a caption LINE stays on screen. Whisper's stamps can
161
+ * cram a burst of words into no time at all — field case 2026-08-26, a
162
+ * revived retake: ten words ("context could read 50 files and then gives a
163
+ * clean") inside 0.25s, 0.01–0.05s each, which packs into 3-word lines with
164
+ * ~0.06s windows. Rendered faithfully that is a flash nobody can read at any
165
+ * speed, so the display repairs it: a too-short line borrows from the GAP
166
+ * that follows it.
167
+ *
168
+ * SLACK ONLY, and there usually is none. On the transcript this came from,
169
+ * 98% of adjacent word pairs have a gap of ≤0 (the §18 contiguous-stamp
170
+ * chain, `parseWhisperJson` sets `next.start = w.end`), so on a zero-gap run
171
+ * of flash lines this sweep does NOTHING and the captions stay fast. That is
172
+ * the honest limit: a display cannot slow speech down, only spend slack that
173
+ * exists — which is also why the sweep is monotone and single-pass, never
174
+ * pushing a later line to make room. The slack it does find is largely what
175
+ * `MAX_CAPTION_WORD_LEAD_SEC` above creates by pulling a smeared start
176
+ * forward.
177
+ */
178
+ export const MIN_CAPTION_LINE_DWELL_SEC = 0.7;
179
+
180
+ /**
181
+ * Extend every line that would flash by less than `MIN_CAPTION_LINE_DWELL_SEC`
182
+ * into the gap after it — the display-side repair of a crammed stamp burst.
183
+ *
184
+ * Forward, single-pass, monotone: only line ENDS move, and only later, so the
185
+ * caps below can be read off the ORIGINAL lines and no line can be pushed by
186
+ * one before it. Bounds, all three of which the packer already respects for
187
+ * `hold` (`buildCaptionLines` below) and which therefore cannot be dropped
188
+ * here without re-opening what they were added for:
189
+ * - the next line's START — never overlap, never reorder (§115,
190
+ * `packages/scenes/src/frames.ts`: no two lines may share a frame);
191
+ * - the next BREAKPOINT — a line held across a scene-cue edge sits in the
192
+ * WRONG layout's caption band and can land on a card or the face
193
+ * (FINDINGS §6b), and readability is not worth that;
194
+ * - `maxEnd`, the output duration — there are no frames past it to draw on.
195
+ * The last line is free of the NEIGHBOUR bound only; the other two still hold.
196
+ *
197
+ * Never shortens a line, and never touches `words`: the dwell is the LINE's
198
+ * window, and stretching the last word's karaoke stamp to fill it would just
199
+ * move the stuck-highlight bug from `MAX_CAPTION_WORD_LEAD_SEC`'s case into
200
+ * this one. Lines with no slack to take are returned VERBATIM. Pure, so the
201
+ * whole bounds matrix is testable without a packer.
202
+ */
203
+ export function enforceLineDwell(
204
+ lines: readonly CaptionLine[],
205
+ opts: { breakpoints?: readonly number[]; maxEnd?: number } = {},
206
+ ): CaptionLine[] {
207
+ const breakpoints = [...(opts.breakpoints ?? [])].sort((a, b) => a - b);
208
+ return lines.map((line, i) => {
209
+ if (line.end - line.start >= MIN_CAPTION_LINE_DWELL_SEC) return line;
210
+ let end = line.start + MIN_CAPTION_LINE_DWELL_SEC;
211
+ const next = lines[i + 1];
212
+ if (next) end = Math.min(end, next.start);
213
+ // Same predicate as the hold clamp, so the two agree on which boundary is
214
+ // "this line's": strictly after its start, with the packer's epsilon.
215
+ const boundary = breakpoints.find((b) => b > line.start + 1e-6);
216
+ if (boundary !== undefined) end = Math.min(end, boundary);
217
+ if (opts.maxEnd !== undefined) end = Math.min(end, opts.maxEnd);
218
+ // A cap at or before where the line already ended is no slack at all —
219
+ // return the line itself, so an untouched track stays byte-identical.
220
+ if (end <= line.end) return line;
221
+ return { ...line, end };
222
+ });
223
+ }
224
+
225
+ /**
226
+ * Landscape draws captions at 44px on a 1920px frame against portrait's
227
+ * 64px on 1080px (`captionFontSizeFor`) — roughly 2.6× the horizontal text
228
+ * budget — so the portrait default's 3-word lines look sparse there;
229
+ * landscape packs 6 words over 2.4s, double the core defaults. Portrait
230
+ * returns those defaults VERBATIM — the core defaults are portrait's
231
+ * contract and its output must stay byte-identical. Lived in produce.ts
232
+ * until the cut-review follow-up; the editor's live caption rebuild packs
233
+ * with this same matrix, so it moved to the one browser-safe home.
146
234
  */
147
- export const MAX_CAPTION_WORD_LEAD_SEC = 2;
235
+ export function captionPackingFor(landscape: boolean): {
236
+ maxWordsPerLine: number;
237
+ maxLineDuration: number;
238
+ } {
239
+ return landscape
240
+ ? { maxWordsPerLine: 6, maxLineDuration: 2.4 }
241
+ : { maxWordsPerLine: 3, maxLineDuration: 1.2 };
242
+ }
148
243
 
149
244
  export function buildCaptionLines(
150
245
  transcript: Transcript,
@@ -211,5 +306,9 @@ export function buildCaptionLines(
211
306
  // A single word physically spanning a boundary stays readable to its end.
212
307
  line.end = Math.min(Math.max(end, lastWordEnd), map.outputDuration);
213
308
  }
214
- return lines;
309
+ // LAST, on the finished windows: `hold` has already had its say, so the
310
+ // dwell floor caps at an absolute `start + MIN_CAPTION_LINE_DWELL_SEC`
311
+ // rather than adding to what the hold produced — a line already long
312
+ // enough is returned untouched instead of held twice.
313
+ return enforceLineDwell(lines, { breakpoints, maxEnd: map.outputDuration });
215
314
  }
package/src/config.ts CHANGED
@@ -67,6 +67,15 @@ export interface OssclipConfig {
67
67
  * `--watermark` / `--no-watermark` win over this per run.
68
68
  */
69
69
  watermark?: boolean;
70
+ /**
71
+ * Overlay the cover image on the opening frames of every produce run, for
72
+ * the platforms that ignore an uploaded cover and use frame 1. DEFAULT OFF:
73
+ * the overlay costs the first fraction of the hook, so it is a choice about
74
+ * where you publish, not a default anyone should inherit.
75
+ * `--cover-in-video` / `--no-cover-in-video` win over this per run
76
+ * (`resolveCoverInVideo`), the `watermark` contract exactly.
77
+ */
78
+ coverInVideo?: boolean;
70
79
  /**
71
80
  * Terms of art the speaker uses — "JSON", "ossclip", "Genkit" — biasing
72
81
  * transcription (whisper `--prompt`), vouching repair corrections, and
@@ -133,6 +142,16 @@ export interface OssclipConfig {
133
142
  * one warning and the default, never a coerced tab count.
134
143
  */
135
144
  renderConcurrency?: number;
145
+ /**
146
+ * Base URL of the user's own self-hosted Postiz instance
147
+ * (https://postiz.com), the backend `ossclip publish` posts through —
148
+ * "https://postiz.example.com" or "http://localhost:5000". Non-secret, so
149
+ * it may live here; the API key is `OSSCLIP_POSTIZ_API_KEY` in the
150
+ * ENVIRONMENT only (env.ts's documented rule — secrets never live in
151
+ * config.json). File-only like `audience`; validated at the consumer
152
+ * (`publishConfigured` in the CLI), never coerced.
153
+ */
154
+ postizUrl?: string;
136
155
  /**
137
156
  * USD per million tokens, keyed by model id or family substring — overrides
138
157
  * the built-in assumptions in `producer/usage.ts` so a run's cost line
@@ -221,6 +240,11 @@ export function loadConfig(): OssclipConfig {
221
240
  // resolveWatermark), so a hand-edited non-boolean stays OFF, the safe
222
241
  // default for a credit.
223
242
  watermark: fileCfg.watermark,
243
+ // File-only, `watermark`'s posture verbatim: the strict `=== true` lives
244
+ // at the consumer (produce's resolveCoverInVideo), so a hand-edited
245
+ // non-boolean stays OFF — the safe default for something that paints over
246
+ // the first frames of the hook.
247
+ coverInVideo: fileCfg.coverInVideo,
224
248
  // File-only for the same reason as `watermark`: these are structured
225
249
  // values a hand-editable JSON file supplies, and parse-don't-coerce says
226
250
  // the strict checks live at the consumer — `validDictionary` /
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `--cover-in-video` (§93): the cover image OVERLAID on the short's opening
3
+ * frames, for the platforms that ignore an uploaded cover and use frame 1.
4
+ *
5
+ * OVERLAY, never insertion. Inserting a still at the head would shift every
6
+ * output instant after it — audio, spans, splits, pinned timing, caption
7
+ * stamps — which is the §93 A/V-sync trap the roadmap item refused to rush.
8
+ * Painting over frames that already exist changes no clock at all, so nothing
9
+ * downstream has to be re-anchored and an off run stays byte-identical.
10
+ *
11
+ * The cost of the overlay is the mirror image: whatever it covers is LOST for
12
+ * its duration, not delayed. That is what bounds the window below.
13
+ */
14
+
15
+ /**
16
+ * Longest the cover may sit on top of the video. Half a second is about the
17
+ * shortest a still reads as a deliberate first frame in a feed scrub; more
18
+ * than that and the overlay is eating the hook the whole pipeline exists to
19
+ * put in the first two seconds.
20
+ */
21
+ export const COVER_IN_VIDEO_CAP_SEC = 0.5;
22
+
23
+ /**
24
+ * Shortest window worth rendering. A take whose first word lands at 0.04s
25
+ * would otherwise get a one-or-two-frame flash that reads as a glitch rather
26
+ * than a cover — and the floor deliberately eats the head of that first word,
27
+ * because a cover nobody can see is not a cover.
28
+ */
29
+ export const COVER_IN_VIDEO_FLOOR_SEC = 0.2;
30
+
31
+ /**
32
+ * How long the cover overlay lasts, in OUTPUT seconds.
33
+ *
34
+ * It ends at the FIRST WORD's start: the moment speech begins is the moment
35
+ * the overlay starts costing content, and the head of a take is usually a
36
+ * breath or a settle nobody misses. Clamped into `[floorSec, capSec]` — see
37
+ * the two constants for what each bound is protecting.
38
+ *
39
+ * `words` are OUTPUT-clock words (caption words, post-cut): the caller owns
40
+ * the clock, this owns the arithmetic. No words at all — a `--no-produce` run,
41
+ * a silent take, a transcript that came back empty — takes the cap, since
42
+ * there is no speech for the overlay to be in the way of.
43
+ *
44
+ * Pure so the whole matrix is testable without a transcript on disk.
45
+ */
46
+ export function coverInVideoWindow(
47
+ words: readonly { start: number }[],
48
+ opts: { capSec: number; floorSec: number },
49
+ ): number {
50
+ const first = words[0]?.start;
51
+ // `Number.isFinite`, not a truthiness check: a first word at exactly 0 is a
52
+ // real value (it floors below), while a NaN start from a mangled transcript
53
+ // must fall back to the cap rather than propagate into a frame count.
54
+ if (first === undefined || !Number.isFinite(first)) return opts.capSec;
55
+ return Math.min(opts.capSec, Math.max(opts.floorSec, first));
56
+ }
package/src/cutlist.ts CHANGED
@@ -349,6 +349,15 @@ export interface CleanupChoices {
349
349
  /** Individual vetoes, SOURCE seconds — see `vetoedRemovals` for the
350
350
  * overlap-based matching rule. */
351
351
  kept?: readonly { srcIn: number; srcOut: number }[];
352
+ /**
353
+ * Dismissed proposals ("not a retake") — the classification itself was
354
+ * wrong, so the material is ordinary footage and the marker disappears.
355
+ * SOURCE seconds, overlap-matched like `kept`. Kept in its own list, NOT
356
+ * folded into `kept`: `vetoedRemovals` feeds the "kept · <reason>" visual
357
+ * state and produce's kept-N line, and a dismissed marker must read as
358
+ * neither. `applyCleanupChoices` re-keeps both.
359
+ */
360
+ dismissed?: readonly { srcIn: number; srcOut: number }[];
352
361
  }
353
362
 
354
363
  /**
@@ -413,11 +422,39 @@ export function vetoedRemovals(
413
422
  * (via `@ossclip/core/browser`) to mark vetoed seams. A preview that
414
423
  * disagrees with the render is worse than no preview.
415
424
  */
425
+ /**
426
+ * The `remove` spans of `cutlist` that `choices.dismissed` reclassifies away
427
+ * — `vetoedRemovals`' exact matching rule (overlap, never float equality; a
428
+ * partial overlap dismisses the WHOLE removal — one decision, not divisible)
429
+ * over the other list. Separate function on purpose: the two lists mean
430
+ * different things to every DISPLAY surface (a veto is "kept", a dismissal
431
+ * is "there was never anything here"), while `applyCleanupChoices` re-keeps
432
+ * the union because the RENDER outcome is identical.
433
+ */
434
+ export function dismissedRemovals(
435
+ cutlist: readonly Segment[],
436
+ choices: CleanupChoices | undefined,
437
+ ): Segment[] {
438
+ const dismissed = choices?.dismissed ?? [];
439
+ if (dismissed.length === 0) return [];
440
+ return cutlist.filter(
441
+ (seg) =>
442
+ seg.kind === "remove" &&
443
+ cleanupVetoable(seg.reason) &&
444
+ dismissed.some((d) => d.srcIn < seg.srcOut && d.srcOut > seg.srcIn),
445
+ );
446
+ }
447
+
416
448
  export function applyCleanupChoices(
417
449
  cutlist: readonly Segment[],
418
450
  choices: CleanupChoices | undefined,
419
451
  ): Segment[] {
420
- const vetoed = new Set(vetoedRemovals(cutlist, choices));
452
+ const vetoed = new Set([
453
+ ...vetoedRemovals(cutlist, choices),
454
+ // Dismissed proposals re-keep identically — the difference is display
455
+ // state and permanence, not render outcome (dismissedRemovals' doc).
456
+ ...dismissedRemovals(cutlist, choices),
457
+ ]);
421
458
  if (vetoed.size === 0) return [...cutlist];
422
459
  const out: Segment[] = [];
423
460
  for (const seg of cutlist) {
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export * from "./recut";
10
10
  export * from "./ingest";
11
11
  export * from "./concat";
12
12
  export * from "./transcribe";
13
+ export * from "./restamp";
13
14
  export * from "./analyze";
14
15
  export * from "./cutlist";
15
16
  export * from "./retime-preview";
@@ -28,6 +29,9 @@ export * from "./normalize";
28
29
  export * from "./framing";
29
30
  export * from "./face";
30
31
  export * from "./cover";
32
+ export * from "./cover-in-video";
33
+ export * from "./publish/index";
34
+ export * from "./kept-takes";
31
35
  export * from "./thumbnail";
32
36
  export * from "./source-text";
33
37
  export * from "./report";
package/src/ingest.ts CHANGED
@@ -85,6 +85,40 @@ export async function extractAudio(tools: IngestTools, src: string, outWav: stri
85
85
  ]);
86
86
  }
87
87
 
88
+ /**
89
+ * Extract ONE span of an existing wav, same 16 kHz mono PCM shape
90
+ * (2026-08-26, the caption re-alignment pass).
91
+ *
92
+ * Fed the workdir's `audio.wav`, which `extractAudio` above already wrote at
93
+ * 16k/mono/pcm_s16le — so this is a sample-exact cut, not a re-encode, and it
94
+ * costs milliseconds even on a long source. Re-slicing from the ORIGINAL video
95
+ * would decode video frames for nothing and hand whisper an audio stream
96
+ * conditioned differently from the one the first pass decoded, which is
97
+ * exactly the variable a re-transcription is trying to hold still.
98
+ *
99
+ * `-ss` goes BEFORE `-i`: as an input option ffmpeg seeks the demuxer and
100
+ * starts decoding at the span, instead of decoding the whole file and
101
+ * discarding everything ahead of it. On PCM that seek is exact, so the clip's
102
+ * stamps are `spanStart`-relative with no drift to compensate for
103
+ * (`alignRestamp` adds the offset back).
104
+ */
105
+ export async function extractAudioSpan(
106
+ tools: IngestTools,
107
+ wav: string,
108
+ outWav: string,
109
+ fromSec: number,
110
+ durSec: number,
111
+ ): Promise<void> {
112
+ await run(tools.ffmpegPath, [
113
+ "-y",
114
+ "-ss", fromSec.toFixed(3),
115
+ "-i", wav,
116
+ "-t", durSec.toFixed(3),
117
+ "-vn", "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le",
118
+ outWav,
119
+ ]);
120
+ }
121
+
88
122
  /**
89
123
  * Headroom over the exact displayed size so a zoomed span never renders from
90
124
  * below-native pixels (2026-08-17 render-speed pass). The two motion drivers
@@ -0,0 +1,170 @@
1
+ import type { SceneCue } from "./scene-schema";
2
+ import type { TimeMap } from "./timemap";
3
+ import { SPLIT_MIN_PIECE_SEC } from "./overrides";
4
+
5
+ /**
6
+ * Carve KEPT (vetoed) and DISMISSED cleanup removals out of the plain-take
7
+ * cues, so revived material is a first-class block instead of an invisible
8
+ * stretch annexed by its neighbour (cut-review rework, 2026-08-26).
9
+ *
10
+ * Why annexation happens without this: the live memo's `retimeForPreview`
11
+ * only remaps EXISTING cue endpoints, and `TimeMap.toSource` at a seam
12
+ * returns the earlier preimage (timemap.ts's own boundary doc), so the cue
13
+ * after the seam starts exactly where the revived material begins — the
14
+ * stretch belongs to it, carries no id of its own, and cannot be selected,
15
+ * labeled, split, or trimmed.
16
+ *
17
+ * ONE implementation, two callers (the `applyCleanupChoices` pattern):
18
+ * produce carves between `fillPlainCues` and `splitCues` with this run's
19
+ * map, so `take-kept-*` ids exist server-side and framing edits on them
20
+ * survive a re-render; the editor carves after `retimeForPreview` with
21
+ * `livePreviewMap`'s newMap, so the block appears the moment a chip is
22
+ * clicked. Same ranges, same map semantics — the two cannot drift.
23
+ */
24
+
25
+ export interface KeptRange {
26
+ /** SOURCE seconds of the removal the user kept or dismissed. */
27
+ srcIn: number;
28
+ srcOut: number;
29
+ /**
30
+ * A dismissed range carves the same stable block but WITHOUT the `kept`
31
+ * tag: dismissed material is ordinary footage and must render as a normal
32
+ * take, while a vetoed-kept range renders in the revived state.
33
+ */
34
+ dismissed?: boolean;
35
+ }
36
+
37
+ /**
38
+ * The carved cue's id, from the range's SOURCE milliseconds — stable across
39
+ * re-cuts, veto toggles and re-produces by construction (§155: key on the
40
+ * property the disruption cannot move), so a framing edit on the revived
41
+ * block survives all of them.
42
+ */
43
+ export function keptTakeId(srcIn: number): string {
44
+ return `take-kept-${Math.round(srcIn * 1000)}`;
45
+ }
46
+
47
+ /** Below this, a leading/trailing remainder of the carved cue is float dust
48
+ * from seam math, not a piece anyone can edit — it folds into the carved
49
+ * block instead of surviving as a sliver cue. */
50
+ const REMAINDER_EPS = 0.05;
51
+
52
+ export interface CarveResult {
53
+ cues: SceneCue[];
54
+ reports: string[];
55
+ }
56
+
57
+ /**
58
+ * For each range: map its source edges onto `map`'s output clock (exact —
59
+ * a kept range is interior to a merged keep span) and split the covering
60
+ * PLAIN cue into up-to-three pieces, the middle one becoming the
61
+ * `take-kept-<srcInMs>` block. Rules, each stated where enforced:
62
+ *
63
+ * - a range shorter than `SPLIT_MIN_PIECE_SEC` carves nothing (chip-only,
64
+ * reported) — deliberately below `MIN_PLAIN_SEC` (0.6, fill.ts), because
65
+ * this is real footage the user asked to see, not an assembler gap;
66
+ * - a range covered by a GRAPHIC cue is left alone with a report — the
67
+ * graphic owns that window;
68
+ * - a range whose block already exists (produce carved it server-side, or
69
+ * an earlier call did) is skipped — carving is idempotent;
70
+ * - a range not fully inside one plain cue carves the part that is, with a
71
+ * report — never two cues sharing one id.
72
+ */
73
+ export function carveKeptTakes(
74
+ cues: readonly SceneCue[],
75
+ ranges: readonly KeptRange[],
76
+ map: TimeMap,
77
+ ): CarveResult {
78
+ const out = [...cues];
79
+ const reports: string[] = [];
80
+ for (const range of ranges) {
81
+ const id = keptTakeId(range.srcIn);
82
+ const label = `kept range ${range.srcIn.toFixed(3)}–${range.srcOut.toFixed(3)}s`;
83
+ if (out.some((c) => c.id === id || c.id.startsWith(`${id}@`))) continue; // already carved
84
+ if (range.srcOut - range.srcIn < SPLIT_MIN_PIECE_SEC) {
85
+ reports.push(`${label} is shorter than ${SPLIT_MIN_PIECE_SEC}s — shown in the lane only`);
86
+ continue;
87
+ }
88
+ const outIn = map.toOutput(range.srcIn);
89
+ const outOut = map.toOutput(range.srcOut);
90
+ if (outIn === null || outOut === null || outOut <= outIn) {
91
+ // Not on this clock at all — the range's material is (still) removed
92
+ // here; nothing to carve, and clamping would mint a lie of a block.
93
+ reports.push(`${label} is not in this cut — no block carved`);
94
+ continue;
95
+ }
96
+ const i = out.findIndex((c) => c.startSec <= outIn + 1e-6 && c.endSec > outIn + 1e-6);
97
+ const host = i === -1 ? undefined : out[i];
98
+ if (host === undefined) {
99
+ // A HOLE, not an annexation: a removal at the head (or against a
100
+ // graphic's edge) retimes the neighbouring take AWAY from the revived
101
+ // stretch instead of over it (`TimeMap.toSource`'s earlier-preimage
102
+ // rule points the old 0 at the neighbour's own source start). Nothing
103
+ // owns the window, so the block is minted from scratch — layout
104
+ // borrowed from the nearest plain cue so the revived footage frames
105
+ // like its neighbours, never like a graphic.
106
+ const overlapping = out.some((c) => c.startSec < outOut - 1e-6 && c.endSec > outIn + 1e-6);
107
+ if (overlapping) {
108
+ reports.push(`${label} straddles existing cues — no block carved`);
109
+ continue;
110
+ }
111
+ const neighbour = [...out]
112
+ .filter((c) => c.kind === "plain")
113
+ .sort(
114
+ (a, b) => Math.abs(a.startSec - outIn) - Math.abs(b.startSec - outIn),
115
+ )[0];
116
+ const minted: SceneCue = {
117
+ id,
118
+ kind: "plain",
119
+ layout: neighbour?.layout ?? "video-top",
120
+ ...(neighbour?.video !== undefined ? { video: neighbour.video } : {}),
121
+ startSec: outIn,
122
+ endSec: outOut,
123
+ ...(range.dismissed === true
124
+ ? {}
125
+ : { kept: { srcIn: range.srcIn, srcOut: range.srcOut } }),
126
+ };
127
+ const insertAt = out.findIndex((c) => c.startSec >= outOut - 1e-6);
128
+ out.splice(insertAt === -1 ? out.length : insertAt, 0, minted);
129
+ continue;
130
+ }
131
+ if (host.kind !== "plain") {
132
+ // Absence means "graphic" (SceneCueSchema's kind doc) — either way,
133
+ // not ours to carve.
134
+ reports.push(`${label} sits under graphic "${host.id}" — the graphic keeps the window`);
135
+ continue;
136
+ }
137
+ const end = Math.min(outOut, host.endSec);
138
+ if (end < outOut - 1e-6) {
139
+ reports.push(
140
+ `${label} crosses out of take "${host.id}" — carved up to its edge (${end.toFixed(3)}s)`,
141
+ );
142
+ }
143
+ // Pieces: [host.start, outIn] (host keeps its id), the carved block,
144
+ // [end, host.end] (host's id too — `splitCues`' both-halves-keep rule
145
+ // does not apply: these are the SAME take around a foreign block, and
146
+ // minting `@` names here would collide with the split-id namespace).
147
+ const carved: SceneCue = {
148
+ ...host,
149
+ id,
150
+ startSec: Math.max(host.startSec, outIn),
151
+ endSec: end,
152
+ ...(range.dismissed === true ? {} : { kept: { srcIn: range.srcIn, srcOut: range.srcOut } }),
153
+ };
154
+ // Sub-eps remainders fold into the carved block — a 20ms sliver take is
155
+ // seam float dust, not content (REMAINDER_EPS).
156
+ const lead = outIn - host.startSec;
157
+ const tail = host.endSec - end;
158
+ const pieces: SceneCue[] = [
159
+ ...(lead >= REMAINDER_EPS ? [{ ...host, endSec: outIn }] : []),
160
+ lead >= REMAINDER_EPS ? carved : { ...carved, startSec: host.startSec },
161
+ ...(tail >= REMAINDER_EPS ? [{ ...host, startSec: end }] : []),
162
+ ];
163
+ if (tail < REMAINDER_EPS) {
164
+ const last = pieces[pieces.length - 1]!;
165
+ pieces[pieces.length - 1] = { ...last, endSec: host.endSec };
166
+ }
167
+ out.splice(i, 1, ...pieces);
168
+ }
169
+ return { cues: out, reports };
170
+ }