@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/dist/index.d.ts +154 -37
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +544 -218
- package/dist/index.js.map +1 -1
- package/package.json +8 -6
- package/src/camera.ts +324 -0
- package/src/ffmpeg.ts +27 -0
- package/src/index.ts +2 -1
- package/src/math.ts +158 -126
- package/src/plan.ts +102 -66
- package/src/presets.ts +10 -5
- package/src/render.ts +214 -80
- package/src/scene/scene.tsx +10 -17
- package/src/scene/tsconfig.json +14 -0
- package/src/types.ts +86 -23
- package/src/validate.ts +18 -0
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,
|