@open-take/compositor 0.1.1 → 0.2.0

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/src/camera.ts ADDED
@@ -0,0 +1,324 @@
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. This is the BLIND guess; when the frame-diff pass gives
74
+ // a real effectBox, that wins over this (roiForBeat prefers effectBox).
75
+ function growDown(box: BBox, video: { w: number; h: number }): BBox {
76
+ const grownH = Math.max(box.h, Math.min(video.h * 0.42, box.w * 0.55));
77
+ const h = Math.min(grownH, video.h - box.y); // never spill past the video edge
78
+ return { x: box.x, y: box.y, w: box.w, h };
79
+ }
80
+
81
+ /** The region a beat is "about" — what the camera should frame. Prefers the
82
+ * captured effect region; else shapes one from the element bbox by kind. */
83
+ function roiForBeat(b: Beat, video: { w: number; h: number }): BBox | undefined {
84
+ if (b.effectBox) return b.effectBox;
85
+ if (!b.box) return undefined;
86
+ if (b.kind === "type") return growDown(b.box, video);
87
+ return b.box;
88
+ }
89
+
90
+ function clusterLabel(beats: Beat[], idx: number[]): string {
91
+ const raw = beats[idx[0]!]?.label ?? "";
92
+ const cleaned = raw.replace(/\s+/g, " ").trim().slice(0, 18);
93
+ return cleaned || "region";
94
+ }
95
+
96
+ type Node = {
97
+ roi?: BBox;
98
+ scale: number; // fit of its OWN roi (rest if none)
99
+ forceFull: boolean; // must show full view (scroll / never / global repaint / no-roi / orienting)
100
+ forcePunch: boolean; // its own isolated punch (intent === "always")
101
+ boundary: boolean; // starts a new segment regardless of similarity
102
+ reason: string;
103
+ };
104
+
105
+ type Seg = {
106
+ idx: number[];
107
+ kind: "rest" | "punch";
108
+ roi?: BBox;
109
+ scale: number;
110
+ center: Pt;
111
+ dropped?: boolean; // a punch demoted to rest by min-hold
112
+ };
113
+
114
+ /**
115
+ * Decide framing for every beat. `endMs` is when the last frame can hold until
116
+ * (for the min-hold check on the final segment). `rest` is the stage scale at
117
+ * full view.
118
+ */
119
+ export function directCamera(
120
+ beats: Beat[],
121
+ video: { w: number; h: number },
122
+ out: { w: number; h: number },
123
+ cam: CameraConfig,
124
+ rest: number,
125
+ endMs: number,
126
+ ): Framing[] {
127
+ const restC: Pt = { x: video.w / 2, y: video.h / 2 };
128
+ const fit = (roi: BBox) => bboxFitScale(roi, out.w, out.h, cam.fillFrac, cam.maxScale, rest);
129
+
130
+ // --- phase 1: per-beat ROI + hard-break classification ---------------------
131
+ const nodes: Node[] = beats.map((b, i) => {
132
+ const roi = roiForBeat(b, video);
133
+ const scale = roi ? fit(roi) : rest;
134
+ const gapBreak =
135
+ i > 0 && b.tMs - (beats[i - 1]!.tMs + beats[i - 1]!.durationMs) > cam.coalesceWindowMs;
136
+
137
+ // explicit overrides win, and are segment boundaries (Q2 / point 3).
138
+ if (b.intent === "never")
139
+ return {
140
+ roi,
141
+ scale,
142
+ forceFull: true,
143
+ forcePunch: false,
144
+ boundary: true,
145
+ reason: "plan: zoom=never → full view",
146
+ };
147
+ if (b.intent === "always")
148
+ return {
149
+ roi,
150
+ scale,
151
+ forceFull: false,
152
+ forcePunch: true,
153
+ boundary: true,
154
+ reason: roi
155
+ ? `plan: zoom=always → ${scale.toFixed(2)}× (framing from ROI)`
156
+ : "plan: zoom=always but no bbox to frame → full view",
157
+ };
158
+
159
+ // a scroll is a pan beat: content moves, the frame stays full-view.
160
+ if (b.kind === "scroll")
161
+ return {
162
+ roi: undefined,
163
+ scale: rest,
164
+ forceFull: true,
165
+ forcePunch: false,
166
+ boundary: true,
167
+ reason: "scroll — full view (content pans)",
168
+ };
169
+
170
+ // Escape DISMISSES: its visual change is something VANISHING, so the
171
+ // frame-diff effectBox is the vacated region — framing it would punch into
172
+ // blank space. The editorial payoff of a dismissal is the restored page.
173
+ if (b.kind === "press" && b.keys && /(^|\+)esc(ape)?$/i.test(b.keys.trim()))
174
+ return {
175
+ roi: undefined,
176
+ scale: rest,
177
+ forceFull: true,
178
+ forcePunch: false,
179
+ boundary: true,
180
+ reason: "Escape (dismissal) — full view",
181
+ };
182
+
183
+ // global repaint (nav / restyle): the payoff is the whole page → pull out.
184
+ // Needs the frame-diff annotation; without it this branch is skipped (the
185
+ // director can't tell nav from popover on a bbox alone — say so).
186
+ if (b.changeCoverage != null && b.changeCoverage >= cam.pullOutCoverage)
187
+ return {
188
+ roi,
189
+ scale,
190
+ forceFull: true,
191
+ forcePunch: false,
192
+ boundary: true,
193
+ reason: `changeCoverage ${b.changeCoverage.toFixed(2)} ≥ ${cam.pullOutCoverage} (global repaint) → full view`,
194
+ };
195
+
196
+ // the opening beat orients: open on the whole app (Q3 — falls out of "the
197
+ // camera opens full", no zoomFirst flag). A cold-open uses zoom=always.
198
+ if (i === 0)
199
+ return {
200
+ roi,
201
+ scale,
202
+ forceFull: true,
203
+ forcePunch: false,
204
+ boundary: true,
205
+ reason: "opening beat — full view (orienting)",
206
+ };
207
+
208
+ // nothing locatable to frame (a bare Escape/Enter with no reveal).
209
+ if (!roi)
210
+ return {
211
+ roi,
212
+ scale,
213
+ forceFull: true,
214
+ forcePunch: false,
215
+ boundary: true,
216
+ reason: "no bbox to frame → full view",
217
+ };
218
+
219
+ // not tight enough to earn a distinct frame (a big element fills it already).
220
+ if (scale < cam.minZoomScale)
221
+ return {
222
+ roi,
223
+ scale,
224
+ forceFull: true,
225
+ forcePunch: false,
226
+ boundary: gapBreak,
227
+ reason: `ROI fills the frame already (fit ${scale.toFixed(2)}× < ${cam.minZoomScale}×) — full view`,
228
+ };
229
+
230
+ // an ordinary punchable beat — eligible to coalesce with its neighbours.
231
+ return {
232
+ roi,
233
+ scale,
234
+ forceFull: false,
235
+ forcePunch: false,
236
+ boundary: gapBreak,
237
+ reason: `punch ${scale.toFixed(2)}× (ROI-fit)`,
238
+ };
239
+ });
240
+
241
+ // --- phase 2: coalesce adjacent punchable beats into shared-frame clusters --
242
+ const segs: Seg[] = [];
243
+ for (let i = 0; i < nodes.length; i++) {
244
+ const n = nodes[i]!;
245
+ if (n.forceFull) {
246
+ segs.push({ idx: [i], kind: "rest", scale: rest, center: restC });
247
+ continue;
248
+ }
249
+ const last = segs[segs.length - 1];
250
+ const lastLastIdx = last ? last.idx[last.idx.length - 1]! : -1;
251
+ const canExtend =
252
+ !!last &&
253
+ last.kind === "punch" &&
254
+ !!last.roi &&
255
+ !n.forcePunch &&
256
+ !nodes[lastLastIdx]!.forcePunch &&
257
+ !n.boundary &&
258
+ !!n.roi &&
259
+ dist(centerOf(n.roi), centerOf(last.roi)) < cam.travelThreshold * video.w &&
260
+ fit(union(last.roi, n.roi)) >= cam.minZoomScale; // union must stay tight enough
261
+
262
+ if (canExtend && last && last.roi && n.roi) {
263
+ const roi = union(last.roi, n.roi);
264
+ last.idx.push(i);
265
+ last.roi = roi;
266
+ last.scale = fit(roi);
267
+ last.center = centerOf(roi);
268
+ } else {
269
+ segs.push({
270
+ idx: [i],
271
+ kind: "punch",
272
+ roi: n.roi,
273
+ scale: n.scale,
274
+ center: n.roi ? centerOf(n.roi) : restC,
275
+ });
276
+ }
277
+ }
278
+
279
+ // --- phase 3: min-hold — extend into the gap before the next break; a punch
280
+ // that still can't clear minHoldMs is a flinch → drop it to full view. NEVER
281
+ // merges across a break (order: hard break → coalesce → min-hold).
282
+ segs.forEach((s, si) => {
283
+ if (s.kind !== "punch") return;
284
+ const firstT = beats[s.idx[0]!]!.tMs;
285
+ const next = segs[si + 1];
286
+ const heldUntil = next ? beats[next.idx[0]!]!.tMs : endMs;
287
+ if (heldUntil - firstT < cam.minHoldMs) {
288
+ s.kind = "rest";
289
+ s.scale = rest;
290
+ s.center = restC;
291
+ s.dropped = true;
292
+ }
293
+ });
294
+
295
+ // --- assemble per-beat framing --------------------------------------------
296
+ const framings: Framing[] = new Array(beats.length);
297
+ for (const s of segs) {
298
+ if (s.kind === "rest") {
299
+ for (const i of s.idx)
300
+ framings[i] = {
301
+ enabled: false,
302
+ scale: rest,
303
+ center: restC,
304
+ reason: s.dropped
305
+ ? `punch dropped — held < ${cam.minHoldMs}ms (would flinch) → full view`
306
+ : nodes[i]!.reason,
307
+ };
308
+ continue;
309
+ }
310
+ const N = s.idx.length;
311
+ const tag = N > 1 ? `cluster[${clusterLabel(beats, s.idx)}]` : null;
312
+ s.idx.forEach((i, k) => {
313
+ framings[i] = {
314
+ enabled: true,
315
+ scale: s.scale,
316
+ center: s.center,
317
+ reason: tag
318
+ ? `${tag} ${k + 1}/${N} · hold shared framing (union of ${N}) → ${s.scale.toFixed(2)}×`
319
+ : nodes[i]!.reason,
320
+ };
321
+ });
322
+ }
323
+ return framings;
324
+ }
package/src/ffmpeg.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // with an install hint only when neither exists.
7
7
 
8
8
  import { spawnSync } from "node:child_process";
9
+ import { chmod, stat } from "node:fs/promises";
9
10
 
10
11
  let cachedFfmpeg: string | undefined;
11
12
  let cachedFfprobe: string | undefined;
@@ -27,6 +28,30 @@ async function installerPath(pkg: string): Promise<string | null> {
27
28
  }
28
29
  }
29
30
 
31
+ /** Some pnpm installs leave the platform packages' media binaries without an
32
+ * executable bit. Repair the resolved target at the point of use as well as
33
+ * in the repo postinstall: published consumers do not run this monorepo's
34
+ * root lifecycle script. Windows does not use POSIX execute bits. */
35
+ async function ensureExecutable(path: string | null): Promise<void> {
36
+ if (!path || process.platform === "win32") return;
37
+ try {
38
+ const info = await stat(path);
39
+ if ((info.mode & 0o111) === 0) await chmod(path, info.mode | 0o111);
40
+ } catch {
41
+ // Best effort. The normal spawn below still returns the useful failure.
42
+ }
43
+ }
44
+
45
+ /** Revideo resolves its own bundled ffprobe rather than calling
46
+ * resolveFfprobe(), so repair both installer targets before invoking it. */
47
+ export async function repairBundledMediaPermissions(): Promise<void> {
48
+ const paths = await Promise.all([
49
+ installerPath("@ffmpeg-installer/ffmpeg"),
50
+ installerPath("@ffprobe-installer/ffprobe"),
51
+ ]);
52
+ await Promise.all(paths.map(ensureExecutable));
53
+ }
54
+
30
55
  export async function resolveFfmpeg(): Promise<string> {
31
56
  if (cachedFfmpeg) return cachedFfmpeg;
32
57
  if (runsOk("ffmpeg")) {
@@ -34,6 +59,7 @@ export async function resolveFfmpeg(): Promise<string> {
34
59
  return cachedFfmpeg;
35
60
  }
36
61
  const p = await installerPath("@ffmpeg-installer/ffmpeg");
62
+ await ensureExecutable(p);
37
63
  if (p && runsOk(p)) {
38
64
  cachedFfmpeg = p;
39
65
  return p;
@@ -50,6 +76,7 @@ export async function resolveFfprobe(): Promise<string> {
50
76
  return cachedFfprobe;
51
77
  }
52
78
  const p = await installerPath("@ffprobe-installer/ffprobe");
79
+ await ensureExecutable(p);
53
80
  if (p && runsOk(p)) {
54
81
  cachedFfprobe = p;
55
82
  return p;
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // frames -> polished mp4 + an editable revideo composition.
3
3
  //
4
4
  // const comp = planComposition(captureLog) // default editable plan
5
- // await renderTake({ composition: comp, videoPath, outPath })
5
+ // await renderTake({ composition: comp, videoPath, outPath, chromePath })
6
6
  //
7
7
  // Edit `comp` (zoom decisions, framing, cursor) and re-render — the
8
8
  // composition is the editable source of truth.
@@ -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,