@open-take/compositor 0.1.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 ADDED
@@ -0,0 +1,973 @@
1
+ import { __export } from "./chunk-Cl8Af3a2.js";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { copyFile, mkdir, writeFile } from "node:fs/promises";
4
+ import { dirname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { renderVideo } from "@revideo/renderer";
7
+
8
+ //#region src/types.ts
9
+ /** True when motion blur is configured to actually do something (so the OFF
10
+ * path stays byte-identical to the pre-motion-blur renderer). */
11
+ function motionBlurActive(mb) {
12
+ return !!mb && mb.samples > 1 && mb.shutter > 0;
13
+ }
14
+ const DEFAULT_FRAMING = {
15
+ insetFrac: .92,
16
+ cornerRadius: 28,
17
+ shadow: {
18
+ color: "rgba(0,0,0,0.55)",
19
+ blur: 60,
20
+ offset: {
21
+ x: 0,
22
+ y: 28
23
+ }
24
+ },
25
+ background: {
26
+ from: "#1e1b3a",
27
+ to: "#0a0e1c"
28
+ }
29
+ };
30
+ const DEFAULT_MOTION_BLUR = {
31
+ samples: 6,
32
+ shutter: .7
33
+ };
34
+ const DEFAULT_CURSOR = {
35
+ travelMs: 560,
36
+ travelWidthsPerSec: .35,
37
+ travelMinMs: 300,
38
+ travelMaxMs: 850,
39
+ scale: 2,
40
+ arcFrac: .05,
41
+ arcMax: 24,
42
+ rippleMs: 450,
43
+ holdMs: 1100,
44
+ zoomOutMs: 800,
45
+ zoomInMs: 760,
46
+ zoomEase: [
47
+ .3,
48
+ 0,
49
+ .2,
50
+ 1
51
+ ],
52
+ dragLagMs: 190,
53
+ travelEase: [
54
+ .3,
55
+ 0,
56
+ .2,
57
+ 1
58
+ ]
59
+ };
60
+
61
+ //#endregion
62
+ //#region src/presets.ts
63
+ const ZOOM_LEVELS = {
64
+ light: 1.25,
65
+ medium: 1.5,
66
+ tight: 1.8,
67
+ close: 2.2
68
+ };
69
+ /** Name a scale if it sits within tolerance of a preset; else null (custom). */
70
+ function zoomLevelName(scale, tol = .07) {
71
+ let best = null;
72
+ let bestD = tol;
73
+ for (const [name, v] of Object.entries(ZOOM_LEVELS)) {
74
+ const d = Math.abs(scale - v);
75
+ if (d <= bestD) {
76
+ best = name;
77
+ bestD = d;
78
+ }
79
+ }
80
+ return best;
81
+ }
82
+ const MOTION = {
83
+ calm: {
84
+ travelWidthsPerSec: .28,
85
+ holdMs: 1500,
86
+ zoomInMs: 950,
87
+ zoomOutMs: 950
88
+ },
89
+ natural: {
90
+ travelWidthsPerSec: .35,
91
+ holdMs: 1100,
92
+ zoomInMs: 760,
93
+ zoomOutMs: 800
94
+ },
95
+ brisk: {
96
+ travelWidthsPerSec: .45,
97
+ holdMs: 750,
98
+ zoomInMs: 560,
99
+ zoomOutMs: 600
100
+ }
101
+ };
102
+ function motionName(cursor) {
103
+ for (const [name, m] of Object.entries(MOTION)) if (Math.abs(cursor.travelWidthsPerSec - m.travelWidthsPerSec) < .015 && Math.abs(cursor.holdMs - m.holdMs) < 60 && Math.abs(cursor.zoomInMs - m.zoomInMs) < 60 && Math.abs(cursor.zoomOutMs - m.zoomOutMs) < 60) return name;
104
+ return null;
105
+ }
106
+ const DARK_SHADOW = {
107
+ color: "rgba(0,0,0,0.55)",
108
+ blur: 60,
109
+ offset: {
110
+ x: 0,
111
+ y: 28
112
+ }
113
+ };
114
+ const LOOKS = {
115
+ midnight: {
116
+ background: {
117
+ from: "#1e1b3a",
118
+ to: "#0a0e1c"
119
+ },
120
+ cornerRadius: 28,
121
+ shadow: DARK_SHADOW
122
+ },
123
+ ink: {
124
+ background: {
125
+ from: "#16181d",
126
+ to: "#07080a"
127
+ },
128
+ cornerRadius: 28,
129
+ shadow: DARK_SHADOW
130
+ },
131
+ slate: {
132
+ background: {
133
+ from: "#232733",
134
+ to: "#0e1116"
135
+ },
136
+ cornerRadius: 28,
137
+ shadow: DARK_SHADOW
138
+ },
139
+ ocean: {
140
+ background: {
141
+ from: "#0c2b36",
142
+ to: "#071019"
143
+ },
144
+ cornerRadius: 28,
145
+ shadow: DARK_SHADOW
146
+ },
147
+ plum: {
148
+ background: {
149
+ from: "#2a1e3a",
150
+ to: "#140a1c"
151
+ },
152
+ cornerRadius: 28,
153
+ shadow: DARK_SHADOW
154
+ },
155
+ ember: {
156
+ background: {
157
+ from: "#38231d",
158
+ to: "#1a0d09"
159
+ },
160
+ cornerRadius: 28,
161
+ shadow: DARK_SHADOW
162
+ },
163
+ paper: {
164
+ background: {
165
+ from: "#f6f4ef",
166
+ to: "#ddd8cd"
167
+ },
168
+ cornerRadius: 22,
169
+ shadow: {
170
+ color: "rgba(31,27,20,0.30)",
171
+ blur: 45,
172
+ offset: {
173
+ x: 0,
174
+ y: 20
175
+ }
176
+ }
177
+ },
178
+ plain: {
179
+ background: {
180
+ from: "#101014",
181
+ to: "#101014",
182
+ type: "solid"
183
+ },
184
+ cornerRadius: 28,
185
+ shadow: DARK_SHADOW
186
+ }
187
+ };
188
+ function lookName(framing) {
189
+ const bg = framing.background;
190
+ for (const [name, l] of Object.entries(LOOKS)) if (l.background.from.toLowerCase() === bg.from.toLowerCase() && l.background.to.toLowerCase() === bg.to.toLowerCase() && (l.background.type ?? "gradient") === (bg.type ?? "gradient")) return name;
191
+ return null;
192
+ }
193
+ const FINISH = {
194
+ smooth: DEFAULT_MOTION_BLUR,
195
+ crisp: void 0,
196
+ heavy: {
197
+ samples: 8,
198
+ shutter: .85
199
+ }
200
+ };
201
+ function finishName(mb) {
202
+ const active = !!mb && mb.samples > 1 && mb.shutter > 0;
203
+ if (!active) return "crisp";
204
+ for (const [name, f] of Object.entries(FINISH)) if (f && mb && f.samples === mb.samples && Math.abs(f.shutter - mb.shutter) < .03) return name;
205
+ return null;
206
+ }
207
+
208
+ //#endregion
209
+ //#region src/ffmpeg.ts
210
+ let cachedFfmpeg;
211
+ let cachedFfprobe;
212
+ function runsOk(bin) {
213
+ try {
214
+ return spawnSync(bin, ["-version"], { stdio: "ignore" }).status === 0;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+ async function installerPath(pkg) {
220
+ try {
221
+ const m = await import(pkg);
222
+ return m.default?.path ?? m.path ?? null;
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+ async function resolveFfmpeg() {
228
+ if (cachedFfmpeg) return cachedFfmpeg;
229
+ if (runsOk("ffmpeg")) {
230
+ cachedFfmpeg = "ffmpeg";
231
+ return cachedFfmpeg;
232
+ }
233
+ const p = await installerPath("@ffmpeg-installer/ffmpeg");
234
+ if (p && runsOk(p)) {
235
+ cachedFfmpeg = p;
236
+ return p;
237
+ }
238
+ throw new Error("ffmpeg not found — install it (e.g. `brew install ffmpeg`) or `npm install` so the bundled @ffmpeg-installer binary resolves for this platform");
239
+ }
240
+ async function resolveFfprobe() {
241
+ if (cachedFfprobe) return cachedFfprobe;
242
+ if (runsOk("ffprobe")) {
243
+ cachedFfprobe = "ffprobe";
244
+ return cachedFfprobe;
245
+ }
246
+ const p = await installerPath("@ffprobe-installer/ffprobe");
247
+ if (p && runsOk(p)) {
248
+ cachedFfprobe = p;
249
+ return p;
250
+ }
251
+ throw new Error("ffprobe not found — install ffmpeg (e.g. `brew install ffmpeg`) or `npm install` so the bundled @ffprobe-installer binary resolves for this platform");
252
+ }
253
+
254
+ //#endregion
255
+ //#region src/math.ts
256
+ var math_exports = {};
257
+ __export(math_exports, {
258
+ bboxFitScale: () => bboxFitScale,
259
+ buildLegs: () => buildLegs,
260
+ buildStageKeyframes: () => buildStageKeyframes,
261
+ clampCenter: () => clampCenter,
262
+ cubicBezier: () => cubicBezier,
263
+ cursorPos: () => cursorPos,
264
+ gradientEndpoints: () => gradientEndpoints,
265
+ isDragging: () => isDragging,
266
+ keyvalN: () => keyvalN,
267
+ keyvalP: () => keyvalP,
268
+ panEasing: () => panEasing,
269
+ restStageScale: () => restStageScale,
270
+ smoother: () => smoother,
271
+ springEase: () => springEase,
272
+ stageEasing: () => stageEasing
273
+ });
274
+ function smoother(t) {
275
+ t = Math.max(0, Math.min(1, t));
276
+ return t * t * t * (t * (t * 6 - 15) + 10);
277
+ }
278
+ function cubicBezier(x1, y1, x2, y2) {
279
+ const bx = (s) => {
280
+ const u = 1 - s;
281
+ return 3 * u * u * s * x1 + 3 * u * s * s * x2 + s * s * s;
282
+ };
283
+ const by = (s) => {
284
+ const u = 1 - s;
285
+ return 3 * u * u * s * y1 + 3 * u * s * s * y2 + s * s * s;
286
+ };
287
+ return (x) => {
288
+ if (x <= 0) return 0;
289
+ if (x >= 1) return 1;
290
+ let lo = 0, hi = 1, s = x;
291
+ for (let i = 0; i < 24; i++) {
292
+ const xt = bx(s);
293
+ if (Math.abs(xt - x) < 1e-4) break;
294
+ if (xt < x) lo = s;
295
+ else hi = s;
296
+ s = (lo + hi) / 2;
297
+ }
298
+ return by(s);
299
+ };
300
+ }
301
+ function springEase(bounce) {
302
+ const zeta = Math.max(.4, Math.min(1, 1 - bounce));
303
+ 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;
308
+ if (zeta >= 1) return 1 - Math.exp(-t) * (1 + t);
309
+ const wd = Math.sqrt(1 - zeta * zeta);
310
+ return 1 - Math.exp(-zeta * t) * (Math.cos(wd * t) + zeta / wd * Math.sin(wd * t));
311
+ };
312
+ }
313
+ function stageEasing(cursor) {
314
+ if (cursor.zoomSpring != null) return springEase(cursor.zoomSpring);
315
+ 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;
321
+ }
322
+ function gradientEndpoints(angleDeg, oW, oH) {
323
+ if (angleDeg == null) return {
324
+ x0: 0,
325
+ y0: 0,
326
+ x1: oW,
327
+ y1: oH
328
+ };
329
+ const th = angleDeg * Math.PI / 180;
330
+ const dx = Math.sin(th);
331
+ const dy = -Math.cos(th);
332
+ const L = Math.abs(oW * dx) + Math.abs(oH * dy);
333
+ const cx = oW / 2;
334
+ const cy = oH / 2;
335
+ return {
336
+ x0: cx - dx * L / 2,
337
+ y0: cy - dy * L / 2,
338
+ x1: cx + dx * L / 2,
339
+ y1: cy + dy * L / 2
340
+ };
341
+ }
342
+ function keyvalN(t, kfs, ease = smoother, easeDown) {
343
+ if (t <= kfs[0][0]) return kfs[0][1];
344
+ if (t >= kfs[kfs.length - 1][0]) return kfs[kfs.length - 1][1];
345
+ for (let i = 0; i < kfs.length - 1; i++) {
346
+ const [t0, v0] = kfs[i];
347
+ 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
+ }
368
+ }
369
+ return kfs[kfs.length - 1][1];
370
+ }
371
+ /**
372
+ * Scale (absolute, video-px → output-px) that fits `bbox` into `fillFrac`
373
+ * of the output frame, capped at `maxScale` and floored at `restScale`.
374
+ * Wide/long elements naturally get a gentler scale because the limiting
375
+ * dimension dominates min().
376
+ */
377
+ function bboxFitScale(bbox, outW, outH, fillFrac, maxScale, restScale) {
378
+ const fitW = outW * fillFrac / Math.max(1, bbox.w);
379
+ const fitH = outH * fillFrac / Math.max(1, bbox.h);
380
+ return Math.min(maxScale, Math.max(restScale, Math.min(fitW, fitH)));
381
+ }
382
+ /** Stage scale at rest: video inset into the frame (leaves backdrop margin). */
383
+ function restStageScale(videoW, videoH, outW, outH, insetFrac) {
384
+ return insetFrac * Math.min(outW / videoW, outH / videoH);
385
+ }
386
+ /**
387
+ * Clamp a desired video-px center so the scaled video still covers the output
388
+ * frame (the viewport crop stays inside the source video) when zoomed in. When
389
+ * the content does not cover an axis (zoomed out / inset), centre that axis.
390
+ *
391
+ * In the composition-camera model the whole composition
392
+ * (backdrop + inset screen) is zoomed by one camera; this keeps the camera crop
393
+ * within the screen so a zoom fills the frame (no backdrop margin) without ever
394
+ * panning past the recording's edge.
395
+ */
396
+ function clampCenter(center, scale, videoW, videoH, outW, outH) {
397
+ const halfW = outW / (2 * scale);
398
+ const halfH = outH / (2 * scale);
399
+ const cx = videoW * scale >= outW ? Math.min(Math.max(center.x, halfW), videoW - halfW) : videoW / 2;
400
+ const cy = videoH * scale >= outH ? Math.min(Math.max(center.y, halfH), videoH - halfH) : videoH / 2;
401
+ return {
402
+ x: cx,
403
+ y: cy
404
+ };
405
+ }
406
+ function buildStageKeyframes(comp) {
407
+ const { videoWidth: vW, videoHeight: vH } = comp.source;
408
+ const { width: oW, height: oH } = comp.output;
409
+ const rest = restStageScale(vW, vH, oW, oH, comp.framing.insetFrac);
410
+ const restC = {
411
+ x: vW / 2,
412
+ y: vH / 2
413
+ };
414
+ const HOLD = comp.cursor.holdMs / 1e3;
415
+ const ZOUT = comp.cursor.zoomOutMs / 1e3;
416
+ const anchors = [];
417
+ for (const e of comp.events) {
418
+ const base = {
419
+ tMs: e.tMs,
420
+ durationMs: e.durationMs ?? 0,
421
+ inAtMs: e.zoom.inAtMs
422
+ };
423
+ if (e.kind === "scroll" || e.kind === "press" && !e.zoom.enabled) anchors.push({
424
+ ...base,
425
+ scale: rest,
426
+ center: restC
427
+ });
428
+ else if (e.zoom.enabled) anchors.push({
429
+ ...base,
430
+ scale: e.zoom.scale,
431
+ center: e.zoom.center,
432
+ glide: e.zoom.glide
433
+ });
434
+ }
435
+ const zf = [{
436
+ t: 0,
437
+ s: rest
438
+ }];
439
+ const cf = [{
440
+ t: 0,
441
+ c: restC
442
+ }];
443
+ const push = (t, s, c) => {
444
+ zf.push({
445
+ t: Math.max(t, zf[zf.length - 1].t + .001),
446
+ s
447
+ });
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
+ };
475
+ anchors.forEach((e, i) => {
476
+ const rampStart = e.inAtMs / 1e3;
477
+ const clickT = e.tMs / 1e3;
478
+ const actionEnd = (e.tMs + e.durationMs) / 1e3;
479
+ const next = anchors[i + 1];
480
+ const holdEndT = next ? next.inAtMs / 1e3 : actionEnd + HOLD;
481
+ let holdC = e.center;
482
+ if (e.glide && (e.glide.x !== 0 || e.glide.y !== 0)) {
483
+ const holdDur = Math.max(0, holdEndT - clickT);
484
+ holdC = {
485
+ x: e.center.x + e.glide.x * holdDur,
486
+ 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);
493
+ }
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
+ };
513
+ }
514
+ });
515
+ const lastT = Math.max(zf[zf.length - 1].t, cf[cf.length - 1].t);
516
+ const T = Math.max(comp.durationMs / 1e3, lastT) + .3;
517
+ push(T, rest, restC);
518
+ return {
519
+ z: zf.map((f) => [f.t, f.s]),
520
+ c: cf.map((f) => [f.t, f.c]),
521
+ T
522
+ };
523
+ }
524
+ function buildLegs(comp) {
525
+ const legs = [];
526
+ let cur = comp.start;
527
+ const { travelWidthsPerSec, travelMinMs, travelMaxMs, travelMs } = comp.cursor;
528
+ const speedPxPerMs = (travelWidthsPerSec || 0) * comp.source.videoWidth / 1e3;
529
+ const travelDur = (a, b) => {
530
+ 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;
533
+ };
534
+ for (const e of comp.events) {
535
+ if (e.kind === "scroll" || e.kind === "press") continue;
536
+ const arrive = e.tMs / 1e3;
537
+ const prevEnd = legs.length ? legs[legs.length - 1].t1 : 0;
538
+ const t0 = Math.max(arrive - travelDur(cur, e.point), prevEnd, 0);
539
+ legs.push({
540
+ t0,
541
+ t1: arrive,
542
+ a: cur,
543
+ b: e.point
544
+ });
545
+ cur = e.point;
546
+ if (e.kind === "drag" && e.to) {
547
+ const lag = comp.cursor.dragLagMs / 1e3;
548
+ const start = arrive + lag;
549
+ const dEnd = (e.tMs + (e.durationMs ?? 0)) / 1e3 + lag;
550
+ const path = e.path && e.path.length >= 2 ? e.path : [e.point, e.to];
551
+ if (dEnd > start) legs.push({
552
+ t0: start,
553
+ t1: dEnd,
554
+ a: e.point,
555
+ b: e.to,
556
+ drag: true,
557
+ path,
558
+ ease: e.ease
559
+ });
560
+ cur = e.to;
561
+ }
562
+ }
563
+ return legs;
564
+ }
565
+ /** Point a fraction `u` (0..1) along a polyline, parameterised by arc length. */
566
+ function alongPath(path, u) {
567
+ if (path.length === 1) return path[0];
568
+ const seg = [];
569
+ let total = 0;
570
+ for (let i = 0; i < path.length - 1; i++) {
571
+ const d = Math.hypot(path[i + 1].x - path[i].x, path[i + 1].y - path[i].y);
572
+ seg.push(d);
573
+ total += d;
574
+ }
575
+ if (total === 0) return path[0];
576
+ let target = u * total;
577
+ for (let i = 0; i < seg.length; i++) {
578
+ if (target <= seg[i] || i === seg.length - 1) {
579
+ const f = seg[i] > 0 ? target / seg[i] : 0;
580
+ return {
581
+ x: path[i].x + (path[i + 1].x - path[i].x) * f,
582
+ y: path[i].y + (path[i + 1].y - path[i].y) * f
583
+ };
584
+ }
585
+ target -= seg[i];
586
+ }
587
+ return path[path.length - 1];
588
+ }
589
+ function cursorPos(t, legs, comp) {
590
+ for (const lg of legs) if (lg.t0 <= t && t <= lg.t1) {
591
+ const raw = Math.max(0, Math.min(1, (t - lg.t0) / (lg.t1 - lg.t0)));
592
+ if (lg.drag && lg.path) return alongPath(lg.path, lg.ease === "smooth" ? smoother(raw) : raw);
593
+ const e = comp.cursor.travelEase;
594
+ const p = e ? cubicBezier(e[0], e[1], e[2], e[3])(raw) : smoother(raw);
595
+ const base = {
596
+ x: lg.a.x + (lg.b.x - lg.a.x) * p,
597
+ y: lg.a.y + (lg.b.y - lg.a.y) * p
598
+ };
599
+ const dx = lg.b.x - lg.a.x, dy = lg.b.y - lg.a.y;
600
+ const L = Math.hypot(dx, dy) || 1;
601
+ const arc = Math.min(comp.cursor.arcFrac * L, comp.cursor.arcMax) * Math.sin(Math.PI * p);
602
+ return {
603
+ x: base.x + -dy / L * arc,
604
+ y: base.y + dx / L * arc
605
+ };
606
+ }
607
+ if (legs.length === 0 || t < legs[0].t0) return comp.start;
608
+ for (let i = 0; i < legs.length - 1; i++) if (legs[i].t1 < t && t < legs[i + 1].t0) return legs[i].b;
609
+ return legs[legs.length - 1].b;
610
+ }
611
+ /** True while a drag is mid-stroke (button held) — for the pressed cursor. */
612
+ function isDragging(t, legs) {
613
+ return legs.some((lg) => lg.drag === true && lg.t0 <= t && t <= lg.t1);
614
+ }
615
+
616
+ //#endregion
617
+ //#region src/plan.ts
618
+ function planComposition(log, opts = {}) {
619
+ const vW = log.video.width, vH = log.video.height;
620
+ const oW = opts.output?.width ?? vW;
621
+ const oH = opts.output?.height ?? vH;
622
+ 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
+ const framing = {
628
+ ...DEFAULT_FRAMING,
629
+ ...opts.framing
630
+ };
631
+ const cursor = {
632
+ ...DEFAULT_CURSOR,
633
+ ...opts.cursor
634
+ };
635
+ const sx = vW / log.viewport.w;
636
+ const sy = vH / log.viewport.h;
637
+ const mapPt = (p) => ({
638
+ x: p.x * sx,
639
+ y: p.y * sy
640
+ });
641
+ const mapBox = (b) => ({
642
+ x: b.x * sx,
643
+ y: b.y * sy,
644
+ w: b.w * sx,
645
+ h: b.h * sy
646
+ });
647
+ const rest = restStageScale(vW, vH, oW, oH, framing.insetFrac);
648
+ const pathBBox = (pts) => {
649
+ const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
650
+ const x = Math.min(...xs), y = Math.min(...ys);
651
+ return {
652
+ x,
653
+ y,
654
+ w: Math.max(...xs) - x,
655
+ h: Math.max(...ys) - y
656
+ };
657
+ };
658
+ const events = log.events.map((c, i) => {
659
+ const kind = c.kind ?? "click";
660
+ const point = mapPt({
661
+ x: c.x,
662
+ y: c.y
663
+ });
664
+ const isFirst = i === 0;
665
+ const intent = c.zoom ?? "auto";
666
+ const durationMs$1 = "durationMs" in c ? c.durationMs : 0;
667
+ const to = kind === "drag" ? mapPt(c.to) : void 0;
668
+ const rawPath = kind === "drag" ? c.path ?? [{
669
+ x: c.x,
670
+ y: c.y
671
+ }, c.to] : void 0;
672
+ const path = rawPath?.map(mapPt);
673
+ const ease = kind === "drag" ? c.ease : void 0;
674
+ 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 {
710
+ kind,
711
+ tMs: c.tMs,
712
+ point,
713
+ 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
+ },
722
+ ...durationMs$1 ? { durationMs: durationMs$1 } : {},
723
+ ...kind === "type" ? { text: c.text } : {},
724
+ ...kind === "press" ? { keys: c.keys } : {},
725
+ ...to ? { to } : {},
726
+ ...path ? { path } : {},
727
+ ...ease ? { ease } : {}
728
+ };
729
+ });
730
+ const start = log.start ? mapPt(log.start) : {
731
+ x: vW * .25,
732
+ y: vH * .9
733
+ };
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
+ const durationMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs + cursor.zoomOutMs + 400);
737
+ return {
738
+ output: {
739
+ width: oW,
740
+ height: oH,
741
+ fps
742
+ },
743
+ source: {
744
+ videoUrl: "/capture.mp4",
745
+ videoWidth: vW,
746
+ videoHeight: vH,
747
+ viewport: log.viewport
748
+ },
749
+ framing,
750
+ cursor,
751
+ motionBlur: DEFAULT_MOTION_BLUR,
752
+ start,
753
+ events,
754
+ durationMs
755
+ };
756
+ }
757
+
758
+ //#endregion
759
+ //#region src/validate.ts
760
+ const reqField = {
761
+ type: "text",
762
+ press: "keys",
763
+ drag: "to"
764
+ };
765
+ function validateComposition(comp, opts = {}) {
766
+ const issues = [];
767
+ const err = (path, message, fix) => issues.push({
768
+ severity: "error",
769
+ path,
770
+ message,
771
+ fix
772
+ });
773
+ const warn = (path, message, fix) => issues.push({
774
+ severity: "warn",
775
+ path,
776
+ message,
777
+ fix
778
+ });
779
+ const maxScale = opts.maxScale ?? 2.5;
780
+ const { width: oW, height: oH, fps } = comp.output;
781
+ if (!(oW > 0 && oH > 0)) err("output", `non-positive output size ${oW}x${oH}`, "set positive width/height");
782
+ if (!(fps > 0)) err("output.fps", `fps must be > 0 (got ${fps})`, "set output.fps to 30 or 60");
783
+ const { videoWidth: vW, videoHeight: vH } = comp.source;
784
+ if (!(vW > 0 && vH > 0)) err("source", `non-positive source video size ${vW}x${vH}`);
785
+ const rest = restStageScale(vW, vH, oW, oH, comp.framing.insetFrac);
786
+ const mb = comp.motionBlur;
787
+ if (mb) {
788
+ if (!Number.isInteger(mb.samples) || mb.samples < 1 || mb.samples > 16) err("motionBlur.samples", `samples must be an integer 1-16 (got ${mb.samples})`, "6 is the balanced default; 1 turns blur off");
789
+ else if (mb.samples > 9) warn("motionBlur.samples", `samples ${mb.samples} renders ${mb.samples}× the frames for little extra smoothness`, "9 is the practical ceiling");
790
+ if (!(mb.shutter >= 0 && mb.shutter <= 1)) err("motionBlur.shutter", `shutter must be 0..1 (got ${mb.shutter})`, "0.7 is the default; 0 turns blur off");
791
+ }
792
+ const events = comp.events ?? [];
793
+ let lastEnd = 0;
794
+ let prevT = -1;
795
+ for (let i = 0; i < events.length; i++) {
796
+ const e = events[i];
797
+ const p = `events[${i}]`;
798
+ const dur = e.durationMs ?? 0;
799
+ const endMs = e.tMs + dur;
800
+ lastEnd = Math.max(lastEnd, endMs);
801
+ if (e.tMs < prevT) err(`${p}.tMs`, `tMs ${e.tMs} is before the previous beat (${prevT}) — events out of order`, "events follow the recording; reordering needs a re-capture, not a swap here");
802
+ prevT = e.tMs;
803
+ if (e.tMs < 0) err(`${p}.tMs`, `negative tMs ${e.tMs}`);
804
+ if (e.tMs > comp.durationMs) err(`${p}.tMs`, `tMs ${e.tMs} exceeds composition durationMs ${comp.durationMs}`, "raise durationMs or remove the beat");
805
+ const need = reqField[e.kind];
806
+ if (need && e[need] == null) err(`${p}.${need}`, `${e.kind} beat is missing "${need}"`, `restore ${need} from the capture`);
807
+ const z = e.zoom;
808
+ if (!z) {
809
+ err(`${p}.zoom`, "missing zoom decision", "every beat needs a zoom { enabled, scale, center, inAtMs }");
810
+ continue;
811
+ }
812
+ if (z.inAtMs < 0) err(`${p}.zoom.inAtMs`, `negative inAtMs ${z.inAtMs}`, "clamp to 0");
813
+ if (z.inAtMs > e.tMs) err(`${p}.zoom.inAtMs`, `inAtMs ${z.inAtMs} is AFTER the action at tMs ${e.tMs} — the zoom would arrive late`, `set inAtMs = tMs − cursor.zoomInMs (= ${Math.max(0, e.tMs - comp.cursor.zoomInMs)})`);
814
+ if (z.enabled) {
815
+ if (z.scale < rest - 1e-6) err(`${p}.zoom.scale`, `scale ${z.scale.toFixed(3)} is below rest ${rest.toFixed(3)} — that zooms OUT past the frame (dead space)`, `raise to ≥ ${rest.toFixed(2)}, or set zoom.enabled=false for a full-view beat`);
816
+ else if (z.scale <= rest + 1e-6) warn(`${p}.zoom.scale`, `scale ${z.scale.toFixed(3)} ≈ rest — zoom is enabled but does nothing visible`, "raise scale to actually zoom, or set enabled=false");
817
+ if (z.scale > maxScale) warn(`${p}.zoom.scale`, `scale ${z.scale.toFixed(2)} exceeds the soft cap ${maxScale} — pixels will soften`, `clamp toward ${maxScale.toFixed(1)} unless the detail truly needs it`);
818
+ if (z.center.x < 0 || z.center.x > vW || z.center.y < 0 || z.center.y > vH) warn(`${p}.zoom.center`, `center (${z.center.x.toFixed(0)},${z.center.y.toFixed(0)}) is outside the video ${vW}x${vH}`, e.bbox ? "use the bbox center: { x: bbox.x+bbox.w/2, y: bbox.y+bbox.h/2 }" : "point at the on-screen target (video-px)");
819
+ 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
+ }
821
+ }
822
+ 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
+ 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
+ if (opts.captureLog) {
825
+ const log = opts.captureLog;
826
+ if (log.events.length !== events.length) warn("events", `composition has ${events.length} beats but the capture log has ${log.events.length} — added/removed actions need a re-capture`, "re-capture to change the choreography; edit only renders the existing recording");
827
+ const n = Math.min(log.events.length, events.length);
828
+ for (let i = 0; i < n; i++) {
829
+ const lt = log.events[i].tMs;
830
+ const ct = events[i].tMs;
831
+ if (Math.abs(lt - ct) > 1) err(`events[${i}].tMs`, `tMs ${ct} drifted from the captured ${lt} — an action's tMs is capture-locked (the video shows it at ${lt}ms)`, `restore tMs to ${lt}; to retime the action itself, re-capture`);
832
+ }
833
+ }
834
+ return issues;
835
+ }
836
+ /** Format issues for a human/agent log. Errors first. */
837
+ function formatIssues(issues) {
838
+ if (!issues.length) return "composition OK (0 issues)";
839
+ const order = (s) => s === "error" ? 0 : 1;
840
+ return issues.slice().sort((a, b) => order(a.severity) - order(b.severity)).map((i) => ` [${i.severity}] ${i.path}: ${i.message}${i.fix ? `\n fix: ${i.fix}` : ""}`).join("\n");
841
+ }
842
+
843
+ //#endregion
844
+ //#region src/render.ts
845
+ 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
+ const RENDER_OUT = "out-render";
849
+ function run(cmd, args) {
850
+ return new Promise((res, rej) => {
851
+ const c = spawn(cmd, args, { stdio: [
852
+ "ignore",
853
+ "ignore",
854
+ "pipe"
855
+ ] });
856
+ let err = "";
857
+ c.stderr.on("data", (d) => err += d);
858
+ c.on("error", rej);
859
+ c.on("close", (code) => code === 0 ? res() : rej(new Error(`${cmd} exited ${code}: ${err}`)));
860
+ });
861
+ }
862
+ /** Normalise the capture to a constant-fps mp4 the web decoder can read.
863
+ * fps follows the composition so a hi-fps capture can render at 60 (the
864
+ * render grid must match — a 30-grid would throw away the extra frames). */
865
+ async function toMp4(videoPath, outMp4, fps) {
866
+ await mkdir(dirname(outMp4), { recursive: true });
867
+ await run(await resolveFfmpeg(), [
868
+ "-y",
869
+ "-loglevel",
870
+ "error",
871
+ "-i",
872
+ resolve(videoPath),
873
+ "-c:v",
874
+ "libx264",
875
+ "-pix_fmt",
876
+ "yuv420p",
877
+ "-crf",
878
+ "18",
879
+ "-r",
880
+ String(fps),
881
+ "-an",
882
+ outMp4
883
+ ]);
884
+ }
885
+ /** Temporal-supersampling motion blur: the scene was rendered at fps·samples
886
+ * (project.ts); average a trailing shutter window of sub-frames back down to the
887
+ * output fps. `tmix=frames=M` averages M consecutive sub-frames; `fps=baseFps`
888
+ * then decimates ≈every `samples`-th, so each output frame = the mean of the last
889
+ * M sub-frames of its interval (a trailing shutter). Re-tags bt709/tv to match
890
+ * the capture pipeline (the input is already bt709, but tmix→encode must keep it). */
891
+ async function motionBlurMp4(inMp4, outMp4, baseFps, samples, shutter) {
892
+ const M = Math.max(1, Math.min(samples, Math.round(shutter * samples)));
893
+ const vf = `tmix=frames=${M},fps=${baseFps},format=yuv420p,setparams=range=tv:colorspace=bt709:color_primaries=bt709:color_trc=bt709`;
894
+ await run(await resolveFfmpeg(), [
895
+ "-y",
896
+ "-loglevel",
897
+ "error",
898
+ "-i",
899
+ resolve(inMp4),
900
+ "-vf",
901
+ vf,
902
+ "-c:v",
903
+ "libx264",
904
+ "-pix_fmt",
905
+ "yuv420p",
906
+ "-crf",
907
+ "18",
908
+ "-r",
909
+ String(baseFps),
910
+ "-an",
911
+ outMp4
912
+ ]);
913
+ }
914
+ async function renderTake(opts) {
915
+ const composition = opts.composition ?? planComposition(opts.log ?? (() => {
916
+ throw new Error("renderTake: provide `log` or `composition`");
917
+ })(), opts.planOpts);
918
+ if (!opts.skipValidate) {
919
+ const issues = validateComposition(composition, { captureLog: opts.captureLog ?? opts.log });
920
+ const errors = issues.filter((i) => i.severity === "error");
921
+ const warns = issues.filter((i) => i.severity === "warn");
922
+ if (opts.logProgress && warns.length) process.stderr.write(`composition warnings:\n${formatIssues(warns)}\n`);
923
+ if (errors.length) throw new Error(`composition has ${errors.length} error(s) — refusing to render:\n${formatIssues(errors)}`);
924
+ }
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;
932
+ 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 } : {}
950
+ }
951
+ }
952
+ });
953
+ } 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));
964
+ }
965
+ return {
966
+ mp4Path: resolve(opts.outPath),
967
+ compositionPath
968
+ };
969
+ }
970
+
971
+ //#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 };
973
+ //# sourceMappingURL=index.js.map