@open-take/compositor 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +155 -33
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +413 -171
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/camera.ts +343 -0
- package/src/index.ts +1 -0
- package/src/math.ts +156 -124
- package/src/plan.ts +114 -69
- package/src/presets.ts +10 -5
- package/src/scene/scene.tsx +8 -16
- package/src/types.ts +90 -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
|
|
365
|
-
if (t <= kfs[0][0]) return kfs[0][1];
|
|
366
|
-
if (t >= kfs[kfs.length - 1][0]) return kfs[kfs.length - 1][1];
|
|
367
|
-
for (let i = 0; i < kfs.length - 1; i++) {
|
|
368
|
-
const [t0, v0] = kfs[i];
|
|
369
|
-
const [t1, v1] = kfs[i + 1];
|
|
370
|
-
if (t0 <= t && t <= t1) {
|
|
371
|
-
const e = easeDown && v1 < v0 ? easeDown : ease;
|
|
372
|
-
return v0 + (v1 - v0) * e((t - t0) / (t1 - t0));
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
return kfs[kfs.length - 1][1];
|
|
376
|
-
}
|
|
377
|
-
function keyvalP(t, kfs, ease = smoother) {
|
|
370
|
+
function keyvalN(t, kfs, ease = smoother) {
|
|
378
371
|
if (t <= kfs[0][0]) return kfs[0][1];
|
|
379
372
|
if (t >= kfs[kfs.length - 1][0]) return kfs[kfs.length - 1][1];
|
|
380
373
|
for (let i = 0; i < kfs.length - 1; i++) {
|
|
381
374
|
const [t0, v0] = kfs[i];
|
|
382
375
|
const [t1, v1] = kfs[i + 1];
|
|
383
|
-
if (t0 <= t && t <= t1)
|
|
384
|
-
const p = ease((t - t0) / (t1 - t0));
|
|
385
|
-
return {
|
|
386
|
-
x: v0.x + (v1.x - v0.x) * p,
|
|
387
|
-
y: v0.y + (v1.y - v0.y) * p
|
|
388
|
-
};
|
|
389
|
-
}
|
|
376
|
+
if (t0 <= t && t <= t1) return v0 + (v1 - v0) * ease((t - t0) / (t1 - t0));
|
|
390
377
|
}
|
|
391
378
|
return kfs[kfs.length - 1][1];
|
|
392
379
|
}
|
|
@@ -425,6 +412,30 @@ function clampCenter(center, scale, videoW, videoH, outW, outH) {
|
|
|
425
412
|
y: cy
|
|
426
413
|
};
|
|
427
414
|
}
|
|
415
|
+
/** Interpolate the rect track: centre AND size move under the SAME eased
|
|
416
|
+
* parameter. This is the whole trick (verified
|
|
417
|
+
* by frame-tracking a reference export: its pan curve overlays its viewport-WIDTH
|
|
418
|
+
* curve exactly, not its scale curve): lerping the rect keeps every corner on
|
|
419
|
+
* a straight line, so the screen-space path of the zoom target is strictly
|
|
420
|
+
* monotone toward frame centre — no wrong-way "bounce" for ANY scale pair,
|
|
421
|
+
* which scale+centre lerp can't guarantee (it hooks when scale > 2×rest). */
|
|
422
|
+
function keyvalR(t, kfs, ease) {
|
|
423
|
+
if (t <= kfs[0][0]) return kfs[0][1];
|
|
424
|
+
if (t >= kfs[kfs.length - 1][0]) return kfs[kfs.length - 1][1];
|
|
425
|
+
for (let i = 0; i < kfs.length - 1; i++) {
|
|
426
|
+
const [t0, v0] = kfs[i];
|
|
427
|
+
const [t1, v1] = kfs[i + 1];
|
|
428
|
+
if (t0 <= t && t <= t1) {
|
|
429
|
+
const p = ease((t - t0) / (t1 - t0));
|
|
430
|
+
return {
|
|
431
|
+
cx: v0.cx + (v1.cx - v0.cx) * p,
|
|
432
|
+
cy: v0.cy + (v1.cy - v0.cy) * p,
|
|
433
|
+
w: Math.max(v0.w + (v1.w - v0.w) * p, Math.min(v0.w, v1.w) * .5)
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return kfs[kfs.length - 1][1];
|
|
438
|
+
}
|
|
428
439
|
function buildStageKeyframes(comp) {
|
|
429
440
|
const { videoWidth: vW, videoHeight: vH } = comp.source;
|
|
430
441
|
const { width: oW, height: oH } = comp.output;
|
|
@@ -434,7 +445,16 @@ function buildStageKeyframes(comp) {
|
|
|
434
445
|
y: vH / 2
|
|
435
446
|
};
|
|
436
447
|
const HOLD = comp.cursor.holdMs / 1e3;
|
|
437
|
-
const
|
|
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
|
|
473
|
-
});
|
|
474
|
-
};
|
|
475
|
-
const pushC = (t, c) => {
|
|
476
|
-
cf.push({
|
|
477
|
-
t: Math.max(t, cf[cf.length - 1].t + .001),
|
|
478
|
-
c
|
|
493
|
+
const push = (t, r) => {
|
|
494
|
+
rf.push({
|
|
495
|
+
t: Math.max(t, rf[rf.length - 1].t + .001),
|
|
496
|
+
r
|
|
479
497
|
});
|
|
480
498
|
};
|
|
481
|
-
|
|
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
|
-
};
|
|
514
|
+
}, e.scale);
|
|
510
515
|
}
|
|
511
|
-
push(
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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 =
|
|
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,17 +646,223 @@ function isDragging(t, legs) {
|
|
|
635
646
|
return legs.some((lg) => lg.drag === true && lg.t0 <= t && t <= lg.t1);
|
|
636
647
|
}
|
|
637
648
|
|
|
649
|
+
//#endregion
|
|
650
|
+
//#region src/camera.ts
|
|
651
|
+
const centerOf = (b) => ({
|
|
652
|
+
x: b.x + b.w / 2,
|
|
653
|
+
y: b.y + b.h / 2
|
|
654
|
+
});
|
|
655
|
+
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
656
|
+
const union = (a, b) => {
|
|
657
|
+
const x = Math.min(a.x, b.x);
|
|
658
|
+
const y = Math.min(a.y, b.y);
|
|
659
|
+
return {
|
|
660
|
+
x,
|
|
661
|
+
y,
|
|
662
|
+
w: Math.max(a.x + a.w, b.x + b.w) - x,
|
|
663
|
+
h: Math.max(a.y + a.h, b.y + b.h) - y
|
|
664
|
+
};
|
|
665
|
+
};
|
|
666
|
+
function growDown(box, video) {
|
|
667
|
+
const grownH = Math.max(box.h, Math.min(video.h * .42, box.w * .55));
|
|
668
|
+
const h = Math.min(grownH, video.h - box.y);
|
|
669
|
+
return {
|
|
670
|
+
x: box.x,
|
|
671
|
+
y: box.y,
|
|
672
|
+
w: box.w,
|
|
673
|
+
h
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
/** The region a beat is "about" — what the camera should frame. A type is
|
|
677
|
+
* field-anchored and height-capped (its reveal is open-ended); every other
|
|
678
|
+
* kind trusts the captured effect region, else shapes one from the bbox. */
|
|
679
|
+
function roiForBeat(b, video) {
|
|
680
|
+
if (b.kind === "type" && b.box) {
|
|
681
|
+
const capH = growDown(b.box, video).h;
|
|
682
|
+
if (b.effectBox) {
|
|
683
|
+
const x = Math.min(b.box.x, b.effectBox.x);
|
|
684
|
+
const right = Math.max(b.box.x + b.box.w, b.effectBox.x + b.effectBox.w);
|
|
685
|
+
const bottom = b.effectBox.y + b.effectBox.h;
|
|
686
|
+
const h = Math.max(b.box.h, Math.min(capH, bottom - b.box.y));
|
|
687
|
+
return {
|
|
688
|
+
x,
|
|
689
|
+
y: b.box.y,
|
|
690
|
+
w: right - x,
|
|
691
|
+
h
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return growDown(b.box, video);
|
|
695
|
+
}
|
|
696
|
+
if (b.effectBox) return b.effectBox;
|
|
697
|
+
if (!b.box) return void 0;
|
|
698
|
+
return b.box;
|
|
699
|
+
}
|
|
700
|
+
function clusterLabel(beats, idx) {
|
|
701
|
+
const raw = beats[idx[0]]?.label ?? "";
|
|
702
|
+
const cleaned = raw.replace(/\s+/g, " ").trim().slice(0, 18);
|
|
703
|
+
return cleaned || "region";
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Decide framing for every beat. `endMs` is when the last frame can hold until
|
|
707
|
+
* (for the min-hold check on the final segment). `rest` is the stage scale at
|
|
708
|
+
* full view.
|
|
709
|
+
*/
|
|
710
|
+
function directCamera(beats, video, out, cam, rest, endMs) {
|
|
711
|
+
const restC = {
|
|
712
|
+
x: video.w / 2,
|
|
713
|
+
y: video.h / 2
|
|
714
|
+
};
|
|
715
|
+
const fit = (roi) => bboxFitScale(roi, out.w, out.h, cam.fillFrac, cam.maxScale, rest);
|
|
716
|
+
const nodes = beats.map((b, i) => {
|
|
717
|
+
const roi = roiForBeat(b, video);
|
|
718
|
+
const scale = roi ? fit(roi) : rest;
|
|
719
|
+
const gapBreak = i > 0 && b.tMs - (beats[i - 1].tMs + beats[i - 1].durationMs) > cam.coalesceWindowMs;
|
|
720
|
+
if (b.intent === "never") return {
|
|
721
|
+
roi,
|
|
722
|
+
scale,
|
|
723
|
+
forceFull: true,
|
|
724
|
+
forcePunch: false,
|
|
725
|
+
boundary: true,
|
|
726
|
+
reason: "plan: zoom=never → full view"
|
|
727
|
+
};
|
|
728
|
+
if (b.intent === "always") return {
|
|
729
|
+
roi,
|
|
730
|
+
scale,
|
|
731
|
+
forceFull: false,
|
|
732
|
+
forcePunch: true,
|
|
733
|
+
boundary: true,
|
|
734
|
+
reason: roi ? `plan: zoom=always → ${scale.toFixed(2)}× (framing from ROI)` : "plan: zoom=always but no bbox to frame → full view"
|
|
735
|
+
};
|
|
736
|
+
if (b.kind === "scroll") return {
|
|
737
|
+
roi: void 0,
|
|
738
|
+
scale: rest,
|
|
739
|
+
forceFull: true,
|
|
740
|
+
forcePunch: false,
|
|
741
|
+
boundary: true,
|
|
742
|
+
reason: "scroll — full view (content pans)"
|
|
743
|
+
};
|
|
744
|
+
if (b.kind === "press" && b.keys && /(^|\+)esc(ape)?$/i.test(b.keys.trim())) return {
|
|
745
|
+
roi: void 0,
|
|
746
|
+
scale: rest,
|
|
747
|
+
forceFull: true,
|
|
748
|
+
forcePunch: false,
|
|
749
|
+
boundary: true,
|
|
750
|
+
reason: "Escape (dismissal) — full view"
|
|
751
|
+
};
|
|
752
|
+
if (b.changeCoverage != null && b.changeCoverage >= cam.pullOutCoverage) return {
|
|
753
|
+
roi,
|
|
754
|
+
scale,
|
|
755
|
+
forceFull: true,
|
|
756
|
+
forcePunch: false,
|
|
757
|
+
boundary: true,
|
|
758
|
+
reason: `changeCoverage ${b.changeCoverage.toFixed(2)} ≥ ${cam.pullOutCoverage} (global repaint) → full view`
|
|
759
|
+
};
|
|
760
|
+
if (i === 0) return {
|
|
761
|
+
roi,
|
|
762
|
+
scale,
|
|
763
|
+
forceFull: true,
|
|
764
|
+
forcePunch: false,
|
|
765
|
+
boundary: true,
|
|
766
|
+
reason: "opening beat — full view (orienting)"
|
|
767
|
+
};
|
|
768
|
+
if (!roi) return {
|
|
769
|
+
roi,
|
|
770
|
+
scale,
|
|
771
|
+
forceFull: true,
|
|
772
|
+
forcePunch: false,
|
|
773
|
+
boundary: true,
|
|
774
|
+
reason: "no bbox to frame → full view"
|
|
775
|
+
};
|
|
776
|
+
if (scale < cam.minZoomScale) return {
|
|
777
|
+
roi,
|
|
778
|
+
scale,
|
|
779
|
+
forceFull: true,
|
|
780
|
+
forcePunch: false,
|
|
781
|
+
boundary: gapBreak,
|
|
782
|
+
reason: `ROI fills the frame already (fit ${scale.toFixed(2)}× < ${cam.minZoomScale}×) — full view`
|
|
783
|
+
};
|
|
784
|
+
return {
|
|
785
|
+
roi,
|
|
786
|
+
scale,
|
|
787
|
+
forceFull: false,
|
|
788
|
+
forcePunch: false,
|
|
789
|
+
boundary: gapBreak,
|
|
790
|
+
reason: `punch ${scale.toFixed(2)}× (ROI-fit)`
|
|
791
|
+
};
|
|
792
|
+
});
|
|
793
|
+
const segs = [];
|
|
794
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
795
|
+
const n = nodes[i];
|
|
796
|
+
if (n.forceFull) {
|
|
797
|
+
segs.push({
|
|
798
|
+
idx: [i],
|
|
799
|
+
kind: "rest",
|
|
800
|
+
scale: rest,
|
|
801
|
+
center: restC
|
|
802
|
+
});
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
const last = segs[segs.length - 1];
|
|
806
|
+
const lastLastIdx = last ? last.idx[last.idx.length - 1] : -1;
|
|
807
|
+
const canExtend = !!last && last.kind === "punch" && !!last.roi && !n.forcePunch && !nodes[lastLastIdx].forcePunch && !n.boundary && !!n.roi && dist(centerOf(n.roi), centerOf(last.roi)) < cam.travelThreshold * video.w && fit(union(last.roi, n.roi)) >= cam.minZoomScale;
|
|
808
|
+
if (canExtend && last && last.roi && n.roi) {
|
|
809
|
+
const roi = union(last.roi, n.roi);
|
|
810
|
+
last.idx.push(i);
|
|
811
|
+
last.roi = roi;
|
|
812
|
+
last.scale = fit(roi);
|
|
813
|
+
last.center = centerOf(roi);
|
|
814
|
+
} else segs.push({
|
|
815
|
+
idx: [i],
|
|
816
|
+
kind: "punch",
|
|
817
|
+
roi: n.roi,
|
|
818
|
+
scale: n.scale,
|
|
819
|
+
center: n.roi ? centerOf(n.roi) : restC
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
segs.forEach((s, si) => {
|
|
823
|
+
if (s.kind !== "punch") return;
|
|
824
|
+
const firstT = beats[s.idx[0]].tMs;
|
|
825
|
+
const next = segs[si + 1];
|
|
826
|
+
const heldUntil = next ? beats[next.idx[0]].tMs : endMs;
|
|
827
|
+
if (heldUntil - firstT < cam.minHoldMs) {
|
|
828
|
+
s.kind = "rest";
|
|
829
|
+
s.scale = rest;
|
|
830
|
+
s.center = restC;
|
|
831
|
+
s.dropped = true;
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
const framings = new Array(beats.length);
|
|
835
|
+
for (const s of segs) {
|
|
836
|
+
if (s.kind === "rest") {
|
|
837
|
+
for (const i of s.idx) framings[i] = {
|
|
838
|
+
enabled: false,
|
|
839
|
+
scale: rest,
|
|
840
|
+
center: restC,
|
|
841
|
+
reason: s.dropped ? `punch dropped — held < ${cam.minHoldMs}ms (would flinch) → full view` : nodes[i].reason
|
|
842
|
+
};
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const N = s.idx.length;
|
|
846
|
+
const tag = N > 1 ? `cluster[${clusterLabel(beats, s.idx)}]` : null;
|
|
847
|
+
s.idx.forEach((i, k) => {
|
|
848
|
+
framings[i] = {
|
|
849
|
+
enabled: true,
|
|
850
|
+
scale: s.scale,
|
|
851
|
+
center: s.center,
|
|
852
|
+
reason: tag ? `${tag} ${k + 1}/${N} · hold shared framing (union of ${N}) → ${s.scale.toFixed(2)}×` : nodes[i].reason
|
|
853
|
+
};
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
return framings;
|
|
857
|
+
}
|
|
858
|
+
|
|
638
859
|
//#endregion
|
|
639
860
|
//#region src/plan.ts
|
|
640
861
|
function planComposition(log, opts = {}) {
|
|
641
|
-
const vW = log.
|
|
862
|
+
const vW = log.viewport.w, vH = log.viewport.h;
|
|
642
863
|
const oW = opts.output?.width ?? vW;
|
|
643
864
|
const oH = opts.output?.height ?? vH;
|
|
644
865
|
const fps = opts.output?.fps ?? 30;
|
|
645
|
-
const fillFrac = opts.fillFrac ?? .55;
|
|
646
|
-
const maxScale = opts.maxScale ?? 1.5;
|
|
647
|
-
const zoomRatio = opts.zoomRatio ?? 1.3;
|
|
648
|
-
const zoomFirst = opts.zoomFirst ?? false;
|
|
649
866
|
const framing = {
|
|
650
867
|
...DEFAULT_FRAMING,
|
|
651
868
|
...opts.framing
|
|
@@ -654,6 +871,10 @@ function planComposition(log, opts = {}) {
|
|
|
654
871
|
...DEFAULT_CURSOR,
|
|
655
872
|
...opts.cursor
|
|
656
873
|
};
|
|
874
|
+
const camera = {
|
|
875
|
+
...DEFAULT_CAMERA,
|
|
876
|
+
...opts.camera
|
|
877
|
+
};
|
|
657
878
|
const sx = vW / log.viewport.w;
|
|
658
879
|
const sy = vH / log.viewport.h;
|
|
659
880
|
const mapPt = (p) => ({
|
|
@@ -677,13 +898,12 @@ function planComposition(log, opts = {}) {
|
|
|
677
898
|
h: Math.max(...ys) - y
|
|
678
899
|
};
|
|
679
900
|
};
|
|
680
|
-
const
|
|
901
|
+
const scaffolds = log.events.map((c) => {
|
|
681
902
|
const kind = c.kind ?? "click";
|
|
682
903
|
const point = mapPt({
|
|
683
904
|
x: c.x,
|
|
684
905
|
y: c.y
|
|
685
906
|
});
|
|
686
|
-
const isFirst = i === 0;
|
|
687
907
|
const intent = c.zoom ?? "auto";
|
|
688
908
|
const durationMs$1 = "durationMs" in c ? c.durationMs : 0;
|
|
689
909
|
const to = kind === "drag" ? mapPt(c.to) : void 0;
|
|
@@ -694,53 +914,26 @@ function planComposition(log, opts = {}) {
|
|
|
694
914
|
const path = rawPath?.map(mapPt);
|
|
695
915
|
const ease = kind === "drag" ? c.ease : void 0;
|
|
696
916
|
const bbox = kind === "drag" && path ? pathBBox(path) : c.box ? mapBox(c.box) : void 0;
|
|
697
|
-
|
|
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 {
|
|
917
|
+
const effectBox = c.effectBox ? mapBox(c.effectBox) : void 0;
|
|
918
|
+
const label = c.sel ?? c.note;
|
|
919
|
+
const beat = {
|
|
920
|
+
kind,
|
|
921
|
+
tMs: c.tMs,
|
|
922
|
+
durationMs: durationMs$1,
|
|
923
|
+
box: bbox,
|
|
924
|
+
effectBox,
|
|
925
|
+
changeCoverage: c.changeCoverage,
|
|
926
|
+
point,
|
|
927
|
+
intent,
|
|
928
|
+
label,
|
|
929
|
+
...kind === "press" ? { keys: c.keys } : {}
|
|
930
|
+
};
|
|
931
|
+
const ev = {
|
|
732
932
|
kind,
|
|
733
933
|
tMs: c.tMs,
|
|
734
934
|
point,
|
|
735
935
|
bbox,
|
|
736
|
-
label
|
|
737
|
-
zoom: {
|
|
738
|
-
enabled,
|
|
739
|
-
scale,
|
|
740
|
-
center,
|
|
741
|
-
inAtMs: Math.max(0, c.tMs - cursor.zoomInMs),
|
|
742
|
-
reason
|
|
743
|
-
},
|
|
936
|
+
label,
|
|
744
937
|
...durationMs$1 ? { durationMs: durationMs$1 } : {},
|
|
745
938
|
...kind === "type" ? { text: c.text } : {},
|
|
746
939
|
...kind === "press" ? { keys: c.keys } : {},
|
|
@@ -748,13 +941,40 @@ function planComposition(log, opts = {}) {
|
|
|
748
941
|
...path ? { path } : {},
|
|
749
942
|
...ease ? { ease } : {}
|
|
750
943
|
};
|
|
944
|
+
return {
|
|
945
|
+
beat,
|
|
946
|
+
ev
|
|
947
|
+
};
|
|
948
|
+
});
|
|
949
|
+
const beats = scaffolds.map((s) => s.beat);
|
|
950
|
+
const last = log.events.length ? log.events[log.events.length - 1] : void 0;
|
|
951
|
+
const lastEnd = last ? last.tMs + ("durationMs" in last ? last.durationMs : 0) : 0;
|
|
952
|
+
const endMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs);
|
|
953
|
+
const framings = camera.enabled ? directCamera(beats, {
|
|
954
|
+
w: vW,
|
|
955
|
+
h: vH
|
|
956
|
+
}, {
|
|
957
|
+
w: oW,
|
|
958
|
+
h: oH
|
|
959
|
+
}, camera, rest, endMs) : beats.map((b) => manualFraming(b, rest));
|
|
960
|
+
const events = scaffolds.map((s, i) => {
|
|
961
|
+
const f = framings[i];
|
|
962
|
+
const zoom = {
|
|
963
|
+
enabled: f.enabled,
|
|
964
|
+
scale: f.scale,
|
|
965
|
+
center: f.center,
|
|
966
|
+
inAtMs: Math.max(0, s.beat.tMs - cursor.zoomInMs),
|
|
967
|
+
reason: f.reason
|
|
968
|
+
};
|
|
969
|
+
return {
|
|
970
|
+
...s.ev,
|
|
971
|
+
zoom
|
|
972
|
+
};
|
|
751
973
|
});
|
|
752
974
|
const start = log.start ? mapPt(log.start) : {
|
|
753
975
|
x: vW * .25,
|
|
754
976
|
y: vH * .9
|
|
755
977
|
};
|
|
756
|
-
const last = log.events.length ? log.events[log.events.length - 1] : void 0;
|
|
757
|
-
const lastEnd = last ? last.tMs + ("durationMs" in last ? last.durationMs : 0) : 0;
|
|
758
978
|
const durationMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs + cursor.zoomOutMs + 400);
|
|
759
979
|
return {
|
|
760
980
|
output: {
|
|
@@ -776,6 +996,23 @@ function planComposition(log, opts = {}) {
|
|
|
776
996
|
durationMs
|
|
777
997
|
};
|
|
778
998
|
}
|
|
999
|
+
function manualFraming(b, rest) {
|
|
1000
|
+
if (b.intent === "always" && b.box) return {
|
|
1001
|
+
enabled: true,
|
|
1002
|
+
scale: rest,
|
|
1003
|
+
center: {
|
|
1004
|
+
x: b.box.x + b.box.w / 2,
|
|
1005
|
+
y: b.box.y + b.box.h / 2
|
|
1006
|
+
},
|
|
1007
|
+
reason: "camera off · plan: zoom=always (raise scale by hand)"
|
|
1008
|
+
};
|
|
1009
|
+
return {
|
|
1010
|
+
enabled: false,
|
|
1011
|
+
scale: rest,
|
|
1012
|
+
center: b.point,
|
|
1013
|
+
reason: "camera off · full view (no explicit zoom)"
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
779
1016
|
|
|
780
1017
|
//#endregion
|
|
781
1018
|
//#region src/validate.ts
|
|
@@ -841,6 +1078,11 @@ function validateComposition(comp, opts = {}) {
|
|
|
841
1078
|
if (!e.bbox) warn(`${p}.zoom`, "zoom enabled on a beat with no bbox — center/scale are hand-set, not bbox-fit", "double-check the center frames the intended region");
|
|
842
1079
|
}
|
|
843
1080
|
}
|
|
1081
|
+
const spring = comp.cursor.zoomSpring;
|
|
1082
|
+
if (spring != null) {
|
|
1083
|
+
if (!(spring >= 0 && spring < .6)) err("cursor.zoomSpring", `zoomSpring ${spring} outside [0, 0.6)`, "0 = critically damped (the default feel); keep bounce ≤ 0.3");
|
|
1084
|
+
else if (spring > .3) warn("cursor.zoomSpring", `zoomSpring ${spring} is a lot of bounce — the rect overshoot can flash backdrop at edge-flush targets and over-zoom tight punches`, "keep ≤ 0.3 (the editor slider's max) unless the flash is wanted");
|
|
1085
|
+
}
|
|
844
1086
|
if (comp.durationMs < lastEnd) err("durationMs", `durationMs ${comp.durationMs} ends before the last action (${lastEnd})`, `raise to ≥ ${Math.round(lastEnd + comp.cursor.holdMs + comp.cursor.zoomOutMs)}`);
|
|
845
1087
|
else if (comp.durationMs < lastEnd + comp.cursor.zoomOutMs) warn("durationMs", `only ${comp.durationMs - lastEnd}ms after the last action — the final zoom-out may be cut`, `allow ≥ ${comp.cursor.zoomOutMs}ms (cursor.zoomOutMs) of tail`);
|
|
846
1088
|
if (opts.captureLog) {
|
|
@@ -1069,5 +1311,5 @@ async function renderTakeExclusive(opts) {
|
|
|
1069
1311
|
}
|
|
1070
1312
|
|
|
1071
1313
|
//#endregion
|
|
1072
|
-
export { DEFAULT_CURSOR, DEFAULT_FRAMING, DEFAULT_MOTION_BLUR, FINISH, LOOKS, MOTION, ZOOM_LEVELS, finishName, formatIssues, lookName, math_exports as math, motionBlurActive, motionName, planComposition, renderTake, resolveFfmpeg, resolveFfprobe, validateComposition, zoomLevelName };
|
|
1314
|
+
export { DEFAULT_CAMERA, DEFAULT_CURSOR, DEFAULT_FRAMING, DEFAULT_MOTION_BLUR, FINISH, LOOKS, MOTION, ZOOM_LEVELS, directCamera, finishName, formatIssues, lookName, math_exports as math, motionBlurActive, motionName, planComposition, renderTake, resolveFfmpeg, resolveFfprobe, validateComposition, zoomLevelName };
|
|
1073
1315
|
//# sourceMappingURL=index.js.map
|