@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/dist/index.js CHANGED
@@ -9,6 +9,16 @@ import { fileURLToPath } from "node:url";
9
9
  import { renderVideo } from "@open-take/revideo-renderer";
10
10
 
11
11
  //#region src/types.ts
12
+ const DEFAULT_CAMERA = {
13
+ enabled: true,
14
+ fillFrac: .6,
15
+ maxScale: 2.4,
16
+ minHoldMs: 1200,
17
+ minZoomScale: 1.25,
18
+ coalesceWindowMs: 900,
19
+ travelThreshold: .18,
20
+ pullOutCoverage: .55
21
+ };
12
22
  /** True when motion blur is configured to actually do something (so the OFF
13
23
  * path stays byte-identical to the pre-motion-blur renderer). */
14
24
  function motionBlurActive(mb) {
@@ -44,14 +54,8 @@ const DEFAULT_CURSOR = {
44
54
  arcMax: 24,
45
55
  rippleMs: 450,
46
56
  holdMs: 1100,
47
- zoomOutMs: 800,
48
- zoomInMs: 760,
49
- zoomEase: [
50
- .3,
51
- 0,
52
- .2,
53
- 1
54
- ],
57
+ zoomOutMs: 1340,
58
+ zoomInMs: 730,
55
59
  dragLagMs: 190,
56
60
  travelEase: [
57
61
  .3,
@@ -86,20 +90,23 @@ const MOTION = {
86
90
  calm: {
87
91
  travelWidthsPerSec: .28,
88
92
  holdMs: 1500,
89
- zoomInMs: 950,
90
- zoomOutMs: 950
93
+ zoomInMs: 900,
94
+ zoomOutMs: 1650,
95
+ zoomEase: void 0
91
96
  },
92
97
  natural: {
93
98
  travelWidthsPerSec: .35,
94
99
  holdMs: 1100,
95
- zoomInMs: 760,
96
- zoomOutMs: 800
100
+ zoomInMs: 730,
101
+ zoomOutMs: 1340,
102
+ zoomEase: void 0
97
103
  },
98
104
  brisk: {
99
105
  travelWidthsPerSec: .45,
100
106
  holdMs: 750,
101
- zoomInMs: 560,
102
- zoomOutMs: 600
107
+ zoomInMs: 550,
108
+ zoomOutMs: 1e3,
109
+ zoomEase: void 0
103
110
  }
104
111
  };
105
112
  function motionName(cursor) {
@@ -286,11 +293,11 @@ __export(math_exports, {
286
293
  gradientEndpoints: () => gradientEndpoints,
287
294
  isDragging: () => isDragging,
288
295
  keyvalN: () => keyvalN,
289
- keyvalP: () => keyvalP,
290
- panEasing: () => panEasing,
296
+ keyvalR: () => keyvalR,
291
297
  restStageScale: () => restStageScale,
292
298
  smoother: () => smoother,
293
299
  springEase: () => springEase,
300
+ stageCamera: () => stageCamera,
294
301
  stageEasing: () => stageEasing
295
302
  });
296
303
  function smoother(t) {
@@ -323,23 +330,22 @@ function cubicBezier(x1, y1, x2, y2) {
323
330
  function springEase(bounce) {
324
331
  const zeta = Math.max(.4, Math.min(1, 1 - bounce));
325
332
  const Ts = -Math.log(.001) / zeta;
326
- return (p) => {
327
- if (p <= 0) return 0;
328
- if (p >= 1) return 1;
329
- const t = p * Ts;
333
+ const step = (t) => {
330
334
  if (zeta >= 1) return 1 - Math.exp(-t) * (1 + t);
331
335
  const wd = Math.sqrt(1 - zeta * zeta);
332
336
  return 1 - Math.exp(-zeta * t) * (Math.cos(wd * t) + zeta / wd * Math.sin(wd * t));
333
337
  };
338
+ const end = step(Ts);
339
+ return (p) => {
340
+ if (p <= 0) return 0;
341
+ if (p >= 1) return 1;
342
+ return step(p * Ts) / end;
343
+ };
334
344
  }
335
345
  function stageEasing(cursor) {
336
346
  if (cursor.zoomSpring != null) return springEase(cursor.zoomSpring);
337
347
  if (cursor.zoomEase) return cubicBezier(...cursor.zoomEase);
338
- return smoother;
339
- }
340
- function panEasing(cursor) {
341
- if (cursor.zoomEase) return cubicBezier(...cursor.zoomEase);
342
- return smoother;
348
+ return springEase(0);
343
349
  }
344
350
  function gradientEndpoints(angleDeg, oW, oH) {
345
351
  if (angleDeg == null) return {
@@ -361,32 +367,13 @@ function gradientEndpoints(angleDeg, oW, oH) {
361
367
  y1: cy + dy * L / 2
362
368
  };
363
369
  }
364
- function keyvalN(t, kfs, ease = smoother, easeDown) {
365
- if (t <= kfs[0][0]) return kfs[0][1];
366
- if (t >= kfs[kfs.length - 1][0]) return kfs[kfs.length - 1][1];
367
- for (let i = 0; i < kfs.length - 1; i++) {
368
- const [t0, v0] = kfs[i];
369
- const [t1, v1] = kfs[i + 1];
370
- if (t0 <= t && t <= t1) {
371
- const e = easeDown && v1 < v0 ? easeDown : ease;
372
- return v0 + (v1 - v0) * e((t - t0) / (t1 - t0));
373
- }
374
- }
375
- return kfs[kfs.length - 1][1];
376
- }
377
- function keyvalP(t, kfs, ease = smoother) {
370
+ function keyvalN(t, kfs, ease = smoother) {
378
371
  if (t <= kfs[0][0]) return kfs[0][1];
379
372
  if (t >= kfs[kfs.length - 1][0]) return kfs[kfs.length - 1][1];
380
373
  for (let i = 0; i < kfs.length - 1; i++) {
381
374
  const [t0, v0] = kfs[i];
382
375
  const [t1, v1] = kfs[i + 1];
383
- if (t0 <= t && t <= t1) {
384
- const p = ease((t - t0) / (t1 - t0));
385
- return {
386
- x: v0.x + (v1.x - v0.x) * p,
387
- y: v0.y + (v1.y - v0.y) * p
388
- };
389
- }
376
+ if (t0 <= t && t <= t1) return v0 + (v1 - v0) * ease((t - t0) / (t1 - t0));
390
377
  }
391
378
  return kfs[kfs.length - 1][1];
392
379
  }
@@ -425,6 +412,30 @@ function clampCenter(center, scale, videoW, videoH, outW, outH) {
425
412
  y: cy
426
413
  };
427
414
  }
415
+ /** Interpolate the rect track: centre AND size move under the SAME eased
416
+ * parameter. This is the whole trick (verified
417
+ * by frame-tracking a reference export: its pan curve overlays its viewport-WIDTH
418
+ * curve exactly, not its scale curve): lerping the rect keeps every corner on
419
+ * a straight line, so the screen-space path of the zoom target is strictly
420
+ * monotone toward frame centre — no wrong-way "bounce" for ANY scale pair,
421
+ * which scale+centre lerp can't guarantee (it hooks when scale > 2×rest). */
422
+ function keyvalR(t, kfs, ease) {
423
+ if (t <= kfs[0][0]) return kfs[0][1];
424
+ if (t >= kfs[kfs.length - 1][0]) return kfs[kfs.length - 1][1];
425
+ for (let i = 0; i < kfs.length - 1; i++) {
426
+ const [t0, v0] = kfs[i];
427
+ const [t1, v1] = kfs[i + 1];
428
+ if (t0 <= t && t <= t1) {
429
+ const p = ease((t - t0) / (t1 - t0));
430
+ return {
431
+ cx: v0.cx + (v1.cx - v0.cx) * p,
432
+ cy: v0.cy + (v1.cy - v0.cy) * p,
433
+ w: Math.max(v0.w + (v1.w - v0.w) * p, Math.min(v0.w, v1.w) * .5)
434
+ };
435
+ }
436
+ }
437
+ return kfs[kfs.length - 1][1];
438
+ }
428
439
  function buildStageKeyframes(comp) {
429
440
  const { videoWidth: vW, videoHeight: vH } = comp.source;
430
441
  const { width: oW, height: oH } = comp.output;
@@ -434,7 +445,16 @@ function buildStageKeyframes(comp) {
434
445
  y: vH / 2
435
446
  };
436
447
  const HOLD = comp.cursor.holdMs / 1e3;
437
- const ZOUT = comp.cursor.zoomOutMs / 1e3;
448
+ const ZOUT_MS = comp.cursor.zoomOutMs;
449
+ const rectFor = (center, scale) => {
450
+ const c = clampCenter(center, scale, vW, vH, oW, oH);
451
+ return {
452
+ cx: c.x,
453
+ cy: c.y,
454
+ w: oW / scale
455
+ };
456
+ };
457
+ const restR = rectFor(restC, rest);
438
458
  const anchors = [];
439
459
  for (const e of comp.events) {
440
460
  const base = {
@@ -454,95 +474,86 @@ function buildStageKeyframes(comp) {
454
474
  glide: e.zoom.glide
455
475
  });
456
476
  }
457
- const zf = [{
458
- t: 0,
459
- s: rest
460
- }];
461
- const cf = [{
477
+ const targets = anchors.map((e) => rectFor(e.center, e.scale));
478
+ const ZIN_MS = comp.cursor.zoomInMs;
479
+ const rampStartS = anchors.map((e, i) => {
480
+ const from = i > 0 ? targets[i - 1] : restR;
481
+ const pullOut = targets[i].w > from.w + 1e-6;
482
+ const isDefaultInAt = Math.abs(e.inAtMs - Math.max(0, e.tMs - ZIN_MS)) < 1;
483
+ if (!pullOut || !isDefaultInAt) return e.inAtMs / 1e3;
484
+ const desired = e.tMs - ZOUT_MS;
485
+ const prevEndMs = i > 0 ? anchors[i - 1].tMs + anchors[i - 1].durationMs : 0;
486
+ const start = desired >= prevEndMs ? desired : Math.min(prevEndMs, e.tMs - Math.min(ZOUT_MS, ZIN_MS));
487
+ return Math.max(start, 0) / 1e3;
488
+ });
489
+ const rf = [{
462
490
  t: 0,
463
- c: restC
491
+ r: restR
464
492
  }];
465
- const push = (t, s, c) => {
466
- zf.push({
467
- t: Math.max(t, zf[zf.length - 1].t + .001),
468
- s
469
- });
470
- cf.push({
471
- t: Math.max(t, cf[cf.length - 1].t + .001),
472
- c
473
- });
474
- };
475
- const pushC = (t, c) => {
476
- cf.push({
477
- t: Math.max(t, cf[cf.length - 1].t + .001),
478
- c
493
+ const push = (t, r) => {
494
+ rf.push({
495
+ t: Math.max(t, rf[rf.length - 1].t + .001),
496
+ r
479
497
  });
480
498
  };
481
- const ze = panEasing(comp.cursor);
482
- const invEase = (target) => {
483
- let lo = 0;
484
- let hi = 1;
485
- for (let i = 0; i < 30; i++) {
486
- const m = (lo + hi) / 2;
487
- if (ze(m) < target) lo = m;
488
- else hi = m;
489
- }
490
- return (lo + hi) / 2;
491
- };
492
- const fillThreshold = Math.max(oW / vW, oH / vH);
493
- let cur = {
494
- s: rest,
495
- c: restC
496
- };
499
+ let cur = restR;
497
500
  anchors.forEach((e, i) => {
498
- const rampStart = e.inAtMs / 1e3;
499
501
  const clickT = e.tMs / 1e3;
500
502
  const actionEnd = (e.tMs + e.durationMs) / 1e3;
501
503
  const next = anchors[i + 1];
502
- const holdEndT = next ? next.inAtMs / 1e3 : actionEnd + HOLD;
503
- let holdC = e.center;
504
+ const holdEndT = next ? rampStartS[i + 1] : actionEnd + HOLD;
505
+ push(rampStartS[i], cur);
506
+ push(clickT, targets[i]);
507
+ cur = targets[i];
508
+ let holdR = cur;
504
509
  if (e.glide && (e.glide.x !== 0 || e.glide.y !== 0)) {
505
510
  const holdDur = Math.max(0, holdEndT - clickT);
506
- holdC = {
511
+ holdR = rectFor({
507
512
  x: e.center.x + e.glide.x * holdDur,
508
513
  y: e.center.y + e.glide.y * holdDur
509
- };
514
+ }, e.scale);
510
515
  }
511
- push(rampStart, cur.s, cur.c);
512
- if (cur.s > fillThreshold && e.scale < fillThreshold && fillThreshold > rest && clickT > rampStart && Math.hypot(e.center.x - cur.c.x, e.center.y - cur.c.y) > 1) {
513
- const uCross = invEase((fillThreshold - cur.s) / (e.scale - cur.s));
514
- pushC(rampStart + uCross * (clickT - rampStart), e.center);
515
- }
516
- push(clickT, e.scale, e.center);
517
- cur = {
518
- s: e.scale,
519
- c: holdC
520
- };
521
- if (next) push(holdEndT, cur.s, cur.c);
522
- else {
523
- const holdEnd = holdEndT;
524
- push(holdEnd, cur.s, cur.c);
525
- const offset = Math.hypot(cur.c.x - restC.x, cur.c.y - restC.y) > 1;
526
- if (offset && cur.s > fillThreshold && fillThreshold > rest) {
527
- const uCross = invEase((fillThreshold - cur.s) / (rest - cur.s));
528
- pushC(holdEnd + uCross * ZOUT, restC);
529
- }
530
- push(holdEnd + ZOUT, rest, restC);
531
- cur = {
532
- s: rest,
533
- c: restC
534
- };
516
+ push(holdEndT, holdR);
517
+ cur = holdR;
518
+ if (!next) {
519
+ push(holdEndT + ZOUT_MS / 1e3, restR);
520
+ cur = restR;
535
521
  }
536
522
  });
537
- const lastT = Math.max(zf[zf.length - 1].t, cf[cf.length - 1].t);
523
+ const lastT = rf[rf.length - 1].t;
538
524
  const T = Math.max(comp.durationMs / 1e3, lastT) + .3;
539
- push(T, rest, restC);
525
+ push(T, restR);
540
526
  return {
541
- z: zf.map((f) => [f.t, f.s]),
542
- c: cf.map((f) => [f.t, f.c]),
527
+ r: rf.map((f) => [f.t, f.r]),
543
528
  T
544
529
  };
545
530
  }
531
+ /** The one camera evaluator — scene.tsx (render) and the editor preview both
532
+ * consume THIS, so preview and export can never drift. */
533
+ function stageCamera(comp) {
534
+ const stage = buildStageKeyframes(comp);
535
+ const ease = stageEasing(comp.cursor);
536
+ const oW = comp.output.width;
537
+ const rest = restStageScale(comp.source.videoWidth, comp.source.videoHeight, oW, comp.output.height, comp.framing.insetFrac);
538
+ const at = (t) => {
539
+ const r = keyvalR(t, stage.r, ease);
540
+ return {
541
+ scale: oW / r.w,
542
+ center: {
543
+ x: r.cx,
544
+ y: r.cy
545
+ }
546
+ };
547
+ };
548
+ let peakScale = stage.r.reduce((m, [, r]) => Math.max(m, oW / r.w), rest);
549
+ for (let i = 0; i <= 720; i++) peakScale = Math.max(peakScale, at(i / 720 * stage.T).scale);
550
+ return {
551
+ T: stage.T,
552
+ rest,
553
+ peakScale,
554
+ at
555
+ };
556
+ }
546
557
  function buildLegs(comp) {
547
558
  const legs = [];
548
559
  let cur = comp.start;
@@ -550,8 +561,8 @@ function buildLegs(comp) {
550
561
  const speedPxPerMs = (travelWidthsPerSec || 0) * comp.source.videoWidth / 1e3;
551
562
  const travelDur = (a, b) => {
552
563
  if (speedPxPerMs <= 0) return travelMs / 1e3;
553
- const dist = Math.hypot(b.x - a.x, b.y - a.y);
554
- return Math.min(travelMaxMs, Math.max(travelMinMs, dist / speedPxPerMs)) / 1e3;
564
+ const dist$1 = Math.hypot(b.x - a.x, b.y - a.y);
565
+ return Math.min(travelMaxMs, Math.max(travelMinMs, dist$1 / speedPxPerMs)) / 1e3;
555
566
  };
556
567
  for (const e of comp.events) {
557
568
  if (e.kind === "scroll" || e.kind === "press") continue;
@@ -635,17 +646,223 @@ function isDragging(t, legs) {
635
646
  return legs.some((lg) => lg.drag === true && lg.t0 <= t && t <= lg.t1);
636
647
  }
637
648
 
649
+ //#endregion
650
+ //#region src/camera.ts
651
+ const centerOf = (b) => ({
652
+ x: b.x + b.w / 2,
653
+ y: b.y + b.h / 2
654
+ });
655
+ const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
656
+ const union = (a, b) => {
657
+ const x = Math.min(a.x, b.x);
658
+ const y = Math.min(a.y, b.y);
659
+ return {
660
+ x,
661
+ y,
662
+ w: Math.max(a.x + a.w, b.x + b.w) - x,
663
+ h: Math.max(a.y + a.h, b.y + b.h) - y
664
+ };
665
+ };
666
+ function growDown(box, video) {
667
+ const grownH = Math.max(box.h, Math.min(video.h * .42, box.w * .55));
668
+ const h = Math.min(grownH, video.h - box.y);
669
+ return {
670
+ x: box.x,
671
+ y: box.y,
672
+ w: box.w,
673
+ h
674
+ };
675
+ }
676
+ /** The region a beat is "about" — what the camera should frame. A type is
677
+ * field-anchored and height-capped (its reveal is open-ended); every other
678
+ * kind trusts the captured effect region, else shapes one from the bbox. */
679
+ function roiForBeat(b, video) {
680
+ if (b.kind === "type" && b.box) {
681
+ const capH = growDown(b.box, video).h;
682
+ if (b.effectBox) {
683
+ const x = Math.min(b.box.x, b.effectBox.x);
684
+ const right = Math.max(b.box.x + b.box.w, b.effectBox.x + b.effectBox.w);
685
+ const bottom = b.effectBox.y + b.effectBox.h;
686
+ const h = Math.max(b.box.h, Math.min(capH, bottom - b.box.y));
687
+ return {
688
+ x,
689
+ y: b.box.y,
690
+ w: right - x,
691
+ h
692
+ };
693
+ }
694
+ return growDown(b.box, video);
695
+ }
696
+ if (b.effectBox) return b.effectBox;
697
+ if (!b.box) return void 0;
698
+ return b.box;
699
+ }
700
+ function clusterLabel(beats, idx) {
701
+ const raw = beats[idx[0]]?.label ?? "";
702
+ const cleaned = raw.replace(/\s+/g, " ").trim().slice(0, 18);
703
+ return cleaned || "region";
704
+ }
705
+ /**
706
+ * Decide framing for every beat. `endMs` is when the last frame can hold until
707
+ * (for the min-hold check on the final segment). `rest` is the stage scale at
708
+ * full view.
709
+ */
710
+ function directCamera(beats, video, out, cam, rest, endMs) {
711
+ const restC = {
712
+ x: video.w / 2,
713
+ y: video.h / 2
714
+ };
715
+ const fit = (roi) => bboxFitScale(roi, out.w, out.h, cam.fillFrac, cam.maxScale, rest);
716
+ const nodes = beats.map((b, i) => {
717
+ const roi = roiForBeat(b, video);
718
+ const scale = roi ? fit(roi) : rest;
719
+ const gapBreak = i > 0 && b.tMs - (beats[i - 1].tMs + beats[i - 1].durationMs) > cam.coalesceWindowMs;
720
+ if (b.intent === "never") return {
721
+ roi,
722
+ scale,
723
+ forceFull: true,
724
+ forcePunch: false,
725
+ boundary: true,
726
+ reason: "plan: zoom=never → full view"
727
+ };
728
+ if (b.intent === "always") return {
729
+ roi,
730
+ scale,
731
+ forceFull: false,
732
+ forcePunch: true,
733
+ boundary: true,
734
+ reason: roi ? `plan: zoom=always → ${scale.toFixed(2)}× (framing from ROI)` : "plan: zoom=always but no bbox to frame → full view"
735
+ };
736
+ if (b.kind === "scroll") return {
737
+ roi: void 0,
738
+ scale: rest,
739
+ forceFull: true,
740
+ forcePunch: false,
741
+ boundary: true,
742
+ reason: "scroll — full view (content pans)"
743
+ };
744
+ if (b.kind === "press" && b.keys && /(^|\+)esc(ape)?$/i.test(b.keys.trim())) return {
745
+ roi: void 0,
746
+ scale: rest,
747
+ forceFull: true,
748
+ forcePunch: false,
749
+ boundary: true,
750
+ reason: "Escape (dismissal) — full view"
751
+ };
752
+ if (b.changeCoverage != null && b.changeCoverage >= cam.pullOutCoverage) return {
753
+ roi,
754
+ scale,
755
+ forceFull: true,
756
+ forcePunch: false,
757
+ boundary: true,
758
+ reason: `changeCoverage ${b.changeCoverage.toFixed(2)} ≥ ${cam.pullOutCoverage} (global repaint) → full view`
759
+ };
760
+ if (i === 0) return {
761
+ roi,
762
+ scale,
763
+ forceFull: true,
764
+ forcePunch: false,
765
+ boundary: true,
766
+ reason: "opening beat — full view (orienting)"
767
+ };
768
+ if (!roi) return {
769
+ roi,
770
+ scale,
771
+ forceFull: true,
772
+ forcePunch: false,
773
+ boundary: true,
774
+ reason: "no bbox to frame → full view"
775
+ };
776
+ if (scale < cam.minZoomScale) return {
777
+ roi,
778
+ scale,
779
+ forceFull: true,
780
+ forcePunch: false,
781
+ boundary: gapBreak,
782
+ reason: `ROI fills the frame already (fit ${scale.toFixed(2)}× < ${cam.minZoomScale}×) — full view`
783
+ };
784
+ return {
785
+ roi,
786
+ scale,
787
+ forceFull: false,
788
+ forcePunch: false,
789
+ boundary: gapBreak,
790
+ reason: `punch ${scale.toFixed(2)}× (ROI-fit)`
791
+ };
792
+ });
793
+ const segs = [];
794
+ for (let i = 0; i < nodes.length; i++) {
795
+ const n = nodes[i];
796
+ if (n.forceFull) {
797
+ segs.push({
798
+ idx: [i],
799
+ kind: "rest",
800
+ scale: rest,
801
+ center: restC
802
+ });
803
+ continue;
804
+ }
805
+ const last = segs[segs.length - 1];
806
+ const lastLastIdx = last ? last.idx[last.idx.length - 1] : -1;
807
+ const canExtend = !!last && last.kind === "punch" && !!last.roi && !n.forcePunch && !nodes[lastLastIdx].forcePunch && !n.boundary && !!n.roi && dist(centerOf(n.roi), centerOf(last.roi)) < cam.travelThreshold * video.w && fit(union(last.roi, n.roi)) >= cam.minZoomScale;
808
+ if (canExtend && last && last.roi && n.roi) {
809
+ const roi = union(last.roi, n.roi);
810
+ last.idx.push(i);
811
+ last.roi = roi;
812
+ last.scale = fit(roi);
813
+ last.center = centerOf(roi);
814
+ } else segs.push({
815
+ idx: [i],
816
+ kind: "punch",
817
+ roi: n.roi,
818
+ scale: n.scale,
819
+ center: n.roi ? centerOf(n.roi) : restC
820
+ });
821
+ }
822
+ segs.forEach((s, si) => {
823
+ if (s.kind !== "punch") return;
824
+ const firstT = beats[s.idx[0]].tMs;
825
+ const next = segs[si + 1];
826
+ const heldUntil = next ? beats[next.idx[0]].tMs : endMs;
827
+ if (heldUntil - firstT < cam.minHoldMs) {
828
+ s.kind = "rest";
829
+ s.scale = rest;
830
+ s.center = restC;
831
+ s.dropped = true;
832
+ }
833
+ });
834
+ const framings = new Array(beats.length);
835
+ for (const s of segs) {
836
+ if (s.kind === "rest") {
837
+ for (const i of s.idx) framings[i] = {
838
+ enabled: false,
839
+ scale: rest,
840
+ center: restC,
841
+ reason: s.dropped ? `punch dropped — held < ${cam.minHoldMs}ms (would flinch) → full view` : nodes[i].reason
842
+ };
843
+ continue;
844
+ }
845
+ const N = s.idx.length;
846
+ const tag = N > 1 ? `cluster[${clusterLabel(beats, s.idx)}]` : null;
847
+ s.idx.forEach((i, k) => {
848
+ framings[i] = {
849
+ enabled: true,
850
+ scale: s.scale,
851
+ center: s.center,
852
+ reason: tag ? `${tag} ${k + 1}/${N} · hold shared framing (union of ${N}) → ${s.scale.toFixed(2)}×` : nodes[i].reason
853
+ };
854
+ });
855
+ }
856
+ return framings;
857
+ }
858
+
638
859
  //#endregion
639
860
  //#region src/plan.ts
640
861
  function planComposition(log, opts = {}) {
641
- const vW = log.video.width, vH = log.video.height;
862
+ const vW = log.viewport.w, vH = log.viewport.h;
642
863
  const oW = opts.output?.width ?? vW;
643
864
  const oH = opts.output?.height ?? vH;
644
865
  const fps = opts.output?.fps ?? 30;
645
- const fillFrac = opts.fillFrac ?? .55;
646
- const maxScale = opts.maxScale ?? 1.5;
647
- const zoomRatio = opts.zoomRatio ?? 1.3;
648
- const zoomFirst = opts.zoomFirst ?? false;
649
866
  const framing = {
650
867
  ...DEFAULT_FRAMING,
651
868
  ...opts.framing
@@ -654,6 +871,10 @@ function planComposition(log, opts = {}) {
654
871
  ...DEFAULT_CURSOR,
655
872
  ...opts.cursor
656
873
  };
874
+ const camera = {
875
+ ...DEFAULT_CAMERA,
876
+ ...opts.camera
877
+ };
657
878
  const sx = vW / log.viewport.w;
658
879
  const sy = vH / log.viewport.h;
659
880
  const mapPt = (p) => ({
@@ -677,13 +898,12 @@ function planComposition(log, opts = {}) {
677
898
  h: Math.max(...ys) - y
678
899
  };
679
900
  };
680
- const events = log.events.map((c, i) => {
901
+ const scaffolds = log.events.map((c) => {
681
902
  const kind = c.kind ?? "click";
682
903
  const point = mapPt({
683
904
  x: c.x,
684
905
  y: c.y
685
906
  });
686
- const isFirst = i === 0;
687
907
  const intent = c.zoom ?? "auto";
688
908
  const durationMs$1 = "durationMs" in c ? c.durationMs : 0;
689
909
  const to = kind === "drag" ? mapPt(c.to) : void 0;
@@ -694,53 +914,26 @@ function planComposition(log, opts = {}) {
694
914
  const path = rawPath?.map(mapPt);
695
915
  const ease = kind === "drag" ? c.ease : void 0;
696
916
  const bbox = kind === "drag" && path ? pathBBox(path) : c.box ? mapBox(c.box) : void 0;
697
- let enabled = false;
698
- let scale = rest;
699
- let reason;
700
- const center = bbox ? {
701
- x: bbox.x + bbox.w / 2,
702
- y: bbox.y + bbox.h / 2
703
- } : point;
704
- const fit = bbox ? bboxFitScale(bbox, oW, oH, fillFrac, maxScale, rest) : maxScale;
705
- if (kind === "scroll") {
706
- enabled = false;
707
- reason = "scroll — full view (content pans, no zoom)";
708
- } else if (intent === "never") {
709
- enabled = false;
710
- reason = "plan: zoom=never (global/navigation payoff — keep full view)";
711
- } else if (intent === "always") {
712
- enabled = true;
713
- scale = fit;
714
- reason = `plan: zoom=always → ${fit.toFixed(2)}x (capped ${maxScale}x)`;
715
- } else if (!bbox) reason = "no bbox in event log — cannot bbox-fit, so no zoom (avoids framing dead space)";
716
- else {
717
- scale = fit;
718
- const meaningful = fit > rest * zoomRatio;
719
- const region = kind === "drag" ? "drag path" : "element";
720
- if (isFirst && !zoomFirst) {
721
- enabled = false;
722
- reason = `first/orienting action — skipped by default (fit ${fit.toFixed(2)}x available)`;
723
- } else if (!meaningful) {
724
- enabled = false;
725
- reason = `${region} fills the frame already (fit ${fit.toFixed(2)}x ≈ rest ${rest.toFixed(2)}x) — gentle/no zoom`;
726
- } else {
727
- enabled = true;
728
- reason = `bbox-fit ${fit.toFixed(2)}x (capped ${maxScale}x), ${region} framed with ${Math.round(fillFrac * 100)}% fill`;
729
- }
730
- }
731
- return {
917
+ const effectBox = c.effectBox ? mapBox(c.effectBox) : void 0;
918
+ const label = c.sel ?? c.note;
919
+ const beat = {
920
+ kind,
921
+ tMs: c.tMs,
922
+ durationMs: durationMs$1,
923
+ box: bbox,
924
+ effectBox,
925
+ changeCoverage: c.changeCoverage,
926
+ point,
927
+ intent,
928
+ label,
929
+ ...kind === "press" ? { keys: c.keys } : {}
930
+ };
931
+ const ev = {
732
932
  kind,
733
933
  tMs: c.tMs,
734
934
  point,
735
935
  bbox,
736
- label: c.sel ?? c.note,
737
- zoom: {
738
- enabled,
739
- scale,
740
- center,
741
- inAtMs: Math.max(0, c.tMs - cursor.zoomInMs),
742
- reason
743
- },
936
+ label,
744
937
  ...durationMs$1 ? { durationMs: durationMs$1 } : {},
745
938
  ...kind === "type" ? { text: c.text } : {},
746
939
  ...kind === "press" ? { keys: c.keys } : {},
@@ -748,13 +941,40 @@ function planComposition(log, opts = {}) {
748
941
  ...path ? { path } : {},
749
942
  ...ease ? { ease } : {}
750
943
  };
944
+ return {
945
+ beat,
946
+ ev
947
+ };
948
+ });
949
+ const beats = scaffolds.map((s) => s.beat);
950
+ const last = log.events.length ? log.events[log.events.length - 1] : void 0;
951
+ const lastEnd = last ? last.tMs + ("durationMs" in last ? last.durationMs : 0) : 0;
952
+ const endMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs);
953
+ const framings = camera.enabled ? directCamera(beats, {
954
+ w: vW,
955
+ h: vH
956
+ }, {
957
+ w: oW,
958
+ h: oH
959
+ }, camera, rest, endMs) : beats.map((b) => manualFraming(b, rest));
960
+ const events = scaffolds.map((s, i) => {
961
+ const f = framings[i];
962
+ const zoom = {
963
+ enabled: f.enabled,
964
+ scale: f.scale,
965
+ center: f.center,
966
+ inAtMs: Math.max(0, s.beat.tMs - cursor.zoomInMs),
967
+ reason: f.reason
968
+ };
969
+ return {
970
+ ...s.ev,
971
+ zoom
972
+ };
751
973
  });
752
974
  const start = log.start ? mapPt(log.start) : {
753
975
  x: vW * .25,
754
976
  y: vH * .9
755
977
  };
756
- const last = log.events.length ? log.events[log.events.length - 1] : void 0;
757
- const lastEnd = last ? last.tMs + ("durationMs" in last ? last.durationMs : 0) : 0;
758
978
  const durationMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs + cursor.zoomOutMs + 400);
759
979
  return {
760
980
  output: {
@@ -776,6 +996,23 @@ function planComposition(log, opts = {}) {
776
996
  durationMs
777
997
  };
778
998
  }
999
+ function manualFraming(b, rest) {
1000
+ if (b.intent === "always" && b.box) return {
1001
+ enabled: true,
1002
+ scale: rest,
1003
+ center: {
1004
+ x: b.box.x + b.box.w / 2,
1005
+ y: b.box.y + b.box.h / 2
1006
+ },
1007
+ reason: "camera off · plan: zoom=always (raise scale by hand)"
1008
+ };
1009
+ return {
1010
+ enabled: false,
1011
+ scale: rest,
1012
+ center: b.point,
1013
+ reason: "camera off · full view (no explicit zoom)"
1014
+ };
1015
+ }
779
1016
 
780
1017
  //#endregion
781
1018
  //#region src/validate.ts
@@ -841,6 +1078,11 @@ function validateComposition(comp, opts = {}) {
841
1078
  if (!e.bbox) warn(`${p}.zoom`, "zoom enabled on a beat with no bbox — center/scale are hand-set, not bbox-fit", "double-check the center frames the intended region");
842
1079
  }
843
1080
  }
1081
+ const spring = comp.cursor.zoomSpring;
1082
+ if (spring != null) {
1083
+ if (!(spring >= 0 && spring < .6)) err("cursor.zoomSpring", `zoomSpring ${spring} outside [0, 0.6)`, "0 = critically damped (the default feel); keep bounce ≤ 0.3");
1084
+ else if (spring > .3) warn("cursor.zoomSpring", `zoomSpring ${spring} is a lot of bounce — the rect overshoot can flash backdrop at edge-flush targets and over-zoom tight punches`, "keep ≤ 0.3 (the editor slider's max) unless the flash is wanted");
1085
+ }
844
1086
  if (comp.durationMs < lastEnd) err("durationMs", `durationMs ${comp.durationMs} ends before the last action (${lastEnd})`, `raise to ≥ ${Math.round(lastEnd + comp.cursor.holdMs + comp.cursor.zoomOutMs)}`);
845
1087
  else if (comp.durationMs < lastEnd + comp.cursor.zoomOutMs) warn("durationMs", `only ${comp.durationMs - lastEnd}ms after the last action — the final zoom-out may be cut`, `allow ≥ ${comp.cursor.zoomOutMs}ms (cursor.zoomOutMs) of tail`);
846
1088
  if (opts.captureLog) {
@@ -1069,5 +1311,5 @@ async function renderTakeExclusive(opts) {
1069
1311
  }
1070
1312
 
1071
1313
  //#endregion
1072
- export { DEFAULT_CURSOR, DEFAULT_FRAMING, DEFAULT_MOTION_BLUR, FINISH, LOOKS, MOTION, ZOOM_LEVELS, finishName, formatIssues, lookName, math_exports as math, motionBlurActive, motionName, planComposition, renderTake, resolveFfmpeg, resolveFfprobe, validateComposition, zoomLevelName };
1314
+ export { DEFAULT_CAMERA, DEFAULT_CURSOR, DEFAULT_FRAMING, DEFAULT_MOTION_BLUR, FINISH, LOOKS, MOTION, ZOOM_LEVELS, directCamera, finishName, formatIssues, lookName, math_exports as math, motionBlurActive, motionName, planComposition, renderTake, resolveFfmpeg, resolveFfprobe, validateComposition, zoomLevelName };
1073
1315
  //# sourceMappingURL=index.js.map