@ossclip/core 0.1.31 → 0.1.34

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