@open-take/compositor 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,11 +1,24 @@
1
1
  import { __export } from "./chunk-Cl8Af3a2.js";
2
2
  import { spawn, spawnSync } from "node:child_process";
3
- import { copyFile, mkdir, writeFile } from "node:fs/promises";
4
- import { dirname, resolve } from "node:path";
3
+ import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
4
+ import { existsSync, realpathSync } from "node:fs";
5
+ import { createRequire } from "node:module";
6
+ import { tmpdir } from "node:os";
7
+ import { dirname, join, resolve, sep } from "node:path";
5
8
  import { fileURLToPath } from "node:url";
6
- import { renderVideo } from "@revideo/renderer";
9
+ import { renderVideo } from "@open-take/revideo-renderer";
7
10
 
8
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
+ };
9
22
  /** True when motion blur is configured to actually do something (so the OFF
10
23
  * path stays byte-identical to the pre-motion-blur renderer). */
11
24
  function motionBlurActive(mb) {
@@ -41,14 +54,8 @@ const DEFAULT_CURSOR = {
41
54
  arcMax: 24,
42
55
  rippleMs: 450,
43
56
  holdMs: 1100,
44
- zoomOutMs: 800,
45
- zoomInMs: 760,
46
- zoomEase: [
47
- .3,
48
- 0,
49
- .2,
50
- 1
51
- ],
57
+ zoomOutMs: 1340,
58
+ zoomInMs: 730,
52
59
  dragLagMs: 190,
53
60
  travelEase: [
54
61
  .3,
@@ -83,20 +90,23 @@ const MOTION = {
83
90
  calm: {
84
91
  travelWidthsPerSec: .28,
85
92
  holdMs: 1500,
86
- zoomInMs: 950,
87
- zoomOutMs: 950
93
+ zoomInMs: 900,
94
+ zoomOutMs: 1650,
95
+ zoomEase: void 0
88
96
  },
89
97
  natural: {
90
98
  travelWidthsPerSec: .35,
91
99
  holdMs: 1100,
92
- zoomInMs: 760,
93
- zoomOutMs: 800
100
+ zoomInMs: 730,
101
+ zoomOutMs: 1340,
102
+ zoomEase: void 0
94
103
  },
95
104
  brisk: {
96
105
  travelWidthsPerSec: .45,
97
106
  holdMs: 750,
98
- zoomInMs: 560,
99
- zoomOutMs: 600
107
+ zoomInMs: 550,
108
+ zoomOutMs: 1e3,
109
+ zoomEase: void 0
100
110
  }
101
111
  };
102
112
  function motionName(cursor) {
@@ -224,6 +234,23 @@ async function installerPath(pkg) {
224
234
  return null;
225
235
  }
226
236
  }
237
+ /** Some pnpm installs leave the platform packages' media binaries without an
238
+ * executable bit. Repair the resolved target at the point of use as well as
239
+ * in the repo postinstall: published consumers do not run this monorepo's
240
+ * root lifecycle script. Windows does not use POSIX execute bits. */
241
+ async function ensureExecutable(path) {
242
+ if (!path || process.platform === "win32") return;
243
+ try {
244
+ const info = await stat(path);
245
+ if ((info.mode & 73) === 0) await chmod(path, info.mode | 73);
246
+ } catch {}
247
+ }
248
+ /** Revideo resolves its own bundled ffprobe rather than calling
249
+ * resolveFfprobe(), so repair both installer targets before invoking it. */
250
+ async function repairBundledMediaPermissions() {
251
+ const paths = await Promise.all([installerPath("@ffmpeg-installer/ffmpeg"), installerPath("@ffprobe-installer/ffprobe")]);
252
+ await Promise.all(paths.map(ensureExecutable));
253
+ }
227
254
  async function resolveFfmpeg() {
228
255
  if (cachedFfmpeg) return cachedFfmpeg;
229
256
  if (runsOk("ffmpeg")) {
@@ -231,6 +258,7 @@ async function resolveFfmpeg() {
231
258
  return cachedFfmpeg;
232
259
  }
233
260
  const p = await installerPath("@ffmpeg-installer/ffmpeg");
261
+ await ensureExecutable(p);
234
262
  if (p && runsOk(p)) {
235
263
  cachedFfmpeg = p;
236
264
  return p;
@@ -244,6 +272,7 @@ async function resolveFfprobe() {
244
272
  return cachedFfprobe;
245
273
  }
246
274
  const p = await installerPath("@ffprobe-installer/ffprobe");
275
+ await ensureExecutable(p);
247
276
  if (p && runsOk(p)) {
248
277
  cachedFfprobe = p;
249
278
  return p;
@@ -264,16 +293,16 @@ __export(math_exports, {
264
293
  gradientEndpoints: () => gradientEndpoints,
265
294
  isDragging: () => isDragging,
266
295
  keyvalN: () => keyvalN,
267
- keyvalP: () => keyvalP,
268
- panEasing: () => panEasing,
296
+ keyvalR: () => keyvalR,
269
297
  restStageScale: () => restStageScale,
270
298
  smoother: () => smoother,
271
299
  springEase: () => springEase,
300
+ stageCamera: () => stageCamera,
272
301
  stageEasing: () => stageEasing
273
302
  });
274
303
  function smoother(t) {
275
- t = Math.max(0, Math.min(1, t));
276
- return t * t * t * (t * (t * 6 - 15) + 10);
304
+ const clamped = Math.max(0, Math.min(1, t));
305
+ return clamped * clamped * clamped * (clamped * (clamped * 6 - 15) + 10);
277
306
  }
278
307
  function cubicBezier(x1, y1, x2, y2) {
279
308
  const bx = (s) => {
@@ -301,23 +330,22 @@ function cubicBezier(x1, y1, x2, y2) {
301
330
  function springEase(bounce) {
302
331
  const zeta = Math.max(.4, Math.min(1, 1 - bounce));
303
332
  const Ts = -Math.log(.001) / zeta;
304
- return (p) => {
305
- if (p <= 0) return 0;
306
- if (p >= 1) return 1;
307
- const t = p * Ts;
333
+ const step = (t) => {
308
334
  if (zeta >= 1) return 1 - Math.exp(-t) * (1 + t);
309
335
  const wd = Math.sqrt(1 - zeta * zeta);
310
336
  return 1 - Math.exp(-zeta * t) * (Math.cos(wd * t) + zeta / wd * Math.sin(wd * t));
311
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
+ };
312
344
  }
313
345
  function stageEasing(cursor) {
314
346
  if (cursor.zoomSpring != null) return springEase(cursor.zoomSpring);
315
347
  if (cursor.zoomEase) return cubicBezier(...cursor.zoomEase);
316
- return smoother;
317
- }
318
- function panEasing(cursor) {
319
- if (cursor.zoomEase) return cubicBezier(...cursor.zoomEase);
320
- return smoother;
348
+ return springEase(0);
321
349
  }
322
350
  function gradientEndpoints(angleDeg, oW, oH) {
323
351
  if (angleDeg == null) return {
@@ -339,32 +367,13 @@ function gradientEndpoints(angleDeg, oW, oH) {
339
367
  y1: cy + dy * L / 2
340
368
  };
341
369
  }
342
- function keyvalN(t, kfs, ease = smoother, easeDown) {
370
+ function keyvalN(t, kfs, ease = smoother) {
343
371
  if (t <= kfs[0][0]) return kfs[0][1];
344
372
  if (t >= kfs[kfs.length - 1][0]) return kfs[kfs.length - 1][1];
345
373
  for (let i = 0; i < kfs.length - 1; i++) {
346
374
  const [t0, v0] = kfs[i];
347
375
  const [t1, v1] = kfs[i + 1];
348
- if (t0 <= t && t <= t1) {
349
- const e = easeDown && v1 < v0 ? easeDown : ease;
350
- return v0 + (v1 - v0) * e((t - t0) / (t1 - t0));
351
- }
352
- }
353
- return kfs[kfs.length - 1][1];
354
- }
355
- function keyvalP(t, kfs, ease = smoother) {
356
- if (t <= kfs[0][0]) return kfs[0][1];
357
- if (t >= kfs[kfs.length - 1][0]) return kfs[kfs.length - 1][1];
358
- for (let i = 0; i < kfs.length - 1; i++) {
359
- const [t0, v0] = kfs[i];
360
- const [t1, v1] = kfs[i + 1];
361
- if (t0 <= t && t <= t1) {
362
- const p = ease((t - t0) / (t1 - t0));
363
- return {
364
- x: v0.x + (v1.x - v0.x) * p,
365
- y: v0.y + (v1.y - v0.y) * p
366
- };
367
- }
376
+ if (t0 <= t && t <= t1) return v0 + (v1 - v0) * ease((t - t0) / (t1 - t0));
368
377
  }
369
378
  return kfs[kfs.length - 1][1];
370
379
  }
@@ -403,6 +412,30 @@ function clampCenter(center, scale, videoW, videoH, outW, outH) {
403
412
  y: cy
404
413
  };
405
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
+ }
406
439
  function buildStageKeyframes(comp) {
407
440
  const { videoWidth: vW, videoHeight: vH } = comp.source;
408
441
  const { width: oW, height: oH } = comp.output;
@@ -412,7 +445,16 @@ function buildStageKeyframes(comp) {
412
445
  y: vH / 2
413
446
  };
414
447
  const HOLD = comp.cursor.holdMs / 1e3;
415
- 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);
416
458
  const anchors = [];
417
459
  for (const e of comp.events) {
418
460
  const base = {
@@ -432,95 +474,86 @@ function buildStageKeyframes(comp) {
432
474
  glide: e.zoom.glide
433
475
  });
434
476
  }
435
- const zf = [{
436
- t: 0,
437
- s: rest
438
- }];
439
- 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 = [{
440
490
  t: 0,
441
- c: restC
491
+ r: restR
442
492
  }];
443
- const push = (t, s, c) => {
444
- zf.push({
445
- t: Math.max(t, zf[zf.length - 1].t + .001),
446
- s
493
+ const push = (t, r) => {
494
+ rf.push({
495
+ t: Math.max(t, rf[rf.length - 1].t + .001),
496
+ r
447
497
  });
448
- cf.push({
449
- t: Math.max(t, cf[cf.length - 1].t + .001),
450
- c
451
- });
452
- };
453
- const pushC = (t, c) => {
454
- cf.push({
455
- t: Math.max(t, cf[cf.length - 1].t + .001),
456
- c
457
- });
458
- };
459
- const ze = panEasing(comp.cursor);
460
- const invEase = (target) => {
461
- let lo = 0;
462
- let hi = 1;
463
- for (let i = 0; i < 30; i++) {
464
- const m = (lo + hi) / 2;
465
- if (ze(m) < target) lo = m;
466
- else hi = m;
467
- }
468
- return (lo + hi) / 2;
469
- };
470
- const fillThreshold = Math.max(oW / vW, oH / vH);
471
- let cur = {
472
- s: rest,
473
- c: restC
474
498
  };
499
+ let cur = restR;
475
500
  anchors.forEach((e, i) => {
476
- const rampStart = e.inAtMs / 1e3;
477
501
  const clickT = e.tMs / 1e3;
478
502
  const actionEnd = (e.tMs + e.durationMs) / 1e3;
479
503
  const next = anchors[i + 1];
480
- const holdEndT = next ? next.inAtMs / 1e3 : actionEnd + HOLD;
481
- 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;
482
509
  if (e.glide && (e.glide.x !== 0 || e.glide.y !== 0)) {
483
510
  const holdDur = Math.max(0, holdEndT - clickT);
484
- holdC = {
511
+ holdR = rectFor({
485
512
  x: e.center.x + e.glide.x * holdDur,
486
513
  y: e.center.y + e.glide.y * holdDur
487
- };
488
- }
489
- push(rampStart, cur.s, cur.c);
490
- 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) {
491
- const uCross = invEase((fillThreshold - cur.s) / (e.scale - cur.s));
492
- pushC(rampStart + uCross * (clickT - rampStart), e.center);
514
+ }, e.scale);
493
515
  }
494
- push(clickT, e.scale, e.center);
495
- cur = {
496
- s: e.scale,
497
- c: holdC
498
- };
499
- if (next) push(holdEndT, cur.s, cur.c);
500
- else {
501
- const holdEnd = holdEndT;
502
- push(holdEnd, cur.s, cur.c);
503
- const offset = Math.hypot(cur.c.x - restC.x, cur.c.y - restC.y) > 1;
504
- if (offset && cur.s > fillThreshold && fillThreshold > rest) {
505
- const uCross = invEase((fillThreshold - cur.s) / (rest - cur.s));
506
- pushC(holdEnd + uCross * ZOUT, restC);
507
- }
508
- push(holdEnd + ZOUT, rest, restC);
509
- cur = {
510
- s: rest,
511
- c: restC
512
- };
516
+ push(holdEndT, holdR);
517
+ cur = holdR;
518
+ if (!next) {
519
+ push(holdEndT + ZOUT_MS / 1e3, restR);
520
+ cur = restR;
513
521
  }
514
522
  });
515
- const lastT = Math.max(zf[zf.length - 1].t, cf[cf.length - 1].t);
523
+ const lastT = rf[rf.length - 1].t;
516
524
  const T = Math.max(comp.durationMs / 1e3, lastT) + .3;
517
- push(T, rest, restC);
525
+ push(T, restR);
518
526
  return {
519
- z: zf.map((f) => [f.t, f.s]),
520
- c: cf.map((f) => [f.t, f.c]),
527
+ r: rf.map((f) => [f.t, f.r]),
521
528
  T
522
529
  };
523
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
+ }
524
557
  function buildLegs(comp) {
525
558
  const legs = [];
526
559
  let cur = comp.start;
@@ -528,8 +561,8 @@ function buildLegs(comp) {
528
561
  const speedPxPerMs = (travelWidthsPerSec || 0) * comp.source.videoWidth / 1e3;
529
562
  const travelDur = (a, b) => {
530
563
  if (speedPxPerMs <= 0) return travelMs / 1e3;
531
- const dist = Math.hypot(b.x - a.x, b.y - a.y);
532
- 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;
533
566
  };
534
567
  for (const e of comp.events) {
535
568
  if (e.kind === "scroll" || e.kind === "press") continue;
@@ -613,6 +646,200 @@ function isDragging(t, legs) {
613
646
  return legs.some((lg) => lg.drag === true && lg.t0 <= t && t <= lg.t1);
614
647
  }
615
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
+
616
843
  //#endregion
617
844
  //#region src/plan.ts
618
845
  function planComposition(log, opts = {}) {
@@ -620,10 +847,6 @@ function planComposition(log, opts = {}) {
620
847
  const oW = opts.output?.width ?? vW;
621
848
  const oH = opts.output?.height ?? vH;
622
849
  const fps = opts.output?.fps ?? 30;
623
- const fillFrac = opts.fillFrac ?? .55;
624
- const maxScale = opts.maxScale ?? 1.5;
625
- const zoomRatio = opts.zoomRatio ?? 1.3;
626
- const zoomFirst = opts.zoomFirst ?? false;
627
850
  const framing = {
628
851
  ...DEFAULT_FRAMING,
629
852
  ...opts.framing
@@ -632,6 +855,10 @@ function planComposition(log, opts = {}) {
632
855
  ...DEFAULT_CURSOR,
633
856
  ...opts.cursor
634
857
  };
858
+ const camera = {
859
+ ...DEFAULT_CAMERA,
860
+ ...opts.camera
861
+ };
635
862
  const sx = vW / log.viewport.w;
636
863
  const sy = vH / log.viewport.h;
637
864
  const mapPt = (p) => ({
@@ -655,13 +882,12 @@ function planComposition(log, opts = {}) {
655
882
  h: Math.max(...ys) - y
656
883
  };
657
884
  };
658
- const events = log.events.map((c, i) => {
885
+ const scaffolds = log.events.map((c) => {
659
886
  const kind = c.kind ?? "click";
660
887
  const point = mapPt({
661
888
  x: c.x,
662
889
  y: c.y
663
890
  });
664
- const isFirst = i === 0;
665
891
  const intent = c.zoom ?? "auto";
666
892
  const durationMs$1 = "durationMs" in c ? c.durationMs : 0;
667
893
  const to = kind === "drag" ? mapPt(c.to) : void 0;
@@ -672,53 +898,26 @@ function planComposition(log, opts = {}) {
672
898
  const path = rawPath?.map(mapPt);
673
899
  const ease = kind === "drag" ? c.ease : void 0;
674
900
  const bbox = kind === "drag" && path ? pathBBox(path) : c.box ? mapBox(c.box) : void 0;
675
- let enabled = false;
676
- let scale = rest;
677
- let reason;
678
- const center = bbox ? {
679
- x: bbox.x + bbox.w / 2,
680
- y: bbox.y + bbox.h / 2
681
- } : point;
682
- const fit = bbox ? bboxFitScale(bbox, oW, oH, fillFrac, maxScale, rest) : maxScale;
683
- if (kind === "scroll") {
684
- enabled = false;
685
- reason = "scroll — full view (content pans, no zoom)";
686
- } else if (intent === "never") {
687
- enabled = false;
688
- reason = "plan: zoom=never (global/navigation payoff — keep full view)";
689
- } else if (intent === "always") {
690
- enabled = true;
691
- scale = fit;
692
- reason = `plan: zoom=always → ${fit.toFixed(2)}x (capped ${maxScale}x)`;
693
- } else if (!bbox) reason = "no bbox in event log — cannot bbox-fit, so no zoom (avoids framing dead space)";
694
- else {
695
- scale = fit;
696
- const meaningful = fit > rest * zoomRatio;
697
- const region = kind === "drag" ? "drag path" : "element";
698
- if (isFirst && !zoomFirst) {
699
- enabled = false;
700
- reason = `first/orienting action — skipped by default (fit ${fit.toFixed(2)}x available)`;
701
- } else if (!meaningful) {
702
- enabled = false;
703
- reason = `${region} fills the frame already (fit ${fit.toFixed(2)}x ≈ rest ${rest.toFixed(2)}x) — gentle/no zoom`;
704
- } else {
705
- enabled = true;
706
- reason = `bbox-fit ${fit.toFixed(2)}x (capped ${maxScale}x), ${region} framed with ${Math.round(fillFrac * 100)}% fill`;
707
- }
708
- }
709
- 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 = {
710
916
  kind,
711
917
  tMs: c.tMs,
712
918
  point,
713
919
  bbox,
714
- label: c.sel ?? c.note,
715
- zoom: {
716
- enabled,
717
- scale,
718
- center,
719
- inAtMs: Math.max(0, c.tMs - cursor.zoomInMs),
720
- reason
721
- },
920
+ label,
722
921
  ...durationMs$1 ? { durationMs: durationMs$1 } : {},
723
922
  ...kind === "type" ? { text: c.text } : {},
724
923
  ...kind === "press" ? { keys: c.keys } : {},
@@ -726,13 +925,40 @@ function planComposition(log, opts = {}) {
726
925
  ...path ? { path } : {},
727
926
  ...ease ? { ease } : {}
728
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
+ };
729
957
  });
730
958
  const start = log.start ? mapPt(log.start) : {
731
959
  x: vW * .25,
732
960
  y: vH * .9
733
961
  };
734
- const last = log.events.length ? log.events[log.events.length - 1] : void 0;
735
- const lastEnd = last ? last.tMs + ("durationMs" in last ? last.durationMs : 0) : 0;
736
962
  const durationMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs + cursor.zoomOutMs + 400);
737
963
  return {
738
964
  output: {
@@ -754,6 +980,23 @@ function planComposition(log, opts = {}) {
754
980
  durationMs
755
981
  };
756
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
+ }
757
1000
 
758
1001
  //#endregion
759
1002
  //#region src/validate.ts
@@ -819,6 +1062,11 @@ function validateComposition(comp, opts = {}) {
819
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");
820
1063
  }
821
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
+ }
822
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)}`);
823
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`);
824
1072
  if (opts.captureLog) {
@@ -843,8 +1091,6 @@ function formatIssues(issues) {
843
1091
  //#endregion
844
1092
  //#region src/render.ts
845
1093
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
846
- const PUBLIC_MP4 = resolve(PKG_ROOT, "public/capture.mp4");
847
- const COMP_JSON = resolve(PKG_ROOT, "src/scene/.composition.json");
848
1094
  const RENDER_OUT = "out-render";
849
1095
  function run(cmd, args) {
850
1096
  return new Promise((res, rej) => {
@@ -854,7 +1100,9 @@ function run(cmd, args) {
854
1100
  "pipe"
855
1101
  ] });
856
1102
  let err = "";
857
- c.stderr.on("data", (d) => err += d);
1103
+ c.stderr.on("data", (d) => {
1104
+ err += d;
1105
+ });
858
1106
  c.on("error", rej);
859
1107
  c.on("close", (code) => code === 0 ? res() : rej(new Error(`${cmd} exited ${code}: ${err}`)));
860
1108
  });
@@ -911,7 +1159,80 @@ async function motionBlurMp4(inMp4, outMp4, baseFps, samples, shutter) {
911
1159
  outMp4
912
1160
  ]);
913
1161
  }
1162
+ /** The nearest node_modules above the installed package. Symlinked into the
1163
+ * scratch dir so the copied scene can still resolve `@revideo/*` — pnpm keeps
1164
+ * a node_modules beside the package, npm/yarn hoist it to the project root, so
1165
+ * walk up rather than assume either. */
1166
+ function hostNodeModules() {
1167
+ let dir = PKG_ROOT;
1168
+ for (;;) {
1169
+ const candidate = join(dir, "node_modules");
1170
+ if (existsSync(candidate)) return candidate;
1171
+ const up = dirname(dir);
1172
+ if (up === dir) return null;
1173
+ dir = up;
1174
+ }
1175
+ }
1176
+ /** The whole dependency tree in one directory, for vite's fs.allow. Vite serves
1177
+ * realpaths, and pnpm's are under `<root>/node_modules/.pnpm/…`, so the first
1178
+ * `node_modules` segment of a resolved dependency covers every layout. */
1179
+ function depsRoot() {
1180
+ try {
1181
+ const real = realpathSync(createRequire(import.meta.url).resolve("@revideo/core"));
1182
+ const parts = real.split(sep);
1183
+ const i = parts.indexOf("node_modules");
1184
+ return i === -1 ? null : parts.slice(0, i + 1).join(sep);
1185
+ } catch {
1186
+ return null;
1187
+ }
1188
+ }
1189
+ /** Copy a tree WITHOUT inheriting its permission bits. `fs.cp` preserves mode,
1190
+ * so copying out of a read-only install yields a read-only copy we then can't
1191
+ * write the composition into. Re-creating each file gives us the umask
1192
+ * default instead. The scene tree is a handful of small source files. */
1193
+ async function copyWritable(from, to) {
1194
+ await mkdir(to, { recursive: true });
1195
+ for (const e of await readdir(from, { withFileTypes: true })) {
1196
+ const src = join(from, e.name);
1197
+ const dst = join(to, e.name);
1198
+ if (e.isDirectory()) await copyWritable(src, dst);
1199
+ else if (e.isFile()) await writeFile(dst, await readFile(src));
1200
+ }
1201
+ }
1202
+ async function cleanupScratch(dir) {
1203
+ await unlink(join(dir, "node_modules")).catch(() => {});
1204
+ if (!process.env.OPEN_TAKE_KEEP_SCRATCH) await rm(dir, {
1205
+ recursive: true,
1206
+ force: true
1207
+ });
1208
+ else process.stderr.write(`render scratch kept: ${dir}\n`);
1209
+ }
1210
+ /** Build the throwaway directory this render runs in: the package's `src/`
1211
+ * (the scene and everything it imports), this render's composition, the
1212
+ * normalised capture as vite's public asset, and a node_modules link. */
1213
+ async function prepareScratch(composition, videoPath) {
1214
+ const dir = await mkdtemp(join(tmpdir(), "open-take-render-"));
1215
+ try {
1216
+ await copyWritable(join(PKG_ROOT, "src"), join(dir, "src"));
1217
+ await writeFile(join(dir, "src", "scene", ".composition.json"), JSON.stringify(composition, null, 2));
1218
+ const nm = hostNodeModules();
1219
+ if (!nm) throw new Error("render: could not locate node_modules for the scene's imports");
1220
+ await symlink(nm, join(dir, "node_modules"), "junction");
1221
+ await toMp4(videoPath, join(dir, "public", "capture.mp4"), composition.output.fps);
1222
+ return dir;
1223
+ } catch (error) {
1224
+ await cleanupScratch(dir);
1225
+ throw error;
1226
+ }
1227
+ }
1228
+ let renderQueue = Promise.resolve();
914
1229
  async function renderTake(opts) {
1230
+ const run$1 = renderQueue.then(() => renderTakeExclusive(opts), () => renderTakeExclusive(opts));
1231
+ renderQueue = run$1.catch(() => {});
1232
+ return run$1;
1233
+ }
1234
+ async function renderTakeExclusive(opts) {
1235
+ if (!opts.chromePath) throw new Error("renderTake: `chromePath` is required; use @open-take/runtime to resolve managed Chrome automatically");
915
1236
  const composition = opts.composition ?? planComposition(opts.log ?? (() => {
916
1237
  throw new Error("renderTake: provide `log` or `composition`");
917
1238
  })(), opts.planOpts);
@@ -922,52 +1243,57 @@ async function renderTake(opts) {
922
1243
  if (opts.logProgress && warns.length) process.stderr.write(`composition warnings:\n${formatIssues(warns)}\n`);
923
1244
  if (errors.length) throw new Error(`composition has ${errors.length} error(s) — refusing to render:\n${formatIssues(errors)}`);
924
1245
  }
925
- await toMp4(opts.videoPath, PUBLIC_MP4, composition.output.fps);
926
- await mkdir(dirname(COMP_JSON), { recursive: true });
927
- await writeFile(COMP_JSON, JSON.stringify(composition, null, 2));
928
- if (process.env.DISABLE_TELEMETRY === void 0) process.env.DISABLE_TELEMETRY = "true";
929
- const prevCwd = process.cwd();
930
- process.chdir(PKG_ROOT);
931
- let produced;
1246
+ await repairBundledMediaPermissions();
1247
+ const scratch = await prepareScratch(composition, opts.videoPath);
1248
+ const deps = depsRoot();
932
1249
  try {
933
- produced = await renderVideo({
934
- projectFile: "/src/scene/project.ts",
935
- settings: {
936
- outFile: "take.mp4",
937
- outDir: RENDER_OUT,
938
- workers: 1,
939
- ...opts.rangeSec ? { projectSettings: { range: opts.rangeSec } } : {},
940
- logProgress: opts.logProgress ?? false,
941
- ...opts.onProgress ? { progressCallback: (_worker, progress) => opts.onProgress(progress) } : {},
942
- puppeteer: {
943
- args: [
944
- "--no-sandbox",
945
- "--disable-setuid-sandbox",
946
- "--password-store=basic",
947
- "--use-mock-keychain"
948
- ],
949
- ...opts.chromePath ? { executablePath: opts.chromePath } : {}
1250
+ if (process.env.DISABLE_TELEMETRY === void 0) process.env.DISABLE_TELEMETRY = "true";
1251
+ const prevCwd = process.cwd();
1252
+ process.chdir(scratch);
1253
+ let produced;
1254
+ try {
1255
+ produced = await renderVideo({
1256
+ projectFile: "/src/scene/project.ts",
1257
+ settings: {
1258
+ outFile: "take.mp4",
1259
+ outDir: RENDER_OUT,
1260
+ workers: 1,
1261
+ ...opts.rangeSec ? { projectSettings: { range: opts.rangeSec } } : {},
1262
+ logProgress: opts.logProgress ?? false,
1263
+ ...opts.onProgress ? { progressCallback: (_worker, progress) => opts.onProgress(progress) } : {},
1264
+ viteConfig: { server: { fs: { allow: [scratch, ...deps ? [deps] : []] } } },
1265
+ puppeteer: {
1266
+ args: [
1267
+ "--no-sandbox",
1268
+ "--disable-setuid-sandbox",
1269
+ "--password-store=basic",
1270
+ "--use-mock-keychain"
1271
+ ],
1272
+ executablePath: opts.chromePath
1273
+ }
950
1274
  }
951
- }
952
- });
1275
+ });
1276
+ } finally {
1277
+ process.chdir(prevCwd);
1278
+ }
1279
+ await mkdir(dirname(resolve(opts.outPath)), { recursive: true });
1280
+ const producedAbs = resolve(scratch, produced);
1281
+ if (motionBlurActive(composition.motionBlur)) await motionBlurMp4(producedAbs, resolve(opts.outPath), composition.output.fps, composition.motionBlur.samples, composition.motionBlur.shutter);
1282
+ else await copyFile(producedAbs, resolve(opts.outPath));
1283
+ const compositionPath = resolve(opts.outPath).replace(/\.mp4$/i, "") + ".composition.json";
1284
+ if (opts.writeCompositionSibling !== false) {
1285
+ const { review: _review,...persisted } = composition;
1286
+ await writeFile(compositionPath, JSON.stringify(persisted, null, 2));
1287
+ }
1288
+ return {
1289
+ mp4Path: resolve(opts.outPath),
1290
+ compositionPath
1291
+ };
953
1292
  } finally {
954
- process.chdir(prevCwd);
955
- }
956
- await mkdir(dirname(resolve(opts.outPath)), { recursive: true });
957
- const producedAbs = resolve(PKG_ROOT, produced);
958
- if (motionBlurActive(composition.motionBlur)) await motionBlurMp4(producedAbs, resolve(opts.outPath), composition.output.fps, composition.motionBlur.samples, composition.motionBlur.shutter);
959
- else await copyFile(producedAbs, resolve(opts.outPath));
960
- const compositionPath = resolve(opts.outPath).replace(/\.mp4$/i, "") + ".composition.json";
961
- if (opts.writeCompositionSibling !== false) {
962
- const { review: _review,...persisted } = composition;
963
- await writeFile(compositionPath, JSON.stringify(persisted, null, 2));
1293
+ await cleanupScratch(scratch);
964
1294
  }
965
- return {
966
- mp4Path: resolve(opts.outPath),
967
- compositionPath
968
- };
969
1295
  }
970
1296
 
971
1297
  //#endregion
972
- 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 };
973
1299
  //# sourceMappingURL=index.js.map