@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/LICENSE +21 -0
- package/dist/chunk-Cl8Af3a2.js +11 -0
- package/dist/index.d.ts +482 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +973 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
- package/src/ffmpeg.ts +60 -0
- package/src/index.ts +21 -0
- package/src/math.ts +459 -0
- package/src/plan.ts +157 -0
- package/src/presets.ts +147 -0
- package/src/render.ts +237 -0
- package/src/scene/project.ts +19 -0
- package/src/scene/scene.tsx +231 -0
- package/src/types.ts +349 -0
- package/src/validate.ts +245 -0
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
|