@open-take/compositor 0.1.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/math.ts ADDED
@@ -0,0 +1,459 @@
1
+ // Pure, isomorphic math — imported by both the node-side planner and the
2
+ // revideo scene (compiled fresh by vite at render time). No node/browser
3
+ // APIs in here.
4
+
5
+ import type { BBox, Pt, TakeComposition } from "./types";
6
+
7
+ // smootherstep 6t^5-15t^4+10t^3
8
+ export function smoother(t: number): number {
9
+ t = Math.max(0, Math.min(1, t));
10
+ return t * t * t * (t * (t * 6 - 15) + 10);
11
+ }
12
+
13
+ // Cubic-bezier easing y(x) with endpoints (0,0),(1,1) and control points
14
+ // (x1,y1),(x2,y2) — the same model CSS easing uses. Solves x(s)=x by
15
+ // bisection (cheap, monotone) then returns y(s). Lets the travel cursor use a
16
+ // decelerate-biased curve (long, gentle settle) instead of symmetric easing.
17
+ export function cubicBezier(x1: number, y1: number, x2: number, y2: number): (x: number) => number {
18
+ const bx = (s: number) => {
19
+ const u = 1 - s;
20
+ return 3 * u * u * s * x1 + 3 * u * s * s * x2 + s * s * s;
21
+ };
22
+ const by = (s: number) => {
23
+ const u = 1 - s;
24
+ return 3 * u * u * s * y1 + 3 * u * s * s * y2 + s * s * s;
25
+ };
26
+ return (x: number) => {
27
+ if (x <= 0) return 0;
28
+ if (x >= 1) return 1;
29
+ let lo = 0,
30
+ hi = 1,
31
+ s = x;
32
+ for (let i = 0; i < 24; i++) {
33
+ const xt = bx(s);
34
+ if (Math.abs(xt - x) < 1e-4) break;
35
+ if (xt < x) lo = s;
36
+ else hi = s;
37
+ s = (lo + hi) / 2;
38
+ }
39
+ return by(s);
40
+ };
41
+ }
42
+
43
+ // Spring-shaped easing y(p) over [0,1]: the unit step response of a damped
44
+ // spring, time-normalised so it rises (and, for bounce>0, slightly overshoots)
45
+ // then settles within the segment. `bounce` ∈ [0, ~0.6): 0 = critically damped
46
+ // (no overshoot — a soft, physical ease-out); higher = more overshoot/snap. The
47
+ // "silky" landing comes from a touch of overshoot (a near-critically-damped
48
+ // zoom spring, bounce ~0.06; a snappier cursor ~0.13).
49
+ //
50
+ // Under time-normalisation the curve depends ONLY on the damping ratio ζ=1−bounce
51
+ // (the natural frequency cancels), so a single `bounce` knob is the whole shape —
52
+ // the segment DURATION stays whatever the keyframes say (zoomInMs/zoomOutMs).
53
+ export function springEase(bounce: number): (p: number) => number {
54
+ const zeta = Math.max(0.4, Math.min(1, 1 - bounce));
55
+ const Ts = -Math.log(1e-3) / zeta; // settling time to the 0.1% band (ω0 = 1)
56
+ return (p: number) => {
57
+ if (p <= 0) return 0;
58
+ if (p >= 1) return 1;
59
+ const t = p * Ts;
60
+ if (zeta >= 1) return 1 - Math.exp(-t) * (1 + t); // critically damped
61
+ const wd = Math.sqrt(1 - zeta * zeta); // damped natural frequency (ω0 = 1)
62
+ return 1 - Math.exp(-zeta * t) * (Math.cos(wd * t) + (zeta / wd) * Math.sin(wd * t));
63
+ };
64
+ }
65
+
66
+ // The stage (zoom + pan) easing, selected from the cursor config. The single
67
+ // source the revideo scene (scene.tsx) consumes — any other renderer must use
68
+ // it identically so renderers can never drift. Precedence:
69
+ // spring (zoomSpring) → cubic-bezier (zoomEase) → smootherstep.
70
+ export function stageEasing(cursor: TakeComposition["cursor"]): (u: number) => number {
71
+ if (cursor.zoomSpring != null) return springEase(cursor.zoomSpring);
72
+ if (cursor.zoomEase) return cubicBezier(...cursor.zoomEase);
73
+ return smoother;
74
+ }
75
+
76
+ // Easing for the CENTRE pan. Deliberately NEVER the spring: an overshooting
77
+ // spring on a pan makes the camera wobble past its target and back (and re-
78
+ // accelerates the zoom-out recenter = a stutter). Overshoot is only wanted on
79
+ // the scale zoom-IN, so centre stays on the monotone bezier/smoother. For a
80
+ // non-spring composition this equals stageEasing, so the default is unchanged.
81
+ export function panEasing(cursor: TakeComposition["cursor"]): (u: number) => number {
82
+ if (cursor.zoomEase) return cubicBezier(...cursor.zoomEase);
83
+ return smoother;
84
+ }
85
+
86
+ // Backdrop gradient endpoints in TOP-LEFT origin (0..oW, 0..oH), shared by the
87
+ // preview canvas and the revideo scene so the backdrop can't drift. `angle` is
88
+ // CSS-like degrees (0 = upward); absent ⇒ the legacy (0,0)→(oW,oH) diagonal, so
89
+ // existing compositions render pixel-identically. (Scene maps to centred coords
90
+ // by subtracting oW/2, oH/2.)
91
+ export function gradientEndpoints(
92
+ angleDeg: number | undefined,
93
+ oW: number,
94
+ oH: number,
95
+ ): { x0: number; y0: number; x1: number; y1: number } {
96
+ if (angleDeg == null) return { x0: 0, y0: 0, x1: oW, y1: oH };
97
+ const th = (angleDeg * Math.PI) / 180;
98
+ const dx = Math.sin(th);
99
+ const dy = -Math.cos(th);
100
+ const L = Math.abs(oW * dx) + Math.abs(oH * dy); // CSS gradient line length
101
+ const cx = oW / 2;
102
+ const cy = oH / 2;
103
+ return {
104
+ x0: cx - (dx * L) / 2,
105
+ y0: cy - (dy * L) / 2,
106
+ x1: cx + (dx * L) / 2,
107
+ y1: cy + (dy * L) / 2,
108
+ };
109
+ }
110
+
111
+ type KF<T> = [number, T];
112
+
113
+ // `easeDown` (optional) eases segments where the value FALLS (v1<v0) differently
114
+ // from rising ones — used so the scale springs on zoom-IN but settles smoothly
115
+ // (bezier, no overshoot/abruptness) on zoom-OUT. Omit it ⇒ one easing for all
116
+ // (the default behaviour, unchanged).
117
+ export function keyvalN(
118
+ t: number,
119
+ kfs: KF<number>[],
120
+ ease: (u: number) => number = smoother,
121
+ easeDown?: (u: number) => number,
122
+ ): number {
123
+ if (t <= kfs[0]![0]) return kfs[0]![1];
124
+ if (t >= kfs[kfs.length - 1]![0]) return kfs[kfs.length - 1]![1];
125
+ for (let i = 0; i < kfs.length - 1; i++) {
126
+ const [t0, v0] = kfs[i]!;
127
+ const [t1, v1] = kfs[i + 1]!;
128
+ if (t0 <= t && t <= t1) {
129
+ const e = easeDown && v1 < v0 ? easeDown : ease;
130
+ return v0 + (v1 - v0) * e((t - t0) / (t1 - t0));
131
+ }
132
+ }
133
+ return kfs[kfs.length - 1]![1];
134
+ }
135
+
136
+ export function keyvalP(t: number, kfs: KF<Pt>[], ease: (u: number) => number = smoother): Pt {
137
+ if (t <= kfs[0]![0]) return kfs[0]![1];
138
+ if (t >= kfs[kfs.length - 1]![0]) return kfs[kfs.length - 1]![1];
139
+ for (let i = 0; i < kfs.length - 1; i++) {
140
+ const [t0, v0] = kfs[i]!;
141
+ const [t1, v1] = kfs[i + 1]!;
142
+ if (t0 <= t && t <= t1) {
143
+ const p = ease((t - t0) / (t1 - t0));
144
+ return { x: v0.x + (v1.x - v0.x) * p, y: v0.y + (v1.y - v0.y) * p };
145
+ }
146
+ }
147
+ return kfs[kfs.length - 1]![1];
148
+ }
149
+
150
+ // --- bbox-fit zoom (Finding 1) -----------------------------------------
151
+
152
+ /**
153
+ * Scale (absolute, video-px → output-px) that fits `bbox` into `fillFrac`
154
+ * of the output frame, capped at `maxScale` and floored at `restScale`.
155
+ * Wide/long elements naturally get a gentler scale because the limiting
156
+ * dimension dominates min().
157
+ */
158
+ export function bboxFitScale(
159
+ bbox: BBox,
160
+ outW: number,
161
+ outH: number,
162
+ fillFrac: number,
163
+ maxScale: number,
164
+ restScale: number,
165
+ ): number {
166
+ const fitW = (outW * fillFrac) / Math.max(1, bbox.w);
167
+ const fitH = (outH * fillFrac) / Math.max(1, bbox.h);
168
+ return Math.min(maxScale, Math.max(restScale, Math.min(fitW, fitH)));
169
+ }
170
+
171
+ /** Stage scale at rest: video inset into the frame (leaves backdrop margin). */
172
+ export function restStageScale(
173
+ videoW: number,
174
+ videoH: number,
175
+ outW: number,
176
+ outH: number,
177
+ insetFrac: number,
178
+ ): number {
179
+ return insetFrac * Math.min(outW / videoW, outH / videoH);
180
+ }
181
+
182
+ /**
183
+ * Clamp a desired video-px center so the scaled video still covers the output
184
+ * frame (the viewport crop stays inside the source video) when zoomed in. When
185
+ * the content does not cover an axis (zoomed out / inset), centre that axis.
186
+ *
187
+ * In the composition-camera model the whole composition
188
+ * (backdrop + inset screen) is zoomed by one camera; this keeps the camera crop
189
+ * within the screen so a zoom fills the frame (no backdrop margin) without ever
190
+ * panning past the recording's edge.
191
+ */
192
+ export function clampCenter(
193
+ center: Pt,
194
+ scale: number,
195
+ videoW: number,
196
+ videoH: number,
197
+ outW: number,
198
+ outH: number,
199
+ ): Pt {
200
+ const halfW = outW / (2 * scale);
201
+ const halfH = outH / (2 * scale);
202
+ const cx =
203
+ videoW * scale >= outW ? Math.min(Math.max(center.x, halfW), videoW - halfW) : videoW / 2;
204
+ const cy =
205
+ videoH * scale >= outH ? Math.min(Math.max(center.y, halfH), videoH - halfH) : videoH / 2;
206
+ return { x: cx, y: cy };
207
+ }
208
+
209
+ // --- stage (zoom/pan) keyframes from the composition -------------------
210
+
211
+ export type StageKeyframes = {
212
+ z: KF<number>[]; // absolute stage scale over time (seconds)
213
+ c: KF<Pt>[]; // raw video-px center over time (clamp at eval)
214
+ T: number; // total duration (s)
215
+ };
216
+
217
+ export function buildStageKeyframes(comp: TakeComposition): StageKeyframes {
218
+ const { videoWidth: vW, videoHeight: vH } = comp.source;
219
+ const { width: oW, height: oH } = comp.output;
220
+ const rest = restStageScale(vW, vH, oW, oH, comp.framing.insetFrac);
221
+ const restC: Pt = { x: vW / 2, y: vH / 2 };
222
+ const HOLD = comp.cursor.holdMs / 1000;
223
+ const ZOUT = comp.cursor.zoomOutMs / 1000;
224
+
225
+ // Framing anchors over time. A zoom-enabled beat ramps to its target; a
226
+ // scroll (content pans) and a zoom-less press (Escape/Enter whose effect is
227
+ // global) ramp back to REST so the frame is full-view through them — without
228
+ // these, a prior zoom would persist across the scroll/keypress. Other
229
+ // disabled beats (a "never" click) keep holding the prior framing, unchanged.
230
+ type Anchor = {
231
+ tMs: number;
232
+ durationMs: number;
233
+ inAtMs: number;
234
+ scale: number;
235
+ center: Pt;
236
+ glide?: Pt;
237
+ };
238
+ const anchors: Anchor[] = [];
239
+ for (const e of comp.events) {
240
+ const base = { tMs: e.tMs, durationMs: e.durationMs ?? 0, inAtMs: e.zoom.inAtMs };
241
+ if (e.kind === "scroll" || (e.kind === "press" && !e.zoom.enabled)) {
242
+ anchors.push({ ...base, scale: rest, center: restC });
243
+ } else if (e.zoom.enabled) {
244
+ anchors.push({ ...base, scale: e.zoom.scale, center: e.zoom.center, glide: e.zoom.glide });
245
+ }
246
+ }
247
+
248
+ // Scale and centre are evaluated independently (keyvalN / keyvalP), so their
249
+ // keyframe TIMES need not line up — `pushC` adds a centre-only keyframe.
250
+ const zf: { t: number; s: number }[] = [{ t: 0, s: rest }];
251
+ const cf: { t: number; c: Pt }[] = [{ t: 0, c: restC }];
252
+ const push = (t: number, s: number, c: Pt) => {
253
+ zf.push({ t: Math.max(t, zf[zf.length - 1]!.t + 1e-3), s });
254
+ cf.push({ t: Math.max(t, cf[cf.length - 1]!.t + 1e-3), c });
255
+ };
256
+ const pushC = (t: number, c: Pt) => {
257
+ cf.push({ t: Math.max(t, cf[cf.length - 1]!.t + 1e-3), c });
258
+ };
259
+ // Invert the zoom-OUT easing to time the centre recenter (the centre reaches
260
+ // rest exactly when the scale re-covers the frame — else the tightening centre-
261
+ // clamp catches the still-panning centre and re-accelerates it = the "two-stage"
262
+ // zoom-out stutter, commit b005cbe). The zoom-OUT scale uses panEasing (the
263
+ // monotone bezier — scale springs only on zoom-IN, settles smoothly on the way
264
+ // out), so invert THAT, by bisection.
265
+ const ze = panEasing(comp.cursor);
266
+ const invEase = (target: number) => {
267
+ let lo = 0;
268
+ let hi = 1;
269
+ for (let i = 0; i < 30; i++) {
270
+ const m = (lo + hi) / 2;
271
+ if (ze(m) < target) lo = m;
272
+ else hi = m;
273
+ }
274
+ return (lo + hi) / 2;
275
+ };
276
+ // Scale at which the video re-covers the frame on both axes; below it the
277
+ // centre is forced to video-centre (clampCenter), so a recenter must FINISH by
278
+ // here or the tightening clamp "catches" the still-panning centre.
279
+ const fillThreshold = Math.max(oW / vW, oH / vH);
280
+
281
+ let cur = { s: rest, c: restC };
282
+ anchors.forEach((e, i) => {
283
+ const rampStart = e.inAtMs / 1000;
284
+ const clickT = e.tMs / 1000;
285
+ // the action plays out (typing/drawing/scrolling) for durationMs after tMs
286
+ // — hold the target framing through it (a point click has duration 0).
287
+ const actionEnd = (e.tMs + e.durationMs) / 1000;
288
+ const next = anchors[i + 1];
289
+ const holdEndT = next ? next.inAtMs / 1000 : actionEnd + HOLD;
290
+ // glide: drift the held centre across the hold window (velocity px/s ·
291
+ // holdSeconds), so a held zoom slowly pans instead of sitting dead-static.
292
+ // (Eased with the stage easing like any centre segment; clampCenter keeps it
293
+ // in-bounds at read time. invEase below stays on the monotone bezier.)
294
+ let holdC = e.center;
295
+ if (e.glide && (e.glide.x !== 0 || e.glide.y !== 0)) {
296
+ const holdDur = Math.max(0, holdEndT - clickT);
297
+ holdC = { x: e.center.x + e.glide.x * holdDur, y: e.center.y + e.glide.y * holdDur };
298
+ }
299
+ push(rampStart, cur.s, cur.c); // hold current until ramp begins
300
+ // Zoom-OUT across the fill-threshold INTO a beat (e.g. a rest/full-view beat):
301
+ // land the centre on its target AT the crossing so the tightening centre-clamp
302
+ // doesn't catch the still-panning centre and re-accelerate it. This is the same
303
+ // two-stage stutter b005cbe killed for the FINAL zoom-out — here for the
304
+ // between-beats ones (which went through this ramp and never got the fix).
305
+ if (
306
+ cur.s > fillThreshold &&
307
+ e.scale < fillThreshold &&
308
+ fillThreshold > rest &&
309
+ clickT > rampStart &&
310
+ Math.hypot(e.center.x - cur.c.x, e.center.y - cur.c.y) > 1
311
+ ) {
312
+ const uCross = invEase((fillThreshold - cur.s) / (e.scale - cur.s));
313
+ pushC(rampStart + uCross * (clickT - rampStart), e.center);
314
+ }
315
+ push(clickT, e.scale, e.center); // ramp to target by the action
316
+ cur = { s: e.scale, c: holdC };
317
+ if (next) {
318
+ // hold (or glide) the target until the next anchor begins ramping
319
+ push(holdEndT, cur.s, cur.c);
320
+ } else {
321
+ const holdEnd = holdEndT;
322
+ push(holdEnd, cur.s, cur.c);
323
+ // Final zoom-out. Land the CENTRE on rest at the fill-threshold crossing
324
+ // (the scale keeps its single smooth segment) so the centre clamp — which
325
+ // tightens to a point as the video re-covers the frame — never catches the
326
+ // still-panning centre and re-accelerates it (a two-stage stutter). Only
327
+ // when the zoom actually overfills AND sits off-centre.
328
+ const offset = Math.hypot(cur.c.x - restC.x, cur.c.y - restC.y) > 1;
329
+ if (offset && cur.s > fillThreshold && fillThreshold > rest) {
330
+ const uCross = invEase((fillThreshold - cur.s) / (rest - cur.s));
331
+ pushC(holdEnd + uCross * ZOUT, restC);
332
+ }
333
+ push(holdEnd + ZOUT, rest, restC); // zoom back out
334
+ cur = { s: rest, c: restC };
335
+ }
336
+ });
337
+
338
+ const lastT = Math.max(zf[zf.length - 1]!.t, cf[cf.length - 1]!.t);
339
+ const T = Math.max(comp.durationMs / 1000, lastT) + 0.3;
340
+ push(T, rest, restC);
341
+
342
+ return { z: zf.map((f) => [f.t, f.s]), c: cf.map((f) => [f.t, f.c]), T };
343
+ }
344
+
345
+ // --- cursor path (eased, with gentle arc) ------------------------------
346
+
347
+ // A `drag` leg carries the polyline the cursor follows with the button held
348
+ // (no perpendicular arc — it traces the real stroke). A normal travel leg has
349
+ // no path and gets the gentle arc.
350
+ type Leg = {
351
+ t0: number;
352
+ t1: number;
353
+ a: Pt;
354
+ b: Pt;
355
+ drag?: boolean;
356
+ path?: Pt[];
357
+ ease?: "linear" | "smooth";
358
+ };
359
+
360
+ export function buildLegs(comp: TakeComposition): Leg[] {
361
+ const legs: Leg[] = [];
362
+ let cur: Pt = comp.start;
363
+ // Distance-aware travel: hold a roughly constant on-screen speed (premium
364
+ // feel) instead of a fixed duration (which makes short moves slow + long
365
+ // moves fast). Falls back to the fixed travelMs when speed is unset/0.
366
+ const { travelWidthsPerSec, travelMinMs, travelMaxMs, travelMs } = comp.cursor;
367
+ const speedPxPerMs = ((travelWidthsPerSec || 0) * comp.source.videoWidth) / 1000;
368
+ const travelDur = (a: Pt, b: Pt): number => {
369
+ if (speedPxPerMs <= 0) return travelMs / 1000;
370
+ const dist = Math.hypot(b.x - a.x, b.y - a.y);
371
+ return Math.min(travelMaxMs, Math.max(travelMinMs, dist / speedPxPerMs)) / 1000;
372
+ };
373
+ for (const e of comp.events) {
374
+ // scroll/press are not pointer-driven — the cursor holds where it was
375
+ // (the content pans / the keyboard acts). No travel leg; `cur` is untouched,
376
+ // so the between-legs parking logic keeps the cursor at its last anchor.
377
+ if (e.kind === "scroll" || e.kind === "press") continue;
378
+ const arrive = e.tMs / 1000;
379
+ // Start travelDur before arrival, but never before the previous leg ended
380
+ // (a long glide into a quick succession would otherwise overlap it — then
381
+ // the move just runs in the available window, a touch faster than target).
382
+ const prevEnd = legs.length ? legs[legs.length - 1]!.t1 : 0;
383
+ const t0 = Math.max(arrive - travelDur(cur, e.point), prevEnd, 0);
384
+ legs.push({ t0, t1: arrive, a: cur, b: e.point }); // travel to anchor
385
+ cur = e.point;
386
+ if (e.kind === "drag" && e.to) {
387
+ // Delay the stroke by dragLagMs so the cursor rides the captured ink front
388
+ // (the ink trails the pen by the capture-pipeline latency). The cursor
389
+ // holds at the start point during the gap [arrive, arrive+lag], then traces
390
+ // — matching when the ink actually appears on screen.
391
+ const lag = comp.cursor.dragLagMs / 1000;
392
+ const start = arrive + lag;
393
+ const dEnd = (e.tMs + (e.durationMs ?? 0)) / 1000 + lag;
394
+ const path = e.path && e.path.length >= 2 ? e.path : [e.point, e.to];
395
+ if (dEnd > start)
396
+ legs.push({ t0: start, t1: dEnd, a: e.point, b: e.to, drag: true, path, ease: e.ease });
397
+ cur = e.to;
398
+ }
399
+ }
400
+ return legs;
401
+ }
402
+
403
+ /** Point a fraction `u` (0..1) along a polyline, parameterised by arc length. */
404
+ function alongPath(path: Pt[], u: number): Pt {
405
+ if (path.length === 1) return path[0]!;
406
+ const seg: number[] = [];
407
+ let total = 0;
408
+ for (let i = 0; i < path.length - 1; i++) {
409
+ const d = Math.hypot(path[i + 1]!.x - path[i]!.x, path[i + 1]!.y - path[i]!.y);
410
+ seg.push(d);
411
+ total += d;
412
+ }
413
+ if (total === 0) return path[0]!;
414
+ let target = u * total;
415
+ for (let i = 0; i < seg.length; i++) {
416
+ if (target <= seg[i]! || i === seg.length - 1) {
417
+ const f = seg[i]! > 0 ? target / seg[i]! : 0;
418
+ return {
419
+ x: path[i]!.x + (path[i + 1]!.x - path[i]!.x) * f,
420
+ y: path[i]!.y + (path[i + 1]!.y - path[i]!.y) * f,
421
+ };
422
+ }
423
+ target -= seg[i]!;
424
+ }
425
+ return path[path.length - 1]!;
426
+ }
427
+
428
+ export function cursorPos(t: number, legs: Leg[], comp: TakeComposition): Pt {
429
+ for (const lg of legs) {
430
+ if (lg.t0 <= t && t <= lg.t1) {
431
+ const raw = Math.max(0, Math.min(1, (t - lg.t0) / (lg.t1 - lg.t0)));
432
+ // A drag replays the captured stroke's pacing so the cursor stays locked
433
+ // to the ink: "smooth" (accel-in / decel-out — a natural hand-draw) or
434
+ // "linear" (constant speed). The capture bakes the SAME curve into the ink
435
+ // (cdp-capture.ts) and records it on the event; absent ⇒ linear (legacy).
436
+ // (Held-button moves are fire-and-forget there, so the slow eased ends —
437
+ // sub-pixel, no paint — no longer stall the stroke; cursor and ink are
438
+ // both ~stationary at the ends, so they stay locked.)
439
+ if (lg.drag && lg.path) return alongPath(lg.path, lg.ease === "smooth" ? smoother(raw) : raw);
440
+ const e = comp.cursor.travelEase;
441
+ const p = e ? cubicBezier(e[0], e[1], e[2], e[3])(raw) : smoother(raw);
442
+ const base = { x: lg.a.x + (lg.b.x - lg.a.x) * p, y: lg.a.y + (lg.b.y - lg.a.y) * p };
443
+ const dx = lg.b.x - lg.a.x,
444
+ dy = lg.b.y - lg.a.y;
445
+ const L = Math.hypot(dx, dy) || 1;
446
+ const arc = Math.min(comp.cursor.arcFrac * L, comp.cursor.arcMax) * Math.sin(Math.PI * p);
447
+ return { x: base.x + (-dy / L) * arc, y: base.y + (dx / L) * arc };
448
+ }
449
+ }
450
+ if (legs.length === 0 || t < legs[0]!.t0) return comp.start;
451
+ for (let i = 0; i < legs.length - 1; i++)
452
+ if (legs[i]!.t1 < t && t < legs[i + 1]!.t0) return legs[i]!.b;
453
+ return legs[legs.length - 1]!.b;
454
+ }
455
+
456
+ /** True while a drag is mid-stroke (button held) — for the pressed cursor. */
457
+ export function isDragging(t: number, legs: Leg[]): boolean {
458
+ return legs.some((lg) => lg.drag === true && lg.t0 <= t && t <= lg.t1);
459
+ }
package/src/plan.ts ADDED
@@ -0,0 +1,157 @@
1
+ // planComposition: capture event log -> default editable TakeComposition.
2
+ // The selective bbox-fit zoom heuristic lives here; every decision it
3
+ // makes is written into the composition so a human/agent can tune it.
4
+
5
+ import { bboxFitScale, restStageScale } from "./math";
6
+ import {
7
+ type BBox,
8
+ type CaptureLog,
9
+ type CompEvent,
10
+ type CursorConfig,
11
+ DEFAULT_CURSOR,
12
+ DEFAULT_FRAMING,
13
+ DEFAULT_MOTION_BLUR,
14
+ type FramingConfig,
15
+ type Pt,
16
+ type TakeComposition,
17
+ } from "./types";
18
+
19
+ export type PlanOpts = {
20
+ output?: { width?: number; height?: number; fps?: number };
21
+ framing?: Partial<FramingConfig>;
22
+ cursor?: Partial<CursorConfig>;
23
+ /** element should fill this fraction of the frame when zoomed (default 0.55) */
24
+ fillFrac?: number;
25
+ /** hard cap on zoom (default 1.5 — a gentle workhorse; lower than
26
+ * the old 2.0 reads more premium. Small targets still zoom, just not as hard;
27
+ * raise per-beat in the composition when a tiny element needs it.) */
28
+ maxScale?: number;
29
+ /** require fit-scale to exceed rest*this to bother zooming (default 1.3) */
30
+ zoomRatio?: number;
31
+ /** never zoom the first (orienting) action by default (Finding 1) */
32
+ zoomFirst?: boolean;
33
+ };
34
+
35
+ export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeComposition {
36
+ const vW = log.video.width,
37
+ vH = log.video.height;
38
+ const oW = opts.output?.width ?? vW;
39
+ const oH = opts.output?.height ?? vH;
40
+ const fps = opts.output?.fps ?? 30;
41
+ const fillFrac = opts.fillFrac ?? 0.55;
42
+ const maxScale = opts.maxScale ?? 1.5;
43
+ const zoomRatio = opts.zoomRatio ?? 1.3;
44
+ const zoomFirst = opts.zoomFirst ?? false;
45
+
46
+ const framing: FramingConfig = { ...DEFAULT_FRAMING, ...opts.framing };
47
+ const cursor: CursorConfig = { ...DEFAULT_CURSOR, ...opts.cursor };
48
+
49
+ // viewport CSS px -> video px
50
+ const sx = vW / log.viewport.w;
51
+ const sy = vH / log.viewport.h;
52
+ const mapPt = (p: Pt): Pt => ({ x: p.x * sx, y: p.y * sy });
53
+ const mapBox = (b: BBox): BBox => ({ x: b.x * sx, y: b.y * sy, w: b.w * sx, h: b.h * sy });
54
+
55
+ const rest = restStageScale(vW, vH, oW, oH, framing.insetFrac);
56
+
57
+ // Axis-aligned bbox of a polyline (video-px). Used so a drag zooms to fit
58
+ // the WHOLE stroke (a path, not a point): big cross-canvas drags fit ≈ rest
59
+ // → no zoom (correct, global); small localised drags zoom in.
60
+ const pathBBox = (pts: Pt[]): BBox => {
61
+ const xs = pts.map((p) => p.x),
62
+ ys = pts.map((p) => p.y);
63
+ const x = Math.min(...xs),
64
+ y = Math.min(...ys);
65
+ return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
66
+ };
67
+
68
+ const events: CompEvent[] = log.events.map((c, i) => {
69
+ const kind = c.kind ?? "click";
70
+ const point = mapPt({ x: c.x, y: c.y });
71
+ const isFirst = i === 0;
72
+ const intent = c.zoom ?? "auto";
73
+ const durationMs = "durationMs" in c ? c.durationMs : 0;
74
+
75
+ // drag: cursor path + the bbox we fit-zoom is the path's bbox
76
+ const to = kind === "drag" ? mapPt((c as { to: Pt }).to) : undefined;
77
+ const rawPath =
78
+ kind === "drag"
79
+ ? ((c as { path?: Pt[] }).path ?? [{ x: c.x, y: c.y }, (c as { to: Pt }).to])
80
+ : undefined;
81
+ const path = rawPath?.map(mapPt);
82
+ const ease = kind === "drag" ? (c as { ease?: "linear" | "smooth" }).ease : undefined;
83
+
84
+ // The region this action is "about" — what zoom should frame.
85
+ const bbox = kind === "drag" && path ? pathBBox(path) : c.box ? mapBox(c.box) : undefined;
86
+
87
+ let enabled = false;
88
+ let scale = rest;
89
+ let reason: string;
90
+ const center = bbox ? { x: bbox.x + bbox.w / 2, y: bbox.y + bbox.h / 2 } : point;
91
+ const fit = bbox ? bboxFitScale(bbox, oW, oH, fillFrac, maxScale, rest) : maxScale;
92
+
93
+ if (kind === "scroll") {
94
+ // A scroll is a pan beat: the content moves, the frame stays full-view.
95
+ // Zooming would fight the motion, so a scroll never zooms.
96
+ enabled = false;
97
+ reason = "scroll — full view (content pans, no zoom)";
98
+ } else if (intent === "never") {
99
+ enabled = false;
100
+ reason = "plan: zoom=never (global/navigation payoff — keep full view)";
101
+ } else if (intent === "always") {
102
+ enabled = true;
103
+ scale = fit;
104
+ reason = `plan: zoom=always → ${fit.toFixed(2)}x (capped ${maxScale}x)`;
105
+ } else if (!bbox) {
106
+ reason = "no bbox in event log — cannot bbox-fit, so no zoom (avoids framing dead space)";
107
+ } else {
108
+ scale = fit;
109
+ const meaningful = fit > rest * zoomRatio;
110
+ const region = kind === "drag" ? "drag path" : "element";
111
+ if (isFirst && !zoomFirst) {
112
+ enabled = false;
113
+ reason = `first/orienting action — skipped by default (fit ${fit.toFixed(2)}x available)`;
114
+ } else if (!meaningful) {
115
+ enabled = false;
116
+ reason = `${region} fills the frame already (fit ${fit.toFixed(2)}x ≈ rest ${rest.toFixed(2)}x) — gentle/no zoom`;
117
+ } else {
118
+ enabled = true;
119
+ reason = `bbox-fit ${fit.toFixed(2)}x (capped ${maxScale}x), ${region} framed with ${Math.round(fillFrac * 100)}% fill`;
120
+ }
121
+ }
122
+
123
+ return {
124
+ kind,
125
+ tMs: c.tMs,
126
+ point,
127
+ bbox,
128
+ label: c.sel ?? c.note,
129
+ // zoom-in ramp starts zoomInMs before the action (decoupled from cursor
130
+ // travelMs so the zoom can be slower/gentler — a more cinematic feel).
131
+ zoom: { enabled, scale, center, inAtMs: Math.max(0, c.tMs - cursor.zoomInMs), reason },
132
+ ...(durationMs ? { durationMs } : {}),
133
+ ...(kind === "type" ? { text: (c as { text: string }).text } : {}),
134
+ ...(kind === "press" ? { keys: (c as { keys: string }).keys } : {}),
135
+ ...(to ? { to } : {}),
136
+ ...(path ? { path } : {}),
137
+ ...(ease ? { ease } : {}),
138
+ };
139
+ });
140
+
141
+ const start = log.start ? mapPt(log.start) : { x: vW * 0.25, y: vH * 0.9 };
142
+ const last = log.events.length ? log.events[log.events.length - 1]! : undefined;
143
+ // the last action ends durationMs after its tMs (typing/drag plays out)
144
+ const lastEnd = last ? last.tMs + ("durationMs" in last ? last.durationMs : 0) : 0;
145
+ const durationMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs + cursor.zoomOutMs + 400);
146
+
147
+ return {
148
+ output: { width: oW, height: oH, fps },
149
+ source: { videoUrl: "/capture.mp4", videoWidth: vW, videoHeight: vH, viewport: log.viewport },
150
+ framing,
151
+ cursor,
152
+ motionBlur: DEFAULT_MOTION_BLUR,
153
+ start,
154
+ events,
155
+ durationMs,
156
+ };
157
+ }