@open-take/compositor 0.1.2 → 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.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) {
370
+ function keyvalN(t, kfs, ease = smoother) {
365
371
  if (t <= kfs[0][0]) return kfs[0][1];
366
372
  if (t >= kfs[kfs.length - 1][0]) return kfs[kfs.length - 1][1];
367
373
  for (let i = 0; i < kfs.length - 1; i++) {
368
374
  const [t0, v0] = kfs[i];
369
375
  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) {
378
- if (t <= kfs[0][0]) return kfs[0][1];
379
- if (t >= kfs[kfs.length - 1][0]) return kfs[kfs.length - 1][1];
380
- for (let i = 0; i < kfs.length - 1; i++) {
381
- const [t0, v0] = kfs[i];
382
- 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
493
+ const push = (t, r) => {
494
+ rf.push({
495
+ t: Math.max(t, rf[rf.length - 1].t + .001),
496
+ r
473
497
  });
474
498
  };
475
- const pushC = (t, c) => {
476
- cf.push({
477
- t: Math.max(t, cf[cf.length - 1].t + .001),
478
- c
479
- });
480
- };
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
- };
510
- }
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);
514
+ }, e.scale);
515
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,6 +646,200 @@ 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. Prefers the
677
+ * captured effect region; else shapes one from the element bbox by kind. */
678
+ function roiForBeat(b, video) {
679
+ if (b.effectBox) return b.effectBox;
680
+ if (!b.box) return void 0;
681
+ if (b.kind === "type") return growDown(b.box, video);
682
+ return b.box;
683
+ }
684
+ function clusterLabel(beats, idx) {
685
+ const raw = beats[idx[0]]?.label ?? "";
686
+ const cleaned = raw.replace(/\s+/g, " ").trim().slice(0, 18);
687
+ return cleaned || "region";
688
+ }
689
+ /**
690
+ * Decide framing for every beat. `endMs` is when the last frame can hold until
691
+ * (for the min-hold check on the final segment). `rest` is the stage scale at
692
+ * full view.
693
+ */
694
+ function directCamera(beats, video, out, cam, rest, endMs) {
695
+ const restC = {
696
+ x: video.w / 2,
697
+ y: video.h / 2
698
+ };
699
+ const fit = (roi) => bboxFitScale(roi, out.w, out.h, cam.fillFrac, cam.maxScale, rest);
700
+ const nodes = beats.map((b, i) => {
701
+ const roi = roiForBeat(b, video);
702
+ const scale = roi ? fit(roi) : rest;
703
+ const gapBreak = i > 0 && b.tMs - (beats[i - 1].tMs + beats[i - 1].durationMs) > cam.coalesceWindowMs;
704
+ if (b.intent === "never") return {
705
+ roi,
706
+ scale,
707
+ forceFull: true,
708
+ forcePunch: false,
709
+ boundary: true,
710
+ reason: "plan: zoom=never → full view"
711
+ };
712
+ if (b.intent === "always") return {
713
+ roi,
714
+ scale,
715
+ forceFull: false,
716
+ forcePunch: true,
717
+ boundary: true,
718
+ reason: roi ? `plan: zoom=always → ${scale.toFixed(2)}× (framing from ROI)` : "plan: zoom=always but no bbox to frame → full view"
719
+ };
720
+ if (b.kind === "scroll") return {
721
+ roi: void 0,
722
+ scale: rest,
723
+ forceFull: true,
724
+ forcePunch: false,
725
+ boundary: true,
726
+ reason: "scroll — full view (content pans)"
727
+ };
728
+ if (b.kind === "press" && b.keys && /(^|\+)esc(ape)?$/i.test(b.keys.trim())) return {
729
+ roi: void 0,
730
+ scale: rest,
731
+ forceFull: true,
732
+ forcePunch: false,
733
+ boundary: true,
734
+ reason: "Escape (dismissal) — full view"
735
+ };
736
+ if (b.changeCoverage != null && b.changeCoverage >= cam.pullOutCoverage) return {
737
+ roi,
738
+ scale,
739
+ forceFull: true,
740
+ forcePunch: false,
741
+ boundary: true,
742
+ reason: `changeCoverage ${b.changeCoverage.toFixed(2)} ≥ ${cam.pullOutCoverage} (global repaint) → full view`
743
+ };
744
+ if (i === 0) return {
745
+ roi,
746
+ scale,
747
+ forceFull: true,
748
+ forcePunch: false,
749
+ boundary: true,
750
+ reason: "opening beat — full view (orienting)"
751
+ };
752
+ if (!roi) return {
753
+ roi,
754
+ scale,
755
+ forceFull: true,
756
+ forcePunch: false,
757
+ boundary: true,
758
+ reason: "no bbox to frame → full view"
759
+ };
760
+ if (scale < cam.minZoomScale) return {
761
+ roi,
762
+ scale,
763
+ forceFull: true,
764
+ forcePunch: false,
765
+ boundary: gapBreak,
766
+ reason: `ROI fills the frame already (fit ${scale.toFixed(2)}× < ${cam.minZoomScale}×) — full view`
767
+ };
768
+ return {
769
+ roi,
770
+ scale,
771
+ forceFull: false,
772
+ forcePunch: false,
773
+ boundary: gapBreak,
774
+ reason: `punch ${scale.toFixed(2)}× (ROI-fit)`
775
+ };
776
+ });
777
+ const segs = [];
778
+ for (let i = 0; i < nodes.length; i++) {
779
+ const n = nodes[i];
780
+ if (n.forceFull) {
781
+ segs.push({
782
+ idx: [i],
783
+ kind: "rest",
784
+ scale: rest,
785
+ center: restC
786
+ });
787
+ continue;
788
+ }
789
+ const last = segs[segs.length - 1];
790
+ const lastLastIdx = last ? last.idx[last.idx.length - 1] : -1;
791
+ 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;
792
+ if (canExtend && last && last.roi && n.roi) {
793
+ const roi = union(last.roi, n.roi);
794
+ last.idx.push(i);
795
+ last.roi = roi;
796
+ last.scale = fit(roi);
797
+ last.center = centerOf(roi);
798
+ } else segs.push({
799
+ idx: [i],
800
+ kind: "punch",
801
+ roi: n.roi,
802
+ scale: n.scale,
803
+ center: n.roi ? centerOf(n.roi) : restC
804
+ });
805
+ }
806
+ segs.forEach((s, si) => {
807
+ if (s.kind !== "punch") return;
808
+ const firstT = beats[s.idx[0]].tMs;
809
+ const next = segs[si + 1];
810
+ const heldUntil = next ? beats[next.idx[0]].tMs : endMs;
811
+ if (heldUntil - firstT < cam.minHoldMs) {
812
+ s.kind = "rest";
813
+ s.scale = rest;
814
+ s.center = restC;
815
+ s.dropped = true;
816
+ }
817
+ });
818
+ const framings = new Array(beats.length);
819
+ for (const s of segs) {
820
+ if (s.kind === "rest") {
821
+ for (const i of s.idx) framings[i] = {
822
+ enabled: false,
823
+ scale: rest,
824
+ center: restC,
825
+ reason: s.dropped ? `punch dropped — held < ${cam.minHoldMs}ms (would flinch) → full view` : nodes[i].reason
826
+ };
827
+ continue;
828
+ }
829
+ const N = s.idx.length;
830
+ const tag = N > 1 ? `cluster[${clusterLabel(beats, s.idx)}]` : null;
831
+ s.idx.forEach((i, k) => {
832
+ framings[i] = {
833
+ enabled: true,
834
+ scale: s.scale,
835
+ center: s.center,
836
+ reason: tag ? `${tag} ${k + 1}/${N} · hold shared framing (union of ${N}) → ${s.scale.toFixed(2)}×` : nodes[i].reason
837
+ };
838
+ });
839
+ }
840
+ return framings;
841
+ }
842
+
638
843
  //#endregion
639
844
  //#region src/plan.ts
640
845
  function planComposition(log, opts = {}) {
@@ -642,10 +847,6 @@ function planComposition(log, opts = {}) {
642
847
  const oW = opts.output?.width ?? vW;
643
848
  const oH = opts.output?.height ?? vH;
644
849
  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
850
  const framing = {
650
851
  ...DEFAULT_FRAMING,
651
852
  ...opts.framing
@@ -654,6 +855,10 @@ function planComposition(log, opts = {}) {
654
855
  ...DEFAULT_CURSOR,
655
856
  ...opts.cursor
656
857
  };
858
+ const camera = {
859
+ ...DEFAULT_CAMERA,
860
+ ...opts.camera
861
+ };
657
862
  const sx = vW / log.viewport.w;
658
863
  const sy = vH / log.viewport.h;
659
864
  const mapPt = (p) => ({
@@ -677,13 +882,12 @@ function planComposition(log, opts = {}) {
677
882
  h: Math.max(...ys) - y
678
883
  };
679
884
  };
680
- const events = log.events.map((c, i) => {
885
+ const scaffolds = log.events.map((c) => {
681
886
  const kind = c.kind ?? "click";
682
887
  const point = mapPt({
683
888
  x: c.x,
684
889
  y: c.y
685
890
  });
686
- const isFirst = i === 0;
687
891
  const intent = c.zoom ?? "auto";
688
892
  const durationMs$1 = "durationMs" in c ? c.durationMs : 0;
689
893
  const to = kind === "drag" ? mapPt(c.to) : void 0;
@@ -694,53 +898,26 @@ function planComposition(log, opts = {}) {
694
898
  const path = rawPath?.map(mapPt);
695
899
  const ease = kind === "drag" ? c.ease : void 0;
696
900
  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 {
901
+ const effectBox = c.effectBox ? mapBox(c.effectBox) : void 0;
902
+ const label = c.sel ?? c.note;
903
+ const beat = {
904
+ kind,
905
+ tMs: c.tMs,
906
+ durationMs: durationMs$1,
907
+ box: bbox,
908
+ effectBox,
909
+ changeCoverage: c.changeCoverage,
910
+ point,
911
+ intent,
912
+ label,
913
+ ...kind === "press" ? { keys: c.keys } : {}
914
+ };
915
+ const ev = {
732
916
  kind,
733
917
  tMs: c.tMs,
734
918
  point,
735
919
  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
- },
920
+ label,
744
921
  ...durationMs$1 ? { durationMs: durationMs$1 } : {},
745
922
  ...kind === "type" ? { text: c.text } : {},
746
923
  ...kind === "press" ? { keys: c.keys } : {},
@@ -748,13 +925,40 @@ function planComposition(log, opts = {}) {
748
925
  ...path ? { path } : {},
749
926
  ...ease ? { ease } : {}
750
927
  };
928
+ return {
929
+ beat,
930
+ ev
931
+ };
932
+ });
933
+ const beats = scaffolds.map((s) => s.beat);
934
+ const last = log.events.length ? log.events[log.events.length - 1] : void 0;
935
+ const lastEnd = last ? last.tMs + ("durationMs" in last ? last.durationMs : 0) : 0;
936
+ const endMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs);
937
+ const framings = camera.enabled ? directCamera(beats, {
938
+ w: vW,
939
+ h: vH
940
+ }, {
941
+ w: oW,
942
+ h: oH
943
+ }, camera, rest, endMs) : beats.map((b) => manualFraming(b, rest));
944
+ const events = scaffolds.map((s, i) => {
945
+ const f = framings[i];
946
+ const zoom = {
947
+ enabled: f.enabled,
948
+ scale: f.scale,
949
+ center: f.center,
950
+ inAtMs: Math.max(0, s.beat.tMs - cursor.zoomInMs),
951
+ reason: f.reason
952
+ };
953
+ return {
954
+ ...s.ev,
955
+ zoom
956
+ };
751
957
  });
752
958
  const start = log.start ? mapPt(log.start) : {
753
959
  x: vW * .25,
754
960
  y: vH * .9
755
961
  };
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
962
  const durationMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs + cursor.zoomOutMs + 400);
759
963
  return {
760
964
  output: {
@@ -776,6 +980,23 @@ function planComposition(log, opts = {}) {
776
980
  durationMs
777
981
  };
778
982
  }
983
+ function manualFraming(b, rest) {
984
+ if (b.intent === "always" && b.box) return {
985
+ enabled: true,
986
+ scale: rest,
987
+ center: {
988
+ x: b.box.x + b.box.w / 2,
989
+ y: b.box.y + b.box.h / 2
990
+ },
991
+ reason: "camera off · plan: zoom=always (raise scale by hand)"
992
+ };
993
+ return {
994
+ enabled: false,
995
+ scale: rest,
996
+ center: b.point,
997
+ reason: "camera off · full view (no explicit zoom)"
998
+ };
999
+ }
779
1000
 
780
1001
  //#endregion
781
1002
  //#region src/validate.ts
@@ -841,6 +1062,11 @@ function validateComposition(comp, opts = {}) {
841
1062
  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
1063
  }
843
1064
  }
1065
+ const spring = comp.cursor.zoomSpring;
1066
+ if (spring != null) {
1067
+ if (!(spring >= 0 && spring < .6)) err("cursor.zoomSpring", `zoomSpring ${spring} outside [0, 0.6)`, "0 = critically damped (the default feel); keep bounce ≤ 0.3");
1068
+ 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");
1069
+ }
844
1070
  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
1071
  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
1072
  if (opts.captureLog) {
@@ -1069,5 +1295,5 @@ async function renderTakeExclusive(opts) {
1069
1295
  }
1070
1296
 
1071
1297
  //#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 };
1298
+ 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
1299
  //# sourceMappingURL=index.js.map