@open-take/compositor 0.1.2 → 0.2.1

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": "@open-take/compositor",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Polish compositor (D3) — event log + captured frames -> polished mp4 + editable revideo composition.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,13 +16,13 @@
16
16
  "src/scene/tsconfig.json"
17
17
  ],
18
18
  "dependencies": {
19
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
20
+ "@ffprobe-installer/ffprobe": "^2.1.2",
19
21
  "@revideo/2d": "0.11.0",
20
22
  "@revideo/core": "0.11.0",
21
23
  "@revideo/ui": "0.11.0",
22
24
  "@revideo/vite-plugin": "0.11.0",
23
- "@ffmpeg-installer/ffmpeg": "^1.1.0",
24
- "@ffprobe-installer/ffprobe": "^2.1.2",
25
- "@open-take/revideo-renderer": "0.1.0"
25
+ "@open-take/revideo-renderer": "0.2.1"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.10.1",
package/src/camera.ts ADDED
@@ -0,0 +1,343 @@
1
+ // camera.ts — the auto-camera DIRECTOR.
2
+ //
3
+ // Zoom is DECIDED here, deterministically, from the ground-truth event log — NOT
4
+ // by the authoring agent (which decides pre-capture, blind, and every model has
5
+ // its own bias), and NOT per-event in isolation. The director runs over the
6
+ // WHOLE beat sequence so it can do the three things a per-event heuristic can't:
7
+ //
8
+ // 1. shape the region-of-interest PER KIND — a `type`'s payoff grows DOWN out
9
+ // of the thin field, so the ROI (and thus the scale) is result-sized, not
10
+ // strip-sized;
11
+ // 2. COALESCE a burst of nearby small interactions into ONE sustained frame
12
+ // (a thumbnail rail / toolbar), instead of punch-pull flicker per beat;
13
+ // 3. PULL OUT for a global repaint (nav / restyle) — read off the capture's
14
+ // changed-area, not guessed from a bbox that looks identical to a popover's.
15
+ //
16
+ // Output stays in the editable representation: one framing decision per beat
17
+ // (enabled / scale / center + a human-readable reason). A "cluster" is just
18
+ // every beat in it sharing the SAME scale+center — buildStageKeyframes then
19
+ // holds the camera across them for free (no new schema, no new keyframe code).
20
+ //
21
+ // Pure & synchronous: it reads only fields already on the beats, including the
22
+ // capture-derived effectBox / changeCoverage seam. The frame-diff (or, later,
23
+ // mutation) pass that POPULATES those fields lives in the runtime (node) layer;
24
+ // the director itself never does I/O, so it stays snapshot-testable.
25
+
26
+ import { bboxFitScale } from "./math";
27
+ import type { BBox, CameraConfig, Pt, ZoomIntent } from "./types";
28
+
29
+ /** One action, already mapped into video-px, handed to the director. */
30
+ export type Beat = {
31
+ kind: "click" | "type" | "drag" | "scroll" | "hover" | "press";
32
+ tMs: number;
33
+ durationMs: number;
34
+ /** element bbox (for a drag: the path's bbox), video-px — or undefined for a
35
+ * bare press (no located element). */
36
+ box?: BBox;
37
+ /** the region that actually changed after the action (frame-diff seam),
38
+ * video-px. Framed over `box` when present. */
39
+ effectBox?: BBox;
40
+ /** fraction of the frame that changed after the action, 0..1. */
41
+ changeCoverage?: number;
42
+ /** anchor / cursor rest point, video-px (for the reason only). */
43
+ point: Pt;
44
+ /** plan override; absent ⇒ "auto" (the director decides). */
45
+ intent: ZoomIntent;
46
+ /** short label (sel / note) for the cluster tag in the reason. */
47
+ label?: string;
48
+ /** press only: the key chord (for the Escape-dismissal rule). */
49
+ keys?: string;
50
+ };
51
+
52
+ /** The director's per-beat verdict. plan.ts adds `inAtMs` to make a full
53
+ * ZoomDecision. */
54
+ export type Framing = {
55
+ enabled: boolean;
56
+ scale: number;
57
+ center: Pt;
58
+ reason: string;
59
+ };
60
+
61
+ const centerOf = (b: BBox): Pt => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 });
62
+ const dist = (a: Pt, b: Pt): number => Math.hypot(a.x - b.x, a.y - b.y);
63
+ const union = (a: BBox, b: BBox): BBox => {
64
+ const x = Math.min(a.x, b.x);
65
+ const y = Math.min(a.y, b.y);
66
+ return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
67
+ };
68
+
69
+ // A `type` field is a thin strip; its payoff (search results, an AI answer)
70
+ // grows DOWNWARD out of it. Framing the strip alone puts dead header above and
71
+ // crops the result below. Grow the ROI down (never up) so the frame sits over
72
+ // field + result: a taller ROI ⇒ lower scale AND a centre that drops below the
73
+ // field — both correct. For a type this also CAPS the effectBox height: a
74
+ // search/filter result list is open-ended, so the raw frame-diff region can
75
+ // span the whole viewport and flatten the punch to ~1× — growDown bounds how far
76
+ // down the frame reaches while the effectBox still supplies the real reveal width.
77
+ function growDown(box: BBox, video: { w: number; h: number }): BBox {
78
+ const grownH = Math.max(box.h, Math.min(video.h * 0.42, box.w * 0.55));
79
+ const h = Math.min(grownH, video.h - box.y); // never spill past the video edge
80
+ return { x: box.x, y: box.y, w: box.w, h };
81
+ }
82
+
83
+ /** The region a beat is "about" — what the camera should frame. A type is
84
+ * field-anchored and height-capped (its reveal is open-ended); every other
85
+ * kind trusts the captured effect region, else shapes one from the bbox. */
86
+ function roiForBeat(b: Beat, video: { w: number; h: number }): BBox | undefined {
87
+ if (b.kind === "type" && b.box) {
88
+ // Bound the frame to "field + top of results": keep the effectBox's real
89
+ // reveal WIDTH, but cap the HEIGHT to growDown's result-sized window so an
90
+ // open-ended result list can't flatten the punch to ~1× (see growDown).
91
+ const capH = growDown(b.box, video).h;
92
+ if (b.effectBox) {
93
+ const x = Math.min(b.box.x, b.effectBox.x);
94
+ const right = Math.max(b.box.x + b.box.w, b.effectBox.x + b.effectBox.w);
95
+ const bottom = b.effectBox.y + b.effectBox.h;
96
+ // Floor at the field height so a reveal that lands at/above the field top
97
+ // (a rare upward-opening autocomplete) can't yield a zero/negative ROI —
98
+ // we then simply frame the field, never punch into the vacated space above.
99
+ const h = Math.max(b.box.h, Math.min(capH, bottom - b.box.y));
100
+ return { x, y: b.box.y, w: right - x, h };
101
+ }
102
+ return growDown(b.box, video);
103
+ }
104
+ if (b.effectBox) return b.effectBox;
105
+ if (!b.box) return undefined;
106
+ return b.box;
107
+ }
108
+
109
+ function clusterLabel(beats: Beat[], idx: number[]): string {
110
+ const raw = beats[idx[0]!]?.label ?? "";
111
+ const cleaned = raw.replace(/\s+/g, " ").trim().slice(0, 18);
112
+ return cleaned || "region";
113
+ }
114
+
115
+ type Node = {
116
+ roi?: BBox;
117
+ scale: number; // fit of its OWN roi (rest if none)
118
+ forceFull: boolean; // must show full view (scroll / never / global repaint / no-roi / orienting)
119
+ forcePunch: boolean; // its own isolated punch (intent === "always")
120
+ boundary: boolean; // starts a new segment regardless of similarity
121
+ reason: string;
122
+ };
123
+
124
+ type Seg = {
125
+ idx: number[];
126
+ kind: "rest" | "punch";
127
+ roi?: BBox;
128
+ scale: number;
129
+ center: Pt;
130
+ dropped?: boolean; // a punch demoted to rest by min-hold
131
+ };
132
+
133
+ /**
134
+ * Decide framing for every beat. `endMs` is when the last frame can hold until
135
+ * (for the min-hold check on the final segment). `rest` is the stage scale at
136
+ * full view.
137
+ */
138
+ export function directCamera(
139
+ beats: Beat[],
140
+ video: { w: number; h: number },
141
+ out: { w: number; h: number },
142
+ cam: CameraConfig,
143
+ rest: number,
144
+ endMs: number,
145
+ ): Framing[] {
146
+ const restC: Pt = { x: video.w / 2, y: video.h / 2 };
147
+ const fit = (roi: BBox) => bboxFitScale(roi, out.w, out.h, cam.fillFrac, cam.maxScale, rest);
148
+
149
+ // --- phase 1: per-beat ROI + hard-break classification ---------------------
150
+ const nodes: Node[] = beats.map((b, i) => {
151
+ const roi = roiForBeat(b, video);
152
+ const scale = roi ? fit(roi) : rest;
153
+ const gapBreak =
154
+ i > 0 && b.tMs - (beats[i - 1]!.tMs + beats[i - 1]!.durationMs) > cam.coalesceWindowMs;
155
+
156
+ // explicit overrides win, and are segment boundaries (Q2 / point 3).
157
+ if (b.intent === "never")
158
+ return {
159
+ roi,
160
+ scale,
161
+ forceFull: true,
162
+ forcePunch: false,
163
+ boundary: true,
164
+ reason: "plan: zoom=never → full view",
165
+ };
166
+ if (b.intent === "always")
167
+ return {
168
+ roi,
169
+ scale,
170
+ forceFull: false,
171
+ forcePunch: true,
172
+ boundary: true,
173
+ reason: roi
174
+ ? `plan: zoom=always → ${scale.toFixed(2)}× (framing from ROI)`
175
+ : "plan: zoom=always but no bbox to frame → full view",
176
+ };
177
+
178
+ // a scroll is a pan beat: content moves, the frame stays full-view.
179
+ if (b.kind === "scroll")
180
+ return {
181
+ roi: undefined,
182
+ scale: rest,
183
+ forceFull: true,
184
+ forcePunch: false,
185
+ boundary: true,
186
+ reason: "scroll — full view (content pans)",
187
+ };
188
+
189
+ // Escape DISMISSES: its visual change is something VANISHING, so the
190
+ // frame-diff effectBox is the vacated region — framing it would punch into
191
+ // blank space. The editorial payoff of a dismissal is the restored page.
192
+ if (b.kind === "press" && b.keys && /(^|\+)esc(ape)?$/i.test(b.keys.trim()))
193
+ return {
194
+ roi: undefined,
195
+ scale: rest,
196
+ forceFull: true,
197
+ forcePunch: false,
198
+ boundary: true,
199
+ reason: "Escape (dismissal) — full view",
200
+ };
201
+
202
+ // global repaint (nav / restyle): the payoff is the whole page → pull out.
203
+ // Needs the frame-diff annotation; without it this branch is skipped (the
204
+ // director can't tell nav from popover on a bbox alone — say so).
205
+ if (b.changeCoverage != null && b.changeCoverage >= cam.pullOutCoverage)
206
+ return {
207
+ roi,
208
+ scale,
209
+ forceFull: true,
210
+ forcePunch: false,
211
+ boundary: true,
212
+ reason: `changeCoverage ${b.changeCoverage.toFixed(2)} ≥ ${cam.pullOutCoverage} (global repaint) → full view`,
213
+ };
214
+
215
+ // the opening beat orients: open on the whole app (Q3 — falls out of "the
216
+ // camera opens full", no zoomFirst flag). A cold-open uses zoom=always.
217
+ if (i === 0)
218
+ return {
219
+ roi,
220
+ scale,
221
+ forceFull: true,
222
+ forcePunch: false,
223
+ boundary: true,
224
+ reason: "opening beat — full view (orienting)",
225
+ };
226
+
227
+ // nothing locatable to frame (a bare Escape/Enter with no reveal).
228
+ if (!roi)
229
+ return {
230
+ roi,
231
+ scale,
232
+ forceFull: true,
233
+ forcePunch: false,
234
+ boundary: true,
235
+ reason: "no bbox to frame → full view",
236
+ };
237
+
238
+ // not tight enough to earn a distinct frame (a big element fills it already).
239
+ if (scale < cam.minZoomScale)
240
+ return {
241
+ roi,
242
+ scale,
243
+ forceFull: true,
244
+ forcePunch: false,
245
+ boundary: gapBreak,
246
+ reason: `ROI fills the frame already (fit ${scale.toFixed(2)}× < ${cam.minZoomScale}×) — full view`,
247
+ };
248
+
249
+ // an ordinary punchable beat — eligible to coalesce with its neighbours.
250
+ return {
251
+ roi,
252
+ scale,
253
+ forceFull: false,
254
+ forcePunch: false,
255
+ boundary: gapBreak,
256
+ reason: `punch ${scale.toFixed(2)}× (ROI-fit)`,
257
+ };
258
+ });
259
+
260
+ // --- phase 2: coalesce adjacent punchable beats into shared-frame clusters --
261
+ const segs: Seg[] = [];
262
+ for (let i = 0; i < nodes.length; i++) {
263
+ const n = nodes[i]!;
264
+ if (n.forceFull) {
265
+ segs.push({ idx: [i], kind: "rest", scale: rest, center: restC });
266
+ continue;
267
+ }
268
+ const last = segs[segs.length - 1];
269
+ const lastLastIdx = last ? last.idx[last.idx.length - 1]! : -1;
270
+ const canExtend =
271
+ !!last &&
272
+ last.kind === "punch" &&
273
+ !!last.roi &&
274
+ !n.forcePunch &&
275
+ !nodes[lastLastIdx]!.forcePunch &&
276
+ !n.boundary &&
277
+ !!n.roi &&
278
+ dist(centerOf(n.roi), centerOf(last.roi)) < cam.travelThreshold * video.w &&
279
+ fit(union(last.roi, n.roi)) >= cam.minZoomScale; // union must stay tight enough
280
+
281
+ if (canExtend && last && last.roi && n.roi) {
282
+ const roi = union(last.roi, n.roi);
283
+ last.idx.push(i);
284
+ last.roi = roi;
285
+ last.scale = fit(roi);
286
+ last.center = centerOf(roi);
287
+ } else {
288
+ segs.push({
289
+ idx: [i],
290
+ kind: "punch",
291
+ roi: n.roi,
292
+ scale: n.scale,
293
+ center: n.roi ? centerOf(n.roi) : restC,
294
+ });
295
+ }
296
+ }
297
+
298
+ // --- phase 3: min-hold — extend into the gap before the next break; a punch
299
+ // that still can't clear minHoldMs is a flinch → drop it to full view. NEVER
300
+ // merges across a break (order: hard break → coalesce → min-hold).
301
+ segs.forEach((s, si) => {
302
+ if (s.kind !== "punch") return;
303
+ const firstT = beats[s.idx[0]!]!.tMs;
304
+ const next = segs[si + 1];
305
+ const heldUntil = next ? beats[next.idx[0]!]!.tMs : endMs;
306
+ if (heldUntil - firstT < cam.minHoldMs) {
307
+ s.kind = "rest";
308
+ s.scale = rest;
309
+ s.center = restC;
310
+ s.dropped = true;
311
+ }
312
+ });
313
+
314
+ // --- assemble per-beat framing --------------------------------------------
315
+ const framings: Framing[] = new Array(beats.length);
316
+ for (const s of segs) {
317
+ if (s.kind === "rest") {
318
+ for (const i of s.idx)
319
+ framings[i] = {
320
+ enabled: false,
321
+ scale: rest,
322
+ center: restC,
323
+ reason: s.dropped
324
+ ? `punch dropped — held < ${cam.minHoldMs}ms (would flinch) → full view`
325
+ : nodes[i]!.reason,
326
+ };
327
+ continue;
328
+ }
329
+ const N = s.idx.length;
330
+ const tag = N > 1 ? `cluster[${clusterLabel(beats, s.idx)}]` : null;
331
+ s.idx.forEach((i, k) => {
332
+ framings[i] = {
333
+ enabled: true,
334
+ scale: s.scale,
335
+ center: s.center,
336
+ reason: tag
337
+ ? `${tag} ${k + 1}/${N} · hold shared framing (union of ${N}) → ${s.scale.toFixed(2)}×`
338
+ : nodes[i]!.reason,
339
+ };
340
+ });
341
+ }
342
+ return framings;
343
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export * from "./types";
11
11
  export * from "./presets";
12
12
  export { resolveFfmpeg, resolveFfprobe } from "./ffmpeg";
13
13
  export { planComposition, type PlanOpts } from "./plan";
14
+ export { directCamera, type Beat, type Framing } from "./camera";
14
15
  export { renderTake, type RenderTakeOpts } from "./render";
15
16
  export {
16
17
  validateComposition,