@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/src/math.ts CHANGED
@@ -43,44 +43,45 @@ export function cubicBezier(x1: number, y1: number, x2: number, y2: number): (x:
43
43
  // Spring-shaped easing y(p) over [0,1]: the unit step response of a damped
44
44
  // spring, time-normalised so it rises (and, for bounce>0, slightly overshoots)
45
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).
46
+ // (no overshoot — a soft, physical ease-out); higher = more overshoot/snap.
47
+ //
48
+ // bounce 0 over the default zoom durations IS the measured reference zoom
49
+ // feel: frame-tracking a reference export gave a critically-damped spring on the
50
+ // camera rect, ω≈9.4 rad/s in / ω≈5.2 out (which also matches the spring
51
+ // preset numbers from the reference teardown) —
52
+ // i.e. this exact curve over ~730ms in / ~1340ms out.
49
53
  //
50
54
  // Under time-normalisation the curve depends ONLY on the damping ratio ζ=1−bounce
51
55
  // (the natural frequency cancels), so a single `bounce` knob is the whole shape —
52
56
  // the segment DURATION stays whatever the keyframes say (zoomInMs/zoomOutMs).
57
+ // The response is normalised by its end value so the segment lands on exactly 1
58
+ // (no end-of-segment snap from the finite settle band).
53
59
  export function springEase(bounce: number): (p: number) => number {
54
60
  const zeta = Math.max(0.4, Math.min(1, 1 - bounce));
55
61
  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;
62
+ const step = (t: number): number => {
60
63
  if (zeta >= 1) return 1 - Math.exp(-t) * (1 + t); // critically damped
61
64
  const wd = Math.sqrt(1 - zeta * zeta); // damped natural frequency (ω0 = 1)
62
65
  return 1 - Math.exp(-zeta * t) * (Math.cos(wd * t) + (zeta / wd) * Math.sin(wd * t));
63
66
  };
67
+ const end = step(Ts);
68
+ return (p: number) => {
69
+ if (p <= 0) return 0;
70
+ if (p >= 1) return 1;
71
+ return step(p * Ts) / end;
72
+ };
64
73
  }
65
74
 
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.
75
+ // The stage (camera rect) easing, selected from the cursor config. ONE curve
76
+ // eases the whole rect centre and size together (see stageCamera). The single
77
+ // source the revideo scene (scene.tsx) AND the editor preview consume — any
78
+ // other renderer must use it identically so renderers can never drift.
79
+ // Precedence: spring (zoomSpring) → cubic-bezier (zoomEase) → the default
80
+ // critically-damped spring (the measured reference curve).
70
81
  export function stageEasing(cursor: TakeComposition["cursor"]): (u: number) => number {
71
82
  if (cursor.zoomSpring != null) return springEase(cursor.zoomSpring);
72
83
  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
+ return springEase(0);
84
85
  }
85
86
 
86
87
  // Backdrop gradient endpoints in TOP-LEFT origin (0..oW, 0..oH), shared by the
@@ -110,15 +111,10 @@ export function gradientEndpoints(
110
111
 
111
112
  type KF<T> = [number, T];
112
113
 
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
114
  export function keyvalN(
118
115
  t: number,
119
116
  kfs: KF<number>[],
120
117
  ease: (u: number) => number = smoother,
121
- easeDown?: (u: number) => number,
122
118
  ): number {
123
119
  if (t <= kfs[0]![0]) return kfs[0]![1];
124
120
  if (t >= kfs[kfs.length - 1]![0]) return kfs[kfs.length - 1]![1];
@@ -126,22 +122,7 @@ export function keyvalN(
126
122
  const [t0, v0] = kfs[i]!;
127
123
  const [t1, v1] = kfs[i + 1]!;
128
124
  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 };
125
+ return v0 + (v1 - v0) * ease((t - t0) / (t1 - t0));
145
126
  }
146
127
  }
147
128
  return kfs[kfs.length - 1]![1];
@@ -206,21 +187,73 @@ export function clampCenter(
206
187
  return { x: cx, y: cy };
207
188
  }
208
189
 
209
- // --- stage (zoom/pan) keyframes from the composition -------------------
190
+ // --- stage (camera rect) keyframes from the composition ----------------
191
+
192
+ /** The camera as a viewport RECT in video-px: centre + full viewport width
193
+ * (height is implied by the output aspect: h = w·oH/oW). scale = oW / w. */
194
+ export type CamRect = { cx: number; cy: number; w: number };
210
195
 
211
196
  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)
197
+ r: KF<CamRect>[]; // viewport rect over time (seconds)
214
198
  T: number; // total duration (s)
215
199
  };
216
200
 
201
+ /** Interpolate the rect track: centre AND size move under the SAME eased
202
+ * parameter. This is the whole trick (verified
203
+ * by frame-tracking a reference export: its pan curve overlays its viewport-WIDTH
204
+ * curve exactly, not its scale curve): lerping the rect keeps every corner on
205
+ * a straight line, so the screen-space path of the zoom target is strictly
206
+ * monotone toward frame centre — no wrong-way "bounce" for ANY scale pair,
207
+ * which scale+centre lerp can't guarantee (it hooks when scale > 2×rest). */
208
+ export function keyvalR(t: number, kfs: KF<CamRect>[], ease: (u: number) => number): CamRect {
209
+ if (t <= kfs[0]![0]) return kfs[0]![1];
210
+ if (t >= kfs[kfs.length - 1]![0]) return kfs[kfs.length - 1]![1];
211
+ for (let i = 0; i < kfs.length - 1; i++) {
212
+ const [t0, v0] = kfs[i]!;
213
+ const [t1, v1] = kfs[i + 1]!;
214
+ if (t0 <= t && t <= t1) {
215
+ const p = ease((t - t0) / (t1 - t0));
216
+ // A spring ease (zoomSpring > 0) overshoots p past 1, extrapolating the
217
+ // rect beyond its target — that's the wanted "snap", but on a deep punch
218
+ // with a large bounce the extrapolated width could collapse through 0
219
+ // (scale sign-flip = garbage frames). Floor the width against the
220
+ // segment's own endpoints so overshoot can tighten at most 2× past the
221
+ // tighter one; monotone eases (p ∈ [0,1]) never hit the floor.
222
+ return {
223
+ cx: v0.cx + (v1.cx - v0.cx) * p,
224
+ cy: v0.cy + (v1.cy - v0.cy) * p,
225
+ w: Math.max(v0.w + (v1.w - v0.w) * p, Math.min(v0.w, v1.w) * 0.5),
226
+ };
227
+ }
228
+ }
229
+ return kfs[kfs.length - 1]![1];
230
+ }
231
+
217
232
  export function buildStageKeyframes(comp: TakeComposition): StageKeyframes {
218
233
  const { videoWidth: vW, videoHeight: vH } = comp.source;
219
234
  const { width: oW, height: oH } = comp.output;
220
235
  const rest = restStageScale(vW, vH, oW, oH, comp.framing.insetFrac);
221
236
  const restC: Pt = { x: vW / 2, y: vH / 2 };
222
237
  const HOLD = comp.cursor.holdMs / 1000;
223
- const ZOUT = comp.cursor.zoomOutMs / 1000;
238
+ const ZOUT_MS = comp.cursor.zoomOutMs;
239
+
240
+ // A framing target is clamped ONCE, here at build time (the viewport crop
241
+ // stays inside the recording; an axis it doesn't cover centres). Every
242
+ // keyframe rect is therefore valid — and for a monotone ease (p ∈ [0,1];
243
+ // every default) lerped rect corners stay between the endpoints' corners, so
244
+ // every IN-BETWEEN rect is valid too. No per-frame clamp, so the clamp can
245
+ // never bend a path mid-flight (the old model's per-frame clamp force-
246
+ // centred the pan while the video under-covered the frame, then released it
247
+ // mid-zoom — a visible lurch; and its zoom-out "land the centre early"
248
+ // repair made pull-outs pan-then-zoom two-phase). A zoomSpring bounce > 0
249
+ // deliberately overshoots PAST the target rect (edge-flush targets can
250
+ // flash a sliver of backdrop during the settle — the physical cost of
251
+ // bounce; keyvalR floors only the width collapse).
252
+ const rectFor = (center: Pt, scale: number): CamRect => {
253
+ const c = clampCenter(center, scale, vW, vH, oW, oH);
254
+ return { cx: c.x, cy: c.y, w: oW / scale };
255
+ };
256
+ const restR = rectFor(restC, rest);
224
257
 
225
258
  // Framing anchors over time. A zoom-enabled beat ramps to its target; a
226
259
  // scroll (content pans) and a zoom-less press (Escape/Enter whose effect is
@@ -245,101 +278,100 @@ export function buildStageKeyframes(comp: TakeComposition): StageKeyframes {
245
278
  }
246
279
  }
247
280
 
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;
281
+ const targets = anchors.map((e) => rectFor(e.center, e.scale));
282
+
283
+ // Effective ramp start per anchor. A PULL-OUT (wider viewport than the rect
284
+ // it leaves) paces with zoomOutMs measured on the reference export,
285
+ // the pull-out is ~1.8× slower than the punch-in. The stored inAtMs keeps
286
+ // governing punch-ins/same-size travels, and a HAND-SET inAtMs (≠ the
287
+ // planner default tMs zoomInMs) wins even for a pull-out, so the per-beat
288
+ // timing knob stays live. When the zoomOutMs window would start before the
289
+ // previous action's end, shorten it toward the punch-in window rather than
290
+ // cut the payoff but NEVER squeeze below min(zoomOutMs, zoomInMs) of real
291
+ // ramp: a pull-out with no window is a jump cut, which is strictly worse
292
+ // than leaving a payoff a little early.
293
+ const ZIN_MS = comp.cursor.zoomInMs;
294
+ const rampStartS = anchors.map((e, i) => {
295
+ const from = i > 0 ? targets[i - 1]! : restR;
296
+ const pullOut = targets[i]!.w > from.w + 1e-6;
297
+ const isDefaultInAt = Math.abs(e.inAtMs - Math.max(0, e.tMs - ZIN_MS)) < 1;
298
+ if (!pullOut || !isDefaultInAt) return e.inAtMs / 1000;
299
+ const desired = e.tMs - ZOUT_MS;
300
+ const prevEndMs = i > 0 ? anchors[i - 1]!.tMs + anchors[i - 1]!.durationMs : 0;
301
+ const start =
302
+ desired >= prevEndMs ? desired : Math.min(prevEndMs, e.tMs - Math.min(ZOUT_MS, ZIN_MS));
303
+ return Math.max(start, 0) / 1000;
304
+ });
305
+
306
+ const rf: { t: number; r: CamRect }[] = [{ t: 0, r: restR }];
307
+ const push = (t: number, r: CamRect) => {
308
+ rf.push({ t: Math.max(t, rf[rf.length - 1]!.t + 1e-3), r });
275
309
  };
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
310
 
281
- let cur = { s: rest, c: restC };
311
+ let cur = restR;
282
312
  anchors.forEach((e, i) => {
283
- const rampStart = e.inAtMs / 1000;
284
313
  const clickT = e.tMs / 1000;
285
314
  // the action plays out (typing/drawing/scrolling) for durationMs after tMs
286
315
  // — hold the target framing through it (a point click has duration 0).
287
316
  const actionEnd = (e.tMs + e.durationMs) / 1000;
288
317
  const next = anchors[i + 1];
289
- const holdEndT = next ? next.inAtMs / 1000 : actionEnd + HOLD;
318
+ const holdEndT = next ? rampStartS[i + 1]! : actionEnd + HOLD;
319
+ push(rampStartS[i]!, cur); // hold current until ramp begins
320
+ push(clickT, targets[i]!); // ONE eased rect segment lands at the action
321
+ cur = targets[i]!;
290
322
  // glide: drift the held centre across the hold window (velocity px/s ·
291
323
  // 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;
324
+ // Clamped like any target so the drift can't leave the recording.
325
+ let holdR = cur;
295
326
  if (e.glide && (e.glide.x !== 0 || e.glide.y !== 0)) {
296
327
  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);
328
+ holdR = rectFor(
329
+ { x: e.center.x + e.glide.x * holdDur, y: e.center.y + e.glide.y * holdDur },
330
+ e.scale,
331
+ );
314
332
  }
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 };
333
+ push(holdEndT, holdR); // hold (or glide) until the next ramp / the tail
334
+ cur = holdR;
335
+ if (!next) {
336
+ push(holdEndT + ZOUT_MS / 1000, restR); // final zoom-out, one rect segment
337
+ cur = restR;
335
338
  }
336
339
  });
337
340
 
338
- const lastT = Math.max(zf[zf.length - 1]!.t, cf[cf.length - 1]!.t);
341
+ const lastT = rf[rf.length - 1]!.t;
339
342
  const T = Math.max(comp.durationMs / 1000, lastT) + 0.3;
340
- push(T, rest, restC);
343
+ push(T, restR);
341
344
 
342
- return { z: zf.map((f) => [f.t, f.s]), c: cf.map((f) => [f.t, f.c]), T };
345
+ return { r: rf.map((f) => [f.t, f.r]), T };
346
+ }
347
+
348
+ /** The one camera evaluator — scene.tsx (render) and the editor preview both
349
+ * consume THIS, so preview and export can never drift. */
350
+ export function stageCamera(comp: TakeComposition): {
351
+ T: number;
352
+ rest: number;
353
+ peakScale: number;
354
+ at: (t: number) => { scale: number; center: Pt };
355
+ } {
356
+ const stage = buildStageKeyframes(comp);
357
+ const ease = stageEasing(comp.cursor);
358
+ const oW = comp.output.width;
359
+ const rest = restStageScale(
360
+ comp.source.videoWidth,
361
+ comp.source.videoHeight,
362
+ oW,
363
+ comp.output.height,
364
+ comp.framing.insetFrac,
365
+ );
366
+ const at = (t: number) => {
367
+ const r = keyvalR(t, stage.r, ease);
368
+ return { scale: oW / r.w, center: { x: r.cx, y: r.cy } };
369
+ };
370
+ // Peak by sampling (not just keyframe extremes): a spring ease overshoots
371
+ // mid-segment, so the true peak can sit above every keyframe.
372
+ let peakScale = stage.r.reduce((m, [, r]) => Math.max(m, oW / r.w), rest);
373
+ for (let i = 0; i <= 720; i++) peakScale = Math.max(peakScale, at((i / 720) * stage.T).scale);
374
+ return { T: stage.T, rest, peakScale, at };
343
375
  }
344
376
 
345
377
  // --- cursor path (eased, with gentle arc) ------------------------------
package/src/plan.ts CHANGED
@@ -1,52 +1,62 @@
1
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.
2
+ //
3
+ // Coordinate mapping (capture viewport px -> video px) and the per-beat
4
+ // scaffolding live here; the ZOOM decision is delegated to the camera DIRECTOR
5
+ // (camera.ts), which runs over the whole beat sequence with the real bbox /
6
+ // timing / changed-area from the ground-truth log. Every decision it makes is
7
+ // still written per-beat into the composition (enabled/scale/center + reason)
8
+ // so a human/agent can read and tune it.
4
9
 
5
- import { bboxFitScale, restStageScale } from "./math";
10
+ import { type Beat, directCamera } from "./camera";
11
+ import { restStageScale } from "./math";
6
12
  import {
7
13
  type BBox,
14
+ type CameraConfig,
8
15
  type CaptureLog,
9
16
  type CompEvent,
10
17
  type CursorConfig,
18
+ DEFAULT_CAMERA,
11
19
  DEFAULT_CURSOR,
12
20
  DEFAULT_FRAMING,
13
21
  DEFAULT_MOTION_BLUR,
14
22
  type FramingConfig,
15
23
  type Pt,
16
24
  type TakeComposition,
25
+ type ZoomDecision,
17
26
  } from "./types";
18
27
 
19
28
  export type PlanOpts = {
20
29
  output?: { width?: number; height?: number; fps?: number };
21
30
  framing?: Partial<FramingConfig>;
22
31
  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;
32
+ /** auto-camera director tuning (default DEFAULT_CAMERA ON). The director
33
+ * decides zoom from the ground-truth log; these are its feel knobs. Set
34
+ * `camera.enabled = false` for the manual escape hatch (only explicit
35
+ * event `zoom: "always"/"never"` produce zoom then). */
36
+ camera?: Partial<CameraConfig>;
33
37
  };
34
38
 
35
39
  export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeComposition {
36
- const vW = log.video.width,
37
- vH = log.video.height;
40
+ // Stage space = the capture VIEWPORT (CSS px), NOT the video file's pixel
41
+ // size. A Retina capture (captureScale 2) records a 2×-dense bitmap of the
42
+ // same viewport; the scene draws it at viewport size, so the denser bitmap
43
+ // only sharpens sampling under zoom. Keying the stage to the viewport keeps
44
+ // every calibrated quantity — camera scales (rest/minZoomScale/maxScale),
45
+ // cursor/ripple sizes, framing radius/shadow — meaning the same thing at
46
+ // any capture density. (Pre-Retina logs have video == viewport, so this is
47
+ // byte-identical for them.)
48
+ const vW = log.viewport.w,
49
+ vH = log.viewport.h;
38
50
  const oW = opts.output?.width ?? vW;
39
51
  const oH = opts.output?.height ?? vH;
40
52
  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
53
 
46
54
  const framing: FramingConfig = { ...DEFAULT_FRAMING, ...opts.framing };
47
55
  const cursor: CursorConfig = { ...DEFAULT_CURSOR, ...opts.cursor };
56
+ const camera: CameraConfig = { ...DEFAULT_CAMERA, ...opts.camera };
48
57
 
49
- // viewport CSS px -> video px
58
+ // log coords (viewport CSS px) -> stage px. Identity today (stage IS the
59
+ // viewport); kept as an explicit mapping seam.
50
60
  const sx = vW / log.viewport.w;
51
61
  const sy = vH / log.viewport.h;
52
62
  const mapPt = (p: Pt): Pt => ({ x: p.x * sx, y: p.y * sy });
@@ -54,9 +64,8 @@ export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeCompo
54
64
 
55
65
  const rest = restStageScale(vW, vH, oW, oH, framing.insetFrac);
56
66
 
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.
67
+ // Axis-aligned bbox of a polyline (video-px), so a drag frames the WHOLE
68
+ // stroke (a path, not a point): a big cross-canvas drag fits ≈ rest → no zoom.
60
69
  const pathBBox = (pts: Pt[]): BBox => {
61
70
  const xs = pts.map((p) => p.x),
62
71
  ys = pts.map((p) => p.y);
@@ -65,14 +74,19 @@ export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeCompo
65
74
  return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
66
75
  };
67
76
 
68
- const events: CompEvent[] = log.events.map((c, i) => {
77
+ // --- map every event into video-px scaffolding (kind-agnostic zoom yet) -----
78
+ type Scaffold = {
79
+ beat: Beat;
80
+ ev: Omit<CompEvent, "zoom">;
81
+ };
82
+
83
+ const scaffolds: Scaffold[] = log.events.map((c) => {
69
84
  const kind = c.kind ?? "click";
70
85
  const point = mapPt({ x: c.x, y: c.y });
71
- const isFirst = i === 0;
72
86
  const intent = c.zoom ?? "auto";
73
87
  const durationMs = "durationMs" in c ? c.durationMs : 0;
74
88
 
75
- // drag: cursor path + the bbox we fit-zoom is the path's bbox
89
+ // drag: cursor path + the region we frame is the path's bbox
76
90
  const to = kind === "drag" ? mapPt((c as { to: Pt }).to) : undefined;
77
91
  const rawPath =
78
92
  kind === "drag"
@@ -81,54 +95,30 @@ export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeCompo
81
95
  const path = rawPath?.map(mapPt);
82
96
  const ease = kind === "drag" ? (c as { ease?: "linear" | "smooth" }).ease : undefined;
83
97
 
84
- // The region this action is "about" what zoom should frame.
98
+ // the element bbox (ground truth): drag path bbox, else the captured box
85
99
  const bbox = kind === "drag" && path ? pathBBox(path) : c.box ? mapBox(c.box) : undefined;
100
+ const effectBox = c.effectBox ? mapBox(c.effectBox) : undefined;
101
+ const label = c.sel ?? c.note;
86
102
 
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
- }
103
+ const beat: Beat = {
104
+ kind,
105
+ tMs: c.tMs,
106
+ durationMs,
107
+ box: bbox,
108
+ effectBox,
109
+ changeCoverage: c.changeCoverage,
110
+ point,
111
+ intent,
112
+ label,
113
+ ...(kind === "press" ? { keys: (c as { keys?: string }).keys } : {}),
114
+ };
122
115
 
123
- return {
116
+ const ev: Omit<CompEvent, "zoom"> = {
124
117
  kind,
125
118
  tMs: c.tMs,
126
119
  point,
127
120
  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 },
121
+ label,
132
122
  ...(durationMs ? { durationMs } : {}),
133
123
  ...(kind === "type" ? { text: (c as { text: string }).text } : {}),
134
124
  ...(kind === "press" ? { keys: (c as { keys: string }).keys } : {}),
@@ -136,12 +126,38 @@ export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeCompo
136
126
  ...(path ? { path } : {}),
137
127
  ...(ease ? { ease } : {}),
138
128
  };
129
+ return { beat, ev };
139
130
  });
140
131
 
141
- const start = log.start ? mapPt(log.start) : { x: vW * 0.25, y: vH * 0.9 };
132
+ const beats = scaffolds.map((s) => s.beat);
133
+
134
+ // horizon for the min-hold check on the final segment
142
135
  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
136
  const lastEnd = last ? last.tMs + ("durationMs" in last ? last.durationMs : 0) : 0;
137
+ const endMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs);
138
+
139
+ // --- decide framing --------------------------------------------------------
140
+ // director ON (default): decide from the log. OFF: the manual escape hatch —
141
+ // only explicit always/never produce zoom, auto/absent hold full view.
142
+ const framings = camera.enabled
143
+ ? directCamera(beats, { w: vW, h: vH }, { w: oW, h: oH }, camera, rest, endMs)
144
+ : beats.map((b) => manualFraming(b, rest));
145
+
146
+ const events: CompEvent[] = scaffolds.map((s, i) => {
147
+ const f = framings[i]!;
148
+ const zoom: ZoomDecision = {
149
+ enabled: f.enabled,
150
+ scale: f.scale,
151
+ center: f.center,
152
+ // zoom-in ramp starts zoomInMs before the action (decoupled from cursor
153
+ // travelMs so the zoom can be slower/gentler — a more cinematic feel).
154
+ inAtMs: Math.max(0, s.beat.tMs - cursor.zoomInMs),
155
+ reason: f.reason,
156
+ };
157
+ return { ...s.ev, zoom };
158
+ });
159
+
160
+ const start = log.start ? mapPt(log.start) : { x: vW * 0.25, y: vH * 0.9 };
145
161
  const durationMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs + cursor.zoomOutMs + 400);
146
162
 
147
163
  return {
@@ -155,3 +171,32 @@ export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeCompo
155
171
  durationMs,
156
172
  };
157
173
  }
174
+
175
+ // Manual mode (camera.enabled === false): the director does not run. Only an
176
+ // explicit plan intent produces zoom — "always" frames the element bbox, every-
177
+ // thing else holds full view. Deliberately dumb: this is the fully-hand-driven
178
+ // escape hatch, not a second heuristic.
179
+ function manualFraming(
180
+ b: Beat,
181
+ rest: number,
182
+ ): {
183
+ enabled: boolean;
184
+ scale: number;
185
+ center: Pt;
186
+ reason: string;
187
+ } {
188
+ if (b.intent === "always" && b.box) {
189
+ return {
190
+ enabled: true,
191
+ scale: rest, // manual mode leaves scale for the human to raise; frame the bbox
192
+ center: { x: b.box.x + b.box.w / 2, y: b.box.y + b.box.h / 2 },
193
+ reason: "camera off · plan: zoom=always (raise scale by hand)",
194
+ };
195
+ }
196
+ return {
197
+ enabled: false,
198
+ scale: rest,
199
+ center: b.point,
200
+ reason: "camera off · full view (no explicit zoom)",
201
+ };
202
+ }