@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.d.ts +151 -33
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +396 -170
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/camera.ts +324 -0
- package/src/index.ts +1 -0
- package/src/math.ts +156 -124
- package/src/plan.ts +102 -66
- package/src/presets.ts +10 -5
- package/src/scene/scene.tsx +8 -16
- package/src/types.ts +86 -23
- package/src/validate.ts +18 -0
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:
|
|
48
|
-
zoomInMs:
|
|
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:
|
|
90
|
-
zoomOutMs:
|
|
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:
|
|
96
|
-
zoomOutMs:
|
|
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:
|
|
102
|
-
zoomOutMs:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
491
|
+
r: restR
|
|
464
492
|
}];
|
|
465
|
-
const push = (t,
|
|
466
|
-
|
|
467
|
-
t: Math.max(t,
|
|
468
|
-
|
|
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
|
-
|
|
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 ?
|
|
503
|
-
|
|
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
|
-
|
|
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(
|
|
517
|
-
cur =
|
|
518
|
-
|
|
519
|
-
|
|
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 =
|
|
523
|
+
const lastT = rf[rf.length - 1].t;
|
|
538
524
|
const T = Math.max(comp.durationMs / 1e3, lastT) + .3;
|
|
539
|
-
push(T,
|
|
525
|
+
push(T, restR);
|
|
540
526
|
return {
|
|
541
|
-
|
|
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
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
|
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
|