@pixerate/editor 0.82.0 → 0.84.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/video-editor/index.cjs +1054 -130
- package/dist/video-editor/index.cjs.map +1 -1
- package/dist/video-editor/index.d.cts +456 -12
- package/dist/video-editor/index.d.ts +456 -12
- package/dist/video-editor/index.js +992 -68
- package/dist/video-editor/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/pixerate-video-editor/SKILL.md +46 -10
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class; var _class2; var _class3;
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class; var _class2; var _class3; var _class4;
|
|
2
2
|
|
|
3
3
|
var _chunkWAJQKWQUcjs = require('../chunk-WAJQKWQU.cjs');
|
|
4
4
|
|
|
@@ -13,8 +13,12 @@ require('../chunk-2FHLHZAL.cjs');
|
|
|
13
13
|
|
|
14
14
|
// src/video-editor/types.ts
|
|
15
15
|
var VIDEO_FITS = ["contain", "cover", "fill", "none"];
|
|
16
|
-
var VIDEO_TRANSITION_TYPES = ["crossfade", "dip", "wipe"];
|
|
16
|
+
var VIDEO_TRANSITION_TYPES = ["crossfade", "dip", "wipe", "push", "slide", "iris"];
|
|
17
17
|
var VIDEO_WIPE_DIRECTIONS = ["left", "right", "up", "down"];
|
|
18
|
+
var videoTransitionAudioCurve = (t) => _nullishCoalesce(t.audioCurve, () => ( (t.type === "dip" ? "linear" : "equal-power")));
|
|
19
|
+
var VIDEO_BLEND_MODES = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "add"];
|
|
20
|
+
var VIDEO_CLIP_ANIMATION_EFFECTS = ["fade", "rise", "drop", "slide-left", "slide-right", "zoom", "pop"];
|
|
21
|
+
var VIDEO_FADE_CURVES = ["linear", "equal-power", "s-curve", "exponential", "logarithmic"];
|
|
18
22
|
var VIDEO_PLACEMENT_MODES = ["refuse", "overwrite", "insert"];
|
|
19
23
|
var VIDEO_CLIP_TYPES = ["media", "text", "color"];
|
|
20
24
|
|
|
@@ -206,6 +210,136 @@ function videoKeyframeFfmpegExpr(prop, time, fallback, step) {
|
|
|
206
210
|
return `if(lt(${time},${fmt(points[0][0])}),${fmt(points[0][1])},${expr})`;
|
|
207
211
|
}
|
|
208
212
|
|
|
213
|
+
// src/video-editor/grade.ts
|
|
214
|
+
var VIDEO_GRADE_CONTROLS = [
|
|
215
|
+
{ key: "exposure", label: "Exposure", min: -3, max: 3, step: 0.1, description: "Exposure in stops: +1 doubles every channel, -1 halves it (default 0)." },
|
|
216
|
+
{ key: "contrast", label: "Contrast", min: -1, max: 1, step: 0.05, description: "Contrast around mid grey, -1 (flat grey) to 1 (twice as steep); default 0." },
|
|
217
|
+
{ key: "saturation", label: "Saturation", min: -1, max: 1, step: 0.05, description: "Saturation, -1 (black and white) to 1 (twice as saturated); default 0." },
|
|
218
|
+
{ key: "hue", label: "Hue", min: -180, max: 180, step: 1, description: "Hue rotation in degrees (default 0)." },
|
|
219
|
+
{ key: "temperature", label: "Temperature", min: -1, max: 1, step: 0.05, description: "White balance, -1 (cool, bluer) to 1 (warm, more orange); default 0." },
|
|
220
|
+
{ key: "tint", label: "Tint", min: -1, max: 1, step: 0.05, description: "White balance, -1 (greener) to 1 (more magenta); default 0." }
|
|
221
|
+
];
|
|
222
|
+
var VIDEO_GRADE_PRESETS = {
|
|
223
|
+
warm: { temperature: 0.4, saturation: 0.1 },
|
|
224
|
+
cool: { temperature: -0.4, tint: -0.05 },
|
|
225
|
+
punchy: { contrast: 0.25, saturation: 0.3 },
|
|
226
|
+
faded: { exposure: 0.15, contrast: -0.3, saturation: -0.3 },
|
|
227
|
+
"black-and-white": { saturation: -1, contrast: 0.15 },
|
|
228
|
+
"bleach-bypass": { saturation: -0.6, contrast: 0.4 },
|
|
229
|
+
"day-for-night": { exposure: -1.5, saturation: -0.4, temperature: -0.6 }
|
|
230
|
+
};
|
|
231
|
+
var VIDEO_GRADE_PRESET_NAMES = Object.keys(VIDEO_GRADE_PRESETS);
|
|
232
|
+
var clampTo = (c, v) => Math.min(c.max, Math.max(c.min, v));
|
|
233
|
+
function normalizeVideoGrade(grade) {
|
|
234
|
+
if (!grade) return void 0;
|
|
235
|
+
const out = {};
|
|
236
|
+
for (const c of VIDEO_GRADE_CONTROLS) {
|
|
237
|
+
const v = grade[c.key];
|
|
238
|
+
if (typeof v === "number" && Number.isFinite(v) && v !== 0) out[c.key] = Math.round(clampTo(c, v) * 1e3) / 1e3;
|
|
239
|
+
}
|
|
240
|
+
return Object.keys(out).length ? out : void 0;
|
|
241
|
+
}
|
|
242
|
+
function videoGradePreset(name, amount = 1) {
|
|
243
|
+
const preset = VIDEO_GRADE_PRESETS[name];
|
|
244
|
+
if (!preset) throw new Error(`There is no grade preset "${name}"; the presets are ${VIDEO_GRADE_PRESET_NAMES.join(", ")}.`);
|
|
245
|
+
const k = Math.min(1, Math.max(0, amount));
|
|
246
|
+
return normalizeVideoGrade(Object.fromEntries(Object.entries(preset).map(([key, v]) => [key, v * k])));
|
|
247
|
+
}
|
|
248
|
+
function videoGradeSteps(grade) {
|
|
249
|
+
const g = normalizeVideoGrade(grade);
|
|
250
|
+
if (!g) return [];
|
|
251
|
+
const steps = [];
|
|
252
|
+
if (g.exposure) steps.push({ kind: "brightness", amount: 2 ** g.exposure });
|
|
253
|
+
if (g.contrast) steps.push({ kind: "contrast", amount: 1 + g.contrast });
|
|
254
|
+
if (g.saturation) steps.push({ kind: "saturate", amount: 1 + g.saturation });
|
|
255
|
+
if (g.hue) steps.push({ kind: "hue-rotate", degrees: g.hue });
|
|
256
|
+
if (g.temperature || g.tint) {
|
|
257
|
+
const t = _nullishCoalesce(g.temperature, () => ( 0));
|
|
258
|
+
steps.push({ kind: "gains", r: 1 + 0.25 * t, g: 1 - 0.25 * (_nullishCoalesce(g.tint, () => ( 0))), b: 1 - 0.25 * t });
|
|
259
|
+
}
|
|
260
|
+
return steps;
|
|
261
|
+
}
|
|
262
|
+
function videoGradeStepMatrix(step) {
|
|
263
|
+
switch (step.kind) {
|
|
264
|
+
case "brightness":
|
|
265
|
+
return [
|
|
266
|
+
[step.amount, 0, 0, 0],
|
|
267
|
+
[0, step.amount, 0, 0],
|
|
268
|
+
[0, 0, step.amount, 0]
|
|
269
|
+
];
|
|
270
|
+
case "contrast": {
|
|
271
|
+
const c = step.amount;
|
|
272
|
+
const o = 0.5 - 0.5 * c;
|
|
273
|
+
return [
|
|
274
|
+
[c, 0, 0, o],
|
|
275
|
+
[0, c, 0, o],
|
|
276
|
+
[0, 0, c, o]
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
case "gains":
|
|
280
|
+
return [
|
|
281
|
+
[step.r, 0, 0, 0],
|
|
282
|
+
[0, step.g, 0, 0],
|
|
283
|
+
[0, 0, step.b, 0]
|
|
284
|
+
];
|
|
285
|
+
case "saturate": {
|
|
286
|
+
const s = step.amount;
|
|
287
|
+
return [
|
|
288
|
+
[0.213 + 0.787 * s, 0.715 - 0.715 * s, 0.072 - 0.072 * s, 0],
|
|
289
|
+
[0.213 - 0.213 * s, 0.715 + 0.285 * s, 0.072 - 0.072 * s, 0],
|
|
290
|
+
[0.213 - 0.213 * s, 0.715 - 0.715 * s, 0.072 + 0.928 * s, 0]
|
|
291
|
+
];
|
|
292
|
+
}
|
|
293
|
+
case "hue-rotate": {
|
|
294
|
+
const a = step.degrees * Math.PI / 180;
|
|
295
|
+
const cos = Math.cos(a);
|
|
296
|
+
const sin = Math.sin(a);
|
|
297
|
+
return [
|
|
298
|
+
[0.213 + cos * 0.787 - sin * 0.213, 0.715 - cos * 0.715 - sin * 0.715, 0.072 - cos * 0.072 + sin * 0.928, 0],
|
|
299
|
+
[0.213 - cos * 0.213 + sin * 0.143, 0.715 + cos * 0.285 + sin * 0.14, 0.072 - cos * 0.072 - sin * 0.283, 0],
|
|
300
|
+
[0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072, 0]
|
|
301
|
+
];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function applyVideoGrade(grade, rgb) {
|
|
306
|
+
let v = rgb.map((x) => x / 255);
|
|
307
|
+
for (const step of videoGradeSteps(grade)) {
|
|
308
|
+
const m = videoGradeStepMatrix(step);
|
|
309
|
+
v = m.map((row) => Math.min(1, Math.max(0, row[0] * v[0] + row[1] * v[1] + row[2] * v[2] + row[3])));
|
|
310
|
+
}
|
|
311
|
+
return v.map((x) => Math.round(x * 255));
|
|
312
|
+
}
|
|
313
|
+
var fmt2 = (v) => String(Math.round(v * 1e5) / 1e5);
|
|
314
|
+
function videoGradeStepSvgValues(step) {
|
|
315
|
+
return videoGradeStepMatrix(step).map((row) => [row[0], row[1], row[2], 0, row[3]].map(fmt2).join(" ")).concat("0 0 0 1 0").join(" ");
|
|
316
|
+
}
|
|
317
|
+
function videoGradeSvgFilter(id, grade) {
|
|
318
|
+
const steps = videoGradeSteps(grade);
|
|
319
|
+
return `<filter id="${id}" color-interpolation-filters="sRGB" x="0" y="0" width="100%" height="100%">${steps.map((s) => `<feColorMatrix type="matrix" values="${videoGradeStepSvgValues(s)}"/>`).join("")}</filter>`;
|
|
320
|
+
}
|
|
321
|
+
var videoGradeGainsFilterId = (step) => `pixerate-wb-${[step.r, step.g, step.b].map((v) => Math.round(v * 1e3)).join("-")}`;
|
|
322
|
+
function videoGradeCssFilter(grade) {
|
|
323
|
+
return videoGradeSteps(grade).map((s) => s.kind === "brightness" ? `brightness(${fmt2(s.amount)})` : s.kind === "contrast" ? `contrast(${fmt2(s.amount)})` : s.kind === "saturate" ? `saturate(${fmt2(s.amount)})` : s.kind === "hue-rotate" ? `hue-rotate(${fmt2(s.degrees)}deg)` : `url(#${videoGradeGainsFilterId(s)})`).join(" ");
|
|
324
|
+
}
|
|
325
|
+
function videoGradeSvgDefs(grade) {
|
|
326
|
+
return videoGradeSteps(grade).filter((s) => s.kind === "gains").map((s) => {
|
|
327
|
+
const id = videoGradeGainsFilterId(s);
|
|
328
|
+
return { id, markup: `<filter id="${id}" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${videoGradeStepSvgValues(s)}"/></filter>` };
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
function videoGradeFfmpegFilters(grade) {
|
|
332
|
+
return videoGradeSteps(grade).map((step) => {
|
|
333
|
+
const m = videoGradeStepMatrix(step);
|
|
334
|
+
if (step.kind === "brightness" || step.kind === "contrast") {
|
|
335
|
+
const lut = (i) => `clip(val*${fmt2(m[i][i])}+${fmt2(m[i][3] * 255)},0,255)`;
|
|
336
|
+
return `lutrgb=r='${lut(0)}':g='${lut(1)}':b='${lut(2)}'`;
|
|
337
|
+
}
|
|
338
|
+
const k = ["r", "g", "b"];
|
|
339
|
+
return `colorchannelmixer=${m.flatMap((row, i) => row.slice(0, 3).map((v, j) => `${k[i]}${k[j]}=${fmt2(v)}`)).join(":")}`;
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
209
343
|
// src/video-editor/parse.ts
|
|
210
344
|
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
211
345
|
var fail = (path, what) => {
|
|
@@ -274,6 +408,22 @@ function parseVideoTransform(v, path) {
|
|
|
274
408
|
rotation: parseVideoAnimatable(v.rotation, `${path}.rotation`, { optional: true })
|
|
275
409
|
});
|
|
276
410
|
}
|
|
411
|
+
function parseVideoClipAnimation(v, path) {
|
|
412
|
+
if (v === void 0) return void 0;
|
|
413
|
+
if (!isObject(v)) return fail(path, "must be an object { effect, duration }");
|
|
414
|
+
return compact({
|
|
415
|
+
effect: oneOf(v.effect, VIDEO_CLIP_ANIMATION_EFFECTS, `${path}.effect`, false),
|
|
416
|
+
duration: num(v.duration, `${path}.duration`, { optional: true, positive: true })
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
function parseVideoGrade(v, path) {
|
|
420
|
+
if (v === void 0) return void 0;
|
|
421
|
+
if (!isObject(v)) return fail(path, "must be an object such as { exposure, contrast, saturation }");
|
|
422
|
+
const known = new Set(VIDEO_GRADE_CONTROLS.map((c) => c.key));
|
|
423
|
+
const unknown = Object.keys(v).find((k) => !known.has(k));
|
|
424
|
+
if (unknown) fail(`${path}.${unknown}`, `is not a grade setting; use ${[...known].join(", ")}`);
|
|
425
|
+
return compact(Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, num(v[c.key], `${path}.${c.key}`, { optional: true, min: c.min, max: c.max })])));
|
|
426
|
+
}
|
|
277
427
|
function parseVideoTransition(v, path) {
|
|
278
428
|
if (v === void 0) return void 0;
|
|
279
429
|
if (!isObject(v)) return fail(path, "must be an object { type, duration }");
|
|
@@ -281,7 +431,8 @@ function parseVideoTransition(v, path) {
|
|
|
281
431
|
type: oneOf(v.type, VIDEO_TRANSITION_TYPES, `${path}.type`, false),
|
|
282
432
|
duration: num(v.duration, `${path}.duration`, { positive: true }),
|
|
283
433
|
color: str(v.color, `${path}.color`, true),
|
|
284
|
-
direction: oneOf(v.direction, VIDEO_WIPE_DIRECTIONS, `${path}.direction`)
|
|
434
|
+
direction: oneOf(v.direction, VIDEO_WIPE_DIRECTIONS, `${path}.direction`),
|
|
435
|
+
audioCurve: oneOf(v.audioCurve, VIDEO_FADE_CURVES, `${path}.audioCurve`)
|
|
285
436
|
});
|
|
286
437
|
}
|
|
287
438
|
function parseVideoAsset(v, path = "asset") {
|
|
@@ -312,6 +463,10 @@ function parseVideoClip(v, path = "clip") {
|
|
|
312
463
|
opacity: parseVideoAnimatable(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),
|
|
313
464
|
transform: parseVideoTransform(v.transform, `${path}.transform`),
|
|
314
465
|
fit: oneOf(v.fit, VIDEO_FITS, `${path}.fit`),
|
|
466
|
+
blend: oneOf(v.blend, VIDEO_BLEND_MODES, `${path}.blend`),
|
|
467
|
+
grade: parseVideoGrade(v.grade, `${path}.grade`),
|
|
468
|
+
animateIn: parseVideoClipAnimation(v.animateIn, `${path}.animateIn`),
|
|
469
|
+
animateOut: parseVideoClipAnimation(v.animateOut, `${path}.animateOut`),
|
|
315
470
|
fadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),
|
|
316
471
|
fadeOut: num(v.fadeOut, `${path}.fadeOut`, { optional: true, min: 0 }),
|
|
317
472
|
transition: parseVideoTransition(v.transition, `${path}.transition`)
|
|
@@ -324,7 +479,9 @@ function parseVideoClip(v, path = "clip") {
|
|
|
324
479
|
in: num(v.in, `${path}.in`, { optional: true, min: 0 }),
|
|
325
480
|
speed: num(v.speed, `${path}.speed`, { optional: true, min: 0.05, max: 20 }),
|
|
326
481
|
volume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
|
|
327
|
-
muted: bool(v.muted, `${path}.muted`)
|
|
482
|
+
muted: bool(v.muted, `${path}.muted`),
|
|
483
|
+
fadeInCurve: oneOf(v.fadeInCurve, VIDEO_FADE_CURVES, `${path}.fadeInCurve`),
|
|
484
|
+
fadeOutCurve: oneOf(v.fadeOutCurve, VIDEO_FADE_CURVES, `${path}.fadeOutCurve`)
|
|
328
485
|
});
|
|
329
486
|
if (type === "text")
|
|
330
487
|
return compact({
|
|
@@ -426,15 +583,41 @@ function snapToVideoFrame(time, fps) {
|
|
|
426
583
|
return Math.round(frame * 1e3 / fps * 1e3) / 1e3;
|
|
427
584
|
}
|
|
428
585
|
var videoFrames = (time, fps) => Math.round(time * fps / 1e3);
|
|
429
|
-
function
|
|
586
|
+
function isVideoDropFrameRate(fps) {
|
|
587
|
+
const nominal = Math.round(fps);
|
|
588
|
+
return (nominal === 30 || nominal === 60) && Math.abs(fps - nominal * 1e3 / 1001) < 5e-3;
|
|
589
|
+
}
|
|
590
|
+
var exactVideoRate = (fps) => {
|
|
591
|
+
const nominal = Math.round(fps);
|
|
592
|
+
return nominal !== fps && Math.abs(fps - nominal * 1e3 / 1001) < 5e-3 ? nominal * 1e3 / 1001 : fps;
|
|
593
|
+
};
|
|
594
|
+
var videoTimecodeFrames = (time, fps) => Math.max(0, Math.round(time * exactVideoRate(fps) / 1e3));
|
|
595
|
+
function formatVideoTimecode(time, fps, dropFrame = isVideoDropFrameRate(fps)) {
|
|
596
|
+
return formatVideoTimecodeFrames(videoTimecodeFrames(time, fps), fps, dropFrame);
|
|
597
|
+
}
|
|
598
|
+
function formatVideoTimecodeFrames(frames, fps, dropFrame = isVideoDropFrameRate(fps)) {
|
|
430
599
|
const base = Math.round(fps);
|
|
431
|
-
|
|
600
|
+
let total = Math.max(0, Math.round(frames));
|
|
601
|
+
const drop = dropFrame && isVideoDropFrameRate(fps);
|
|
602
|
+
if (drop) {
|
|
603
|
+
const skip = base / 15;
|
|
604
|
+
const perMinute = base * 60 - skip;
|
|
605
|
+
const perTen = perMinute * 10 + skip;
|
|
606
|
+
const tens = Math.floor(total / perTen);
|
|
607
|
+
const rest = total % perTen;
|
|
608
|
+
total += skip * 9 * tens + (rest > skip ? skip * Math.floor((rest - skip) / perMinute) : 0);
|
|
609
|
+
}
|
|
432
610
|
const ff = total % base;
|
|
433
611
|
const secs = Math.floor(total / base);
|
|
434
612
|
const pad = (n2) => String(n2).padStart(2, "0");
|
|
435
|
-
return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}
|
|
613
|
+
return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}${drop ? ";" : ":"}${pad(ff)}`;
|
|
436
614
|
}
|
|
615
|
+
var videoHourFrames = (fps, dropFrame = isVideoDropFrameRate(fps)) => {
|
|
616
|
+
const base = Math.round(fps);
|
|
617
|
+
return dropFrame && isVideoDropFrameRate(fps) ? base * 3600 - base / 15 * 54 : base * 3600;
|
|
618
|
+
};
|
|
437
619
|
var formatVideoSeconds = (time) => `${(time / 1e3).toFixed(2)} s`;
|
|
620
|
+
var videoAssetHasSound = (asset) => !!asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
|
|
438
621
|
function videoAssetMap(project) {
|
|
439
622
|
return new Map(project.assets.map((a) => [a.id, a]));
|
|
440
623
|
}
|
|
@@ -472,13 +655,13 @@ function videoClipHandles(clip, assets) {
|
|
|
472
655
|
const speed = videoClipSpeed(clip);
|
|
473
656
|
const range = videoClipSourceRange(clip);
|
|
474
657
|
const before = range.in / speed;
|
|
475
|
-
if (!asset || asset.kind === "image" || asset.duration === void 0) return { before: _optionalChain([asset, 'optionalAccess',
|
|
658
|
+
if (!asset || asset.kind === "image" || asset.duration === void 0) return { before: _optionalChain([asset, 'optionalAccess', _2 => _2.kind]) === "image" ? Infinity : before, after: Infinity };
|
|
476
659
|
return { before, after: Math.max(0, (asset.duration - range.out) / speed) };
|
|
477
660
|
}
|
|
478
661
|
function videoClipOverhang(track, clip) {
|
|
479
662
|
const lead = clip.transition && clip.transition.type !== "dip" && videoClipBefore(track, clip) ? clip.transition.duration / 2 : 0;
|
|
480
663
|
const next = videoClipAfter(track, clip);
|
|
481
|
-
const tail = _optionalChain([next, 'optionalAccess',
|
|
664
|
+
const tail = _optionalChain([next, 'optionalAccess', _3 => _3.transition]) && next.transition.type !== "dip" ? next.transition.duration / 2 : 0;
|
|
482
665
|
return { lead, tail };
|
|
483
666
|
}
|
|
484
667
|
function videoSnapPoints(project, options = {}) {
|
|
@@ -534,12 +717,72 @@ function videoClipsAt(project, time) {
|
|
|
534
717
|
return out;
|
|
535
718
|
}
|
|
536
719
|
|
|
720
|
+
// src/video-editor/clip-animation.ts
|
|
721
|
+
var VIDEO_CLIP_ANIMATION_DURATION = 500;
|
|
722
|
+
var EFFECTS = {
|
|
723
|
+
fade: { easeIn: "linear", easeOut: "linear" },
|
|
724
|
+
rise: { y: 0.06, easeIn: "ease-out", easeOut: "ease-in" },
|
|
725
|
+
drop: { y: -0.06, easeIn: "ease-out", easeOut: "ease-in" },
|
|
726
|
+
"slide-left": { x: 0.15, easeIn: "ease-out", easeOut: "ease-in" },
|
|
727
|
+
"slide-right": { x: -0.15, easeIn: "ease-out", easeOut: "ease-in" },
|
|
728
|
+
zoom: { scale: 0.6, easeIn: "ease-out", easeOut: "ease-in" },
|
|
729
|
+
// Overshoots past full size, then settles (a "back" curve).
|
|
730
|
+
pop: { scale: 0.3, easeIn: [0.34, 1.56, 0.64, 1], easeOut: [0.36, 0, 0.66, -0.56] }
|
|
731
|
+
};
|
|
732
|
+
var PROPS = ["opacity", "x", "y", "scale"];
|
|
733
|
+
function videoClipAnimationProperties(animation) {
|
|
734
|
+
const spec = EFFECTS[animation.effect];
|
|
735
|
+
return PROPS.filter((p) => p === "opacity" || spec[p] !== void 0);
|
|
736
|
+
}
|
|
737
|
+
function videoClipAnimationSpans(clip) {
|
|
738
|
+
const a = clip.animateIn ? _nullishCoalesce(clip.animateIn.duration, () => ( VIDEO_CLIP_ANIMATION_DURATION)) : 0;
|
|
739
|
+
const b = clip.animateOut ? _nullishCoalesce(clip.animateOut.duration, () => ( VIDEO_CLIP_ANIMATION_DURATION)) : 0;
|
|
740
|
+
const k = a + b > clip.duration ? clip.duration / (a + b) : 1;
|
|
741
|
+
return { in: a * k, out: b * k };
|
|
742
|
+
}
|
|
743
|
+
function videoClipAnimated(clip, project) {
|
|
744
|
+
if (!clip.animateIn && !clip.animateOut) return clip;
|
|
745
|
+
const spans = videoClipAnimationSpans(clip);
|
|
746
|
+
const out = { ...clip, transform: { ...clip.transform } };
|
|
747
|
+
for (const prop of PROPS) {
|
|
748
|
+
const current = prop === "opacity" ? clip.opacity : _optionalChain([clip, 'access', _4 => _4.transform, 'optionalAccess', _5 => _5[prop]]);
|
|
749
|
+
if (isVideoKeyframes(current)) continue;
|
|
750
|
+
const base = _nullishCoalesce(current, () => ( (prop === "opacity" || prop === "scale" ? 1 : 0)));
|
|
751
|
+
const away = (spec) => {
|
|
752
|
+
if (prop === "opacity") return 0;
|
|
753
|
+
const v = spec[prop];
|
|
754
|
+
if (v === void 0) return void 0;
|
|
755
|
+
return prop === "scale" ? base * v : base + v * (prop === "x" ? project.width : project.height);
|
|
756
|
+
};
|
|
757
|
+
const keys = [];
|
|
758
|
+
if (clip.animateIn) {
|
|
759
|
+
const spec = EFFECTS[clip.animateIn.effect];
|
|
760
|
+
const from = away(spec);
|
|
761
|
+
if (from !== void 0) keys.push({ t: 0, v: from, ease: spec.easeIn }, { t: spans.in, v: base });
|
|
762
|
+
}
|
|
763
|
+
if (clip.animateOut) {
|
|
764
|
+
const spec = EFFECTS[clip.animateOut.effect];
|
|
765
|
+
const to = away(spec);
|
|
766
|
+
if (to !== void 0) {
|
|
767
|
+
const start = clip.duration - spans.out;
|
|
768
|
+
if (!keys.length || keys[keys.length - 1].t < start) keys.push({ t: start, v: base, ease: spec.easeOut });
|
|
769
|
+
else keys[keys.length - 1] = { ...keys[keys.length - 1], ease: spec.easeOut };
|
|
770
|
+
keys.push({ t: clip.duration, v: to });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (!keys.length) continue;
|
|
774
|
+
if (prop === "opacity") out.opacity = { keys };
|
|
775
|
+
else out.transform[prop] = { keys };
|
|
776
|
+
}
|
|
777
|
+
return out;
|
|
778
|
+
}
|
|
779
|
+
|
|
537
780
|
// src/video-editor/check.ts
|
|
538
781
|
function videoClipKeyframes(clip) {
|
|
539
782
|
const out = [
|
|
540
783
|
{ property: "opacity", keys: videoKeysOf(clip.opacity) },
|
|
541
784
|
{ property: "volume", keys: clip.type === "media" ? videoKeysOf(clip.volume) : [] },
|
|
542
|
-
...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p, keys: videoKeysOf(_optionalChain([clip, 'access',
|
|
785
|
+
...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p, keys: videoKeysOf(_optionalChain([clip, 'access', _6 => _6.transform, 'optionalAccess', _7 => _7[p]])) }))
|
|
543
786
|
];
|
|
544
787
|
return out.filter((o) => o.keys.length);
|
|
545
788
|
}
|
|
@@ -552,6 +795,8 @@ var VIDEO_ISSUE_CODES = [
|
|
|
552
795
|
"transition-too-long",
|
|
553
796
|
"transition-handles",
|
|
554
797
|
"fades-too-long",
|
|
798
|
+
"animations-too-long",
|
|
799
|
+
"animation-keyed",
|
|
555
800
|
"keyframes-outside-clip",
|
|
556
801
|
"unknown-duration",
|
|
557
802
|
"unused-asset",
|
|
@@ -588,6 +833,16 @@ function checkVideoProject(project) {
|
|
|
588
833
|
issues.push({ code: "overlap", severity: "error", ...at, message: `Clips "${clip.id}" and "${next.id}" overlap on track "${track.id}" from ${formatVideoSeconds(next.start)}; move one of them.` });
|
|
589
834
|
if ((_nullishCoalesce(clip.fadeIn, () => ( 0))) + (_nullishCoalesce(clip.fadeOut, () => ( 0))) > clip.duration + VIDEO_TIME_EPSILON)
|
|
590
835
|
issues.push({ code: "fades-too-long", severity: "warning", ...at, message: `Clip "${clip.id}"'s fades (${_nullishCoalesce(clip.fadeIn, () => ( 0))} + ${_nullishCoalesce(clip.fadeOut, () => ( 0))} ms) are longer than the clip (${clip.duration} ms); shorten them.` });
|
|
836
|
+
const anim = (clip.animateIn ? _nullishCoalesce(clip.animateIn.duration, () => ( VIDEO_CLIP_ANIMATION_DURATION)) : 0) + (clip.animateOut ? _nullishCoalesce(clip.animateOut.duration, () => ( VIDEO_CLIP_ANIMATION_DURATION)) : 0);
|
|
837
|
+
if (anim > clip.duration + VIDEO_TIME_EPSILON)
|
|
838
|
+
issues.push({ code: "animations-too-long", severity: "warning", ...at, message: `Clip "${clip.id}"'s entrance and exit (${anim} ms together) are longer than the clip (${clip.duration} ms), so both are shortened; give them shorter durations.` });
|
|
839
|
+
for (const which of ["animateIn", "animateOut"]) {
|
|
840
|
+
const a = clip[which];
|
|
841
|
+
if (!a) continue;
|
|
842
|
+
const keyed = videoClipAnimationProperties(a).filter((p) => videoKeysOf(p === "opacity" ? clip.opacity : _optionalChain([clip, 'access', _8 => _8.transform, 'optionalAccess', _9 => _9[p]])).length);
|
|
843
|
+
if (keyed.length)
|
|
844
|
+
issues.push({ code: "animation-keyed", severity: "note", ...at, message: `Clip "${clip.id}"'s ${a.effect} ${which === "animateIn" ? "entrance" : "exit"} leaves ${keyed.join(", ")} alone, because the clip keys ${keyed.length > 1 ? "them" : "it"} itself; remove those keyframes or the animation.` });
|
|
845
|
+
}
|
|
591
846
|
for (const { property, keys } of videoClipKeyframes(clip)) {
|
|
592
847
|
const outside = keys.filter((k) => k.t < -VIDEO_TIME_EPSILON || k.t > clip.duration + VIDEO_TIME_EPSILON);
|
|
593
848
|
if (outside.length)
|
|
@@ -692,7 +947,7 @@ function clipAnimatables(clip) {
|
|
|
692
947
|
{ get: () => c.opacity, set: (v) => c.opacity = v },
|
|
693
948
|
{ get: () => c.volume, set: (v) => c.volume = v }
|
|
694
949
|
];
|
|
695
|
-
for (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => _optionalChain([c, 'access',
|
|
950
|
+
for (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => _optionalChain([c, 'access', _10 => _10.transform, 'optionalAccess', _11 => _11[p]]), set: (v) => c.transform && (c.transform[p] = v) });
|
|
696
951
|
return out;
|
|
697
952
|
}
|
|
698
953
|
function retimeClipKeys(clip, fn) {
|
|
@@ -701,7 +956,7 @@ function retimeClipKeys(clip, fn) {
|
|
|
701
956
|
if (isVideoKeyframes(v)) a.set(fn(v));
|
|
702
957
|
}
|
|
703
958
|
}
|
|
704
|
-
var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
|
|
959
|
+
var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
|
|
705
960
|
var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
|
|
706
961
|
var Editor = (_class = class {
|
|
707
962
|
constructor(p) {;_class.prototype.__init.call(this);
|
|
@@ -740,7 +995,7 @@ var Editor = (_class = class {
|
|
|
740
995
|
kindFits(track, clip) {
|
|
741
996
|
const asset = clip.type === "media" ? this.asset(clip.asset) : null;
|
|
742
997
|
if (track.kind === "audio" && (clip.type !== "media" || asset.kind === "image")) refuse(`Track "${track.id}" is an audio track; ${clip.type === "media" ? "images" : `${clip.type} clips`} go on a video track.`);
|
|
743
|
-
if (track.kind === "video" && _optionalChain([asset, 'optionalAccess',
|
|
998
|
+
if (track.kind === "video" && _optionalChain([asset, 'optionalAccess', _12 => _12.kind]) === "audio") refuse(`Track "${track.id}" is a video track; audio asset "${asset.id}" goes on an audio track.`);
|
|
744
999
|
}
|
|
745
1000
|
/** Moves every clip on `track` starting at or after `from` by `delta`. */
|
|
746
1001
|
shift(track, from, delta, except = /* @__PURE__ */ new Set()) {
|
|
@@ -782,7 +1037,7 @@ var Editor = (_class = class {
|
|
|
782
1037
|
}
|
|
783
1038
|
/** Moves a media clip's in point by `delta` of timeline time (stills, titles and colours have none). */
|
|
784
1039
|
moveIn(clip, delta) {
|
|
785
|
-
if (clip.type !== "media" || _optionalChain([videoAssetMap, 'call',
|
|
1040
|
+
if (clip.type !== "media" || _optionalChain([videoAssetMap, 'call', _13 => _13(this.p), 'access', _14 => _14.get, 'call', _15 => _15(clip.asset), 'optionalAccess', _16 => _16.kind]) === "image") return;
|
|
786
1041
|
clip.in = Math.max(0, (_nullishCoalesce(clip.in, () => ( 0))) + delta * videoClipSpeed(clip));
|
|
787
1042
|
if (clip.in < 1e-9) delete clip.in;
|
|
788
1043
|
}
|
|
@@ -797,6 +1052,8 @@ var Editor = (_class = class {
|
|
|
797
1052
|
const right = { ...structuredClone(clip), id: _nullishCoalesce(id, () => ( freshId(`${clip.id}-`, this.ids()))), start: at, duration: end - at };
|
|
798
1053
|
delete right.transition;
|
|
799
1054
|
delete right.fadeIn;
|
|
1055
|
+
delete right.fadeInCurve;
|
|
1056
|
+
delete right.animateIn;
|
|
800
1057
|
const leftValues = clipAnimatables(clip);
|
|
801
1058
|
clipAnimatables(right).forEach((r, i) => {
|
|
802
1059
|
const [l, rr] = splitVideoAnimatable(leftValues[i].get(), at - clip.start);
|
|
@@ -806,6 +1063,8 @@ var Editor = (_class = class {
|
|
|
806
1063
|
this.moveIn(right, at - clip.start);
|
|
807
1064
|
clip.duration = at - clip.start;
|
|
808
1065
|
delete clip.fadeOut;
|
|
1066
|
+
delete clip.animateOut;
|
|
1067
|
+
if (clip.type === "media") delete clip.fadeOutCurve;
|
|
809
1068
|
track.clips.push(right);
|
|
810
1069
|
track.clips.sort((a, b) => a.start - b.start);
|
|
811
1070
|
return right;
|
|
@@ -1044,7 +1303,7 @@ var Editor = (_class = class {
|
|
|
1044
1303
|
if (clip.type === "media") {
|
|
1045
1304
|
const asset = assets.get(clip.asset);
|
|
1046
1305
|
const out = (_nullishCoalesce(clip.in, () => ( 0))) + duration * speed;
|
|
1047
|
-
if (_optionalChain([asset, 'optionalAccess',
|
|
1306
|
+
if (_optionalChain([asset, 'optionalAccess', _17 => _17.duration]) !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
|
|
1048
1307
|
refuse(`"${clip.id}" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (_nullishCoalesce(clip.in, () => ( 0)))) / speed)}, where "${asset.id}" runs out.`);
|
|
1049
1308
|
}
|
|
1050
1309
|
if (e.ripple) {
|
|
@@ -1100,7 +1359,7 @@ var Editor = (_class = class {
|
|
|
1100
1359
|
let stretch = 1;
|
|
1101
1360
|
if (at.clip.type === "media" && !("duration" in set)) {
|
|
1102
1361
|
const was = videoClipSpeed(at.clip);
|
|
1103
|
-
const now = "speed" in set ? set.speed : _optionalChain([e, 'access',
|
|
1362
|
+
const now = "speed" in set ? set.speed : _optionalChain([e, 'access', _18 => _18.remove, 'optionalAccess', _19 => _19.includes, 'call', _20 => _20("speed")]) ? 1 : was;
|
|
1104
1363
|
if (now !== was) {
|
|
1105
1364
|
set.duration = at.clip.duration * was / now;
|
|
1106
1365
|
stretch = was / now;
|
|
@@ -1196,7 +1455,7 @@ var Editor = (_class = class {
|
|
|
1196
1455
|
this.trimHead(after, delta);
|
|
1197
1456
|
}
|
|
1198
1457
|
track.clips.sort((a, b) => a.start - b.start);
|
|
1199
|
-
return this.outcomes.push({ op: e.op, clip: clip.id, clips: [_optionalChain([before, 'optionalAccess',
|
|
1458
|
+
return this.outcomes.push({ op: e.op, clip: clip.id, clips: [_optionalChain([before, 'optionalAccess', _21 => _21.id]), clip.id, _optionalChain([after, 'optionalAccess', _22 => _22.id])].filter((x) => !!x), track: track.id, delta });
|
|
1200
1459
|
}
|
|
1201
1460
|
case "lift_range":
|
|
1202
1461
|
case "extract_range": {
|
|
@@ -1227,7 +1486,7 @@ var Editor = (_class = class {
|
|
|
1227
1486
|
const limits = VIDEO_KEYFRAME_LIMITS[e.property];
|
|
1228
1487
|
const transform = VIDEO_TRANSFORM_PROPERTIES.includes(e.property);
|
|
1229
1488
|
const c = clip;
|
|
1230
|
-
const current = transform ? _optionalChain([c, 'access',
|
|
1489
|
+
const current = transform ? _optionalChain([c, 'access', _23 => _23.transform, 'optionalAccess', _24 => _24[e.property]]) : c[e.property];
|
|
1231
1490
|
let value;
|
|
1232
1491
|
if (e.keys === null) {
|
|
1233
1492
|
const v = videoValueAt(current, 0, limits.default);
|
|
@@ -1309,7 +1568,7 @@ function applyVideoEdits(project, edits) {
|
|
|
1309
1568
|
editor.run(edit);
|
|
1310
1569
|
} catch (error) {
|
|
1311
1570
|
const message = error.message;
|
|
1312
|
-
return { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess',
|
|
1571
|
+
return { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess', _25 => _25.op])}): ` : ""}${message}${/Nothing was changed\.$/.test(message) ? "" : " Nothing was changed."}` };
|
|
1313
1572
|
}
|
|
1314
1573
|
}
|
|
1315
1574
|
let next;
|
|
@@ -1328,7 +1587,7 @@ function applyVideoEdits(project, edits) {
|
|
|
1328
1587
|
|
|
1329
1588
|
// src/video-editor/evaluate.ts
|
|
1330
1589
|
function videoTransformAt(transform, clipTime = 0) {
|
|
1331
|
-
return { x: videoValueAt(_optionalChain([transform, 'optionalAccess',
|
|
1590
|
+
return { x: videoValueAt(_optionalChain([transform, 'optionalAccess', _26 => _26.x]), clipTime, 0), y: videoValueAt(_optionalChain([transform, 'optionalAccess', _27 => _27.y]), clipTime, 0), scale: videoValueAt(_optionalChain([transform, 'optionalAccess', _28 => _28.scale]), clipTime, 1), rotation: videoValueAt(_optionalChain([transform, 'optionalAccess', _29 => _29.rotation]), clipTime, 0) };
|
|
1332
1591
|
}
|
|
1333
1592
|
function placeVideoRect(frame, size, fit = "contain", transform = {}, clipTime = 0) {
|
|
1334
1593
|
let w = size.width;
|
|
@@ -1356,19 +1615,40 @@ function videoTitleFrame(project, clip, clipTime = 0) {
|
|
|
1356
1615
|
return { time: clip.start, width: project.width, height: project.height, background: "rgba(0,0,0,0)", layers: [layer], audio: [] };
|
|
1357
1616
|
}
|
|
1358
1617
|
var clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
1359
|
-
|
|
1618
|
+
var videoTransitionEase = (progress) => (1 - Math.cos(Math.PI * clamp01(progress))) / 2;
|
|
1619
|
+
function videoFadeCurveGain(curve, x) {
|
|
1620
|
+
const t = clamp01(x);
|
|
1621
|
+
switch (curve) {
|
|
1622
|
+
case "equal-power":
|
|
1623
|
+
return Math.sin(t * Math.PI / 2);
|
|
1624
|
+
case "s-curve":
|
|
1625
|
+
return (1 - Math.cos(t * Math.PI)) / 2;
|
|
1626
|
+
case "exponential":
|
|
1627
|
+
return t * t * t;
|
|
1628
|
+
case "logarithmic":
|
|
1629
|
+
return 1 - (1 - t) * (1 - t);
|
|
1630
|
+
default:
|
|
1631
|
+
return t;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
function fadeGain(clip, time, sound = false) {
|
|
1360
1635
|
let g = 1;
|
|
1361
|
-
|
|
1362
|
-
if (clip.
|
|
1636
|
+
const shape = sound && clip.type === "media" ? clip : void 0;
|
|
1637
|
+
if (clip.fadeIn) g *= videoFadeCurveGain(_optionalChain([shape, 'optionalAccess', _30 => _30.fadeInCurve]), (time - clip.start) / clip.fadeIn);
|
|
1638
|
+
if (clip.fadeOut) g *= videoFadeCurveGain(_optionalChain([shape, 'optionalAccess', _31 => _31.fadeOutCurve]), (videoClipEnd(clip) - time) / clip.fadeOut);
|
|
1363
1639
|
return g;
|
|
1364
1640
|
}
|
|
1641
|
+
function videoClipSoundGain(clip, clipTime) {
|
|
1642
|
+
const volume = clip.type === "media" ? videoValueAt(clip.volume, clipTime, 1) : 1;
|
|
1643
|
+
return volume * fadeGain(clip, clip.start + Math.min(Math.max(clipTime, 0), clip.duration), true);
|
|
1644
|
+
}
|
|
1365
1645
|
function activeOnTrack(track, time) {
|
|
1366
1646
|
const out = [];
|
|
1367
1647
|
for (const clip of sortedVideoClips(track)) {
|
|
1368
1648
|
const before = clip.transition ? videoClipBefore(track, clip) : null;
|
|
1369
1649
|
const lead = before ? clip.transition.duration / 2 : 0;
|
|
1370
1650
|
const next = videoClipAfter(track, clip);
|
|
1371
|
-
const tail = _optionalChain([next, 'optionalAccess',
|
|
1651
|
+
const tail = _optionalChain([next, 'optionalAccess', _32 => _32.transition]) ? next.transition.duration / 2 : 0;
|
|
1372
1652
|
if (time < clip.start - lead || time >= videoClipEnd(clip) + tail) continue;
|
|
1373
1653
|
const a = { clip };
|
|
1374
1654
|
if (lead && time < clip.start + lead) a.incoming = { progress: clamp01((time - (clip.start - lead)) / (2 * lead)), type: clip.transition.type, transition: clip.transition };
|
|
@@ -1385,35 +1665,55 @@ function videoFrameAt(project, time) {
|
|
|
1385
1665
|
for (let ti = project.tracks.length - 1; ti >= 0; ti--) {
|
|
1386
1666
|
const track = project.tracks[ti];
|
|
1387
1667
|
for (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {
|
|
1388
|
-
if (_optionalChain([incoming, 'optionalAccess',
|
|
1389
|
-
if (_optionalChain([outgoing, 'optionalAccess',
|
|
1390
|
-
const
|
|
1391
|
-
const
|
|
1392
|
-
const
|
|
1668
|
+
if (_optionalChain([incoming, 'optionalAccess', _33 => _33.type]) === "dip" && incoming.progress < 0.5) continue;
|
|
1669
|
+
if (_optionalChain([outgoing, 'optionalAccess', _34 => _34.type]) === "dip" && outgoing.progress >= 0.5) continue;
|
|
1670
|
+
const inside = Math.min(Math.max(time, clip.start), videoClipEnd(clip));
|
|
1671
|
+
const fade = fadeGain(clip, inside);
|
|
1672
|
+
const level = (t, transition, into) => videoFadeCurveGain(videoTransitionAudioCurve(transition), t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress);
|
|
1673
|
+
const mix = (incoming ? level(incoming, incoming.transition, true) : 1) * (outgoing ? level(outgoing, outgoing.next.transition, false) : 1);
|
|
1393
1674
|
const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
|
|
1394
1675
|
const sourceTime = sourceTimeAt(clip, time);
|
|
1395
1676
|
const clipTime = time - clip.start;
|
|
1396
|
-
const audible =
|
|
1677
|
+
const audible = videoAssetHasSound(asset);
|
|
1397
1678
|
if (audible && clip.type === "media" && !clip.muted && !track.muted) {
|
|
1398
|
-
const gain = videoValueAt(clip.volume, clipTime, 1) * (_nullishCoalesce(track.volume, () => ( 1))) *
|
|
1679
|
+
const gain = videoValueAt(clip.volume, clipTime, 1) * (_nullishCoalesce(track.volume, () => ( 1))) * fadeGain(clip, inside, true) * mix;
|
|
1399
1680
|
audio.push({ clip: clip.id, track: track.id, asset: asset.id, src: asset.src, sourceTime, gain, speed: videoClipSpeed(clip) });
|
|
1400
1681
|
}
|
|
1401
1682
|
if (track.kind !== "video" || track.hidden) continue;
|
|
1402
1683
|
if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
|
|
1403
|
-
|
|
1684
|
+
const shown = videoClipAnimated(clip, project);
|
|
1685
|
+
let opacity = videoValueAt(shown.opacity, clipTime, 1) * fade;
|
|
1404
1686
|
let wipe;
|
|
1405
1687
|
let dip = null;
|
|
1688
|
+
let iris;
|
|
1689
|
+
let shift = 0;
|
|
1690
|
+
let shiftDirection = "right";
|
|
1406
1691
|
if (incoming) {
|
|
1407
1692
|
if (incoming.type === "crossfade") opacity *= incoming.progress;
|
|
1408
1693
|
else if (incoming.type === "wipe") wipe = { direction: _nullishCoalesce(incoming.transition.direction, () => ( "right")), progress: incoming.progress };
|
|
1409
|
-
else
|
|
1694
|
+
else if (incoming.type === "iris") iris = { progress: incoming.progress };
|
|
1695
|
+
else if (incoming.type === "push" || incoming.type === "slide") {
|
|
1696
|
+
shiftDirection = _nullishCoalesce(incoming.transition.direction, () => ( "right"));
|
|
1697
|
+
shift = videoTransitionEase(incoming.progress) - 1;
|
|
1698
|
+
} else dip = { color: _nullishCoalesce(incoming.transition.color, () => ( "#000000")), opacity: (1 - incoming.progress) * 2 };
|
|
1699
|
+
}
|
|
1700
|
+
if (_optionalChain([outgoing, 'optionalAccess', _35 => _35.type]) === "dip") dip = { color: _nullishCoalesce(outgoing.next.transition.color, () => ( "#000000")), opacity: outgoing.progress * 2 };
|
|
1701
|
+
if (_optionalChain([outgoing, 'optionalAccess', _36 => _36.type]) === "push") {
|
|
1702
|
+
shiftDirection = _nullishCoalesce(outgoing.next.transition.direction, () => ( "right"));
|
|
1703
|
+
shift = videoTransitionEase(outgoing.progress);
|
|
1410
1704
|
}
|
|
1411
|
-
if (_optionalChain([outgoing, 'optionalAccess', _28 => _28.type]) === "dip") dip = { color: _nullishCoalesce(outgoing.next.transition.color, () => ( "#000000")), opacity: outgoing.progress * 2 };
|
|
1412
1705
|
const size = clip.type === "media" ? { width: _nullishCoalesce(asset.width, () => ( project.width)), height: _nullishCoalesce(asset.height, () => ( project.height)) } : { width: project.width, height: project.height };
|
|
1413
1706
|
const fit = _nullishCoalesce(clip.fit, () => ( (clip.type === "media" ? "contain" : "fill")));
|
|
1414
|
-
const
|
|
1415
|
-
const
|
|
1707
|
+
const placed = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, shown.transform, clipTime);
|
|
1708
|
+
const across = shiftDirection === "left" || shiftDirection === "right";
|
|
1709
|
+
const sign = shiftDirection === "left" || shiftDirection === "up" ? -1 : 1;
|
|
1710
|
+
const rect = shift ? { ...placed, ...across ? { x: placed.x + sign * shift * project.width } : { y: placed.y + sign * shift * project.height } } : placed;
|
|
1711
|
+
const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(_optionalChain([shown, 'access', _37 => _37.transform, 'optionalAccess', _38 => _38.rotation]), clipTime, 0) };
|
|
1416
1712
|
if (wipe) layer.wipe = wipe;
|
|
1713
|
+
if (iris) layer.iris = iris;
|
|
1714
|
+
if (clip.blend && clip.blend !== "normal") layer.blend = clip.blend;
|
|
1715
|
+
const grade = normalizeVideoGrade(clip.grade);
|
|
1716
|
+
if (grade) layer.grade = grade;
|
|
1417
1717
|
if (clip.type === "media") Object.assign(layer, { asset: asset.id, assetKind: asset.kind, src: asset.src, sourceTime });
|
|
1418
1718
|
if (clip.type === "text") layer.text = videoTextStyle(clip);
|
|
1419
1719
|
if (clip.type === "color") layer.color = clip.color;
|
|
@@ -1424,9 +1724,227 @@ function videoFrameAt(project, time) {
|
|
|
1424
1724
|
return { time, width: project.width, height: project.height, background: _nullishCoalesce(project.background, () => ( "#000000")), layers, audio };
|
|
1425
1725
|
}
|
|
1426
1726
|
|
|
1727
|
+
// src/video-editor/audio.ts
|
|
1728
|
+
var VIDEO_PEAK_BLOCK = 32;
|
|
1729
|
+
function buildVideoAudioPeaks(channels, sampleRate, blockSize = VIDEO_PEAK_BLOCK) {
|
|
1730
|
+
if (!(sampleRate > 0)) throw new Error("The sample rate must be above 0.");
|
|
1731
|
+
const length = channels.reduce((n2, c) => Math.max(n2, c.length), 0);
|
|
1732
|
+
const blocks = Math.max(1, Math.ceil(length / blockSize));
|
|
1733
|
+
const level0 = new Float32Array(blocks * 2);
|
|
1734
|
+
for (let b = 0; b < blocks; b++) {
|
|
1735
|
+
let lo = Infinity;
|
|
1736
|
+
let hi = -Infinity;
|
|
1737
|
+
const end = Math.min(length, (b + 1) * blockSize);
|
|
1738
|
+
for (const channel of channels) {
|
|
1739
|
+
for (let i = b * blockSize; i < Math.min(end, channel.length); i++) {
|
|
1740
|
+
const v = channel[i];
|
|
1741
|
+
if (!Number.isFinite(v)) continue;
|
|
1742
|
+
if (v < lo) lo = v;
|
|
1743
|
+
if (v > hi) hi = v;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
level0[2 * b] = lo === Infinity ? 0 : lo;
|
|
1747
|
+
level0[2 * b + 1] = hi === -Infinity ? 0 : hi;
|
|
1748
|
+
}
|
|
1749
|
+
const levels = [level0];
|
|
1750
|
+
while (levels[levels.length - 1].length > 2) {
|
|
1751
|
+
const below = levels[levels.length - 1];
|
|
1752
|
+
const n2 = below.length / 2;
|
|
1753
|
+
const up = new Float32Array(Math.ceil(n2 / 4) * 2);
|
|
1754
|
+
for (let j = 0; j < up.length / 2; j++) {
|
|
1755
|
+
let lo = Infinity;
|
|
1756
|
+
let hi = -Infinity;
|
|
1757
|
+
for (let i = 4 * j; i < Math.min(n2, 4 * j + 4); i++) {
|
|
1758
|
+
lo = Math.min(lo, below[2 * i]);
|
|
1759
|
+
hi = Math.max(hi, below[2 * i + 1]);
|
|
1760
|
+
}
|
|
1761
|
+
up[2 * j] = lo;
|
|
1762
|
+
up[2 * j + 1] = hi;
|
|
1763
|
+
}
|
|
1764
|
+
levels.push(up);
|
|
1765
|
+
}
|
|
1766
|
+
return { sampleRate, length, blockSize, levels };
|
|
1767
|
+
}
|
|
1768
|
+
var videoAudioPeaksDuration = (peaks) => peaks.length * 1e3 / peaks.sampleRate;
|
|
1769
|
+
function videoAudioPeakRange(peaks, from, to) {
|
|
1770
|
+
const blocks = peaks.levels[0].length / 2;
|
|
1771
|
+
let a = Math.max(0, Math.floor(from * peaks.sampleRate / 1e3 / peaks.blockSize));
|
|
1772
|
+
let b = Math.min(blocks, Math.ceil(to * peaks.sampleRate / 1e3 / peaks.blockSize));
|
|
1773
|
+
if (b <= a) {
|
|
1774
|
+
if (a >= blocks || to < 0) return { min: 0, max: 0 };
|
|
1775
|
+
b = a + 1;
|
|
1776
|
+
}
|
|
1777
|
+
let min = Infinity;
|
|
1778
|
+
let max = -Infinity;
|
|
1779
|
+
const take = (level, i) => {
|
|
1780
|
+
if (2 * i + 1 >= level.length) return;
|
|
1781
|
+
if (level[2 * i] < min) min = level[2 * i];
|
|
1782
|
+
if (level[2 * i + 1] > max) max = level[2 * i + 1];
|
|
1783
|
+
};
|
|
1784
|
+
for (let k = 0; a < b; k++) {
|
|
1785
|
+
const level = peaks.levels[k];
|
|
1786
|
+
if (k === peaks.levels.length - 1) {
|
|
1787
|
+
for (let i = a; i < b; i++) take(level, i);
|
|
1788
|
+
break;
|
|
1789
|
+
}
|
|
1790
|
+
while (a < b && a % 4) take(level, a++);
|
|
1791
|
+
while (a < b && b % 4) take(level, --b);
|
|
1792
|
+
a /= 4;
|
|
1793
|
+
b /= 4;
|
|
1794
|
+
}
|
|
1795
|
+
return min === Infinity ? { min: 0, max: 0 } : { min, max };
|
|
1796
|
+
}
|
|
1797
|
+
function videoAudioPeakSpans(peaks, from, to, columns) {
|
|
1798
|
+
const n2 = Math.max(0, Math.floor(columns));
|
|
1799
|
+
const out = new Float32Array(n2 * 2);
|
|
1800
|
+
const step = (to - from) / Math.max(1, n2);
|
|
1801
|
+
for (let c = 0; c < n2; c++) {
|
|
1802
|
+
const { min, max } = videoAudioPeakRange(peaks, from + c * step, from + (c + 1) * step);
|
|
1803
|
+
out[2 * c] = min;
|
|
1804
|
+
out[2 * c + 1] = max;
|
|
1805
|
+
}
|
|
1806
|
+
return out;
|
|
1807
|
+
}
|
|
1808
|
+
function videoClipWaveform(clip, peaks, columns) {
|
|
1809
|
+
const n2 = Math.max(0, Math.floor(columns));
|
|
1810
|
+
const out = new Float32Array(n2 * 2);
|
|
1811
|
+
const speed = _nullishCoalesce(clip.speed, () => ( 1));
|
|
1812
|
+
const start = _nullishCoalesce(clip.in, () => ( 0));
|
|
1813
|
+
const step = clip.duration / Math.max(1, n2);
|
|
1814
|
+
for (let c = 0; c < n2; c++) {
|
|
1815
|
+
const { min, max } = videoAudioPeakRange(peaks, start + c * step * speed, start + (c + 1) * step * speed);
|
|
1816
|
+
const gain = videoClipSoundGain(clip, (c + 0.5) * step);
|
|
1817
|
+
out[2 * c] = min * gain;
|
|
1818
|
+
out[2 * c + 1] = max * gain;
|
|
1819
|
+
}
|
|
1820
|
+
return out;
|
|
1821
|
+
}
|
|
1822
|
+
function videoWaveformPath(spans, width, height) {
|
|
1823
|
+
const n2 = spans.length / 2;
|
|
1824
|
+
if (!n2 || width <= 0 || height <= 0) return "";
|
|
1825
|
+
const mid = height / 2;
|
|
1826
|
+
const y = (v) => (mid - Math.max(-1, Math.min(1, v)) * mid).toFixed(1);
|
|
1827
|
+
const x = (i) => ((i + 0.5) * (width / n2)).toFixed(1);
|
|
1828
|
+
const floor = 0.5 / mid;
|
|
1829
|
+
const top = [];
|
|
1830
|
+
const bottom = [];
|
|
1831
|
+
for (let i = 0; i < n2; i++) top.push(`${i ? "L" : "M"}${x(i)} ${y(Math.max(spans[2 * i + 1], floor))}`);
|
|
1832
|
+
for (let i = n2 - 1; i >= 0; i--) bottom.push(`L${x(i)} ${y(Math.min(spans[2 * i], -floor))}`);
|
|
1833
|
+
return `${top.join("")}${bottom.join("")}Z`;
|
|
1834
|
+
}
|
|
1835
|
+
function videoClipWaveformPath(clip, peaks, width, height) {
|
|
1836
|
+
const columns = Math.max(1, Math.min(2e3, Math.ceil(width / 2)));
|
|
1837
|
+
return videoWaveformPath(videoClipWaveform(clip, peaks, columns), width, height);
|
|
1838
|
+
}
|
|
1839
|
+
var videoPeakDb = (value) => Math.abs(value) > 1e-5 ? Math.max(-100, Math.round(200 * Math.log10(Math.abs(value))) / 10) : -100;
|
|
1840
|
+
function videoAudioLevels(peaks, from, to, buckets) {
|
|
1841
|
+
const spans = videoAudioPeakSpans(peaks, from, to, buckets);
|
|
1842
|
+
const step = (to - from) / Math.max(1, buckets);
|
|
1843
|
+
return Array.from({ length: spans.length / 2 }, (_, i) => ({ start: from + i * step, end: from + (i + 1) * step, peakDb: videoPeakDb(Math.max(Math.abs(spans[2 * i]), Math.abs(spans[2 * i + 1]))) }));
|
|
1844
|
+
}
|
|
1845
|
+
function findVideoAudioSilences(peaks, from, to, options = {}) {
|
|
1846
|
+
const threshold = _nullishCoalesce(options.thresholdDb, () => ( -40));
|
|
1847
|
+
const minDuration = _nullishCoalesce(options.minDuration, () => ( 300));
|
|
1848
|
+
const resolution = Math.max(1, _nullishCoalesce(options.resolution, () => ( 10)));
|
|
1849
|
+
const buckets = Math.max(1, Math.ceil((to - from) / resolution));
|
|
1850
|
+
const out = [];
|
|
1851
|
+
let run = null;
|
|
1852
|
+
const levels = videoAudioLevels(peaks, from, to, buckets);
|
|
1853
|
+
levels.forEach((l, i) => {
|
|
1854
|
+
const quiet = l.peakDb < threshold;
|
|
1855
|
+
if (quiet && run === null) run = l.start;
|
|
1856
|
+
if ((!quiet || i === levels.length - 1) && run !== null) {
|
|
1857
|
+
const end = quiet ? l.end : l.start;
|
|
1858
|
+
if (end - run >= minDuration) out.push({ start: run, end });
|
|
1859
|
+
run = null;
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
return out;
|
|
1863
|
+
}
|
|
1864
|
+
async function decodeVideoAudioPeaks(src, options = {}) {
|
|
1865
|
+
const Offline = globalThis.OfflineAudioContext;
|
|
1866
|
+
if (!Offline || typeof fetch === "undefined") throw new Error("Audio can only be decoded in a browser with Web Audio.");
|
|
1867
|
+
const response = await fetch(src, options.signal ? { signal: options.signal } : {});
|
|
1868
|
+
if (!response.ok) throw new Error(`"${src}" could not be loaded (${response.status}).`);
|
|
1869
|
+
const data = await response.arrayBuffer();
|
|
1870
|
+
let buffer;
|
|
1871
|
+
try {
|
|
1872
|
+
buffer = await new Offline(1, 1, 44100).decodeAudioData(data);
|
|
1873
|
+
} catch (e2) {
|
|
1874
|
+
throw new Error(`"${src}" has no sound this browser can decode.`);
|
|
1875
|
+
}
|
|
1876
|
+
return buildVideoAudioPeaks(Array.from({ length: buffer.numberOfChannels }, (_, i) => buffer.getChannelData(i)), buffer.sampleRate);
|
|
1877
|
+
}
|
|
1878
|
+
var VideoAudioPeaksCache = (_class2 = class {
|
|
1879
|
+
constructor(loader = (asset) => decodeVideoAudioPeaks(asset.src)) {;_class2.prototype.__init2.call(this);_class2.prototype.__init3.call(this);_class2.prototype.__init4.call(this);_class2.prototype.__init5.call(this);
|
|
1880
|
+
this.loader = loader;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
__init2() {this.ready = /* @__PURE__ */ new Map()}
|
|
1884
|
+
__init3() {this.pending = /* @__PURE__ */ new Map()}
|
|
1885
|
+
__init4() {this.failed = /* @__PURE__ */ new Map()}
|
|
1886
|
+
__init5() {this.listeners = /* @__PURE__ */ new Set()}
|
|
1887
|
+
/** The peaks if ready, without starting to load them. */
|
|
1888
|
+
peek(asset) {
|
|
1889
|
+
return this.ready.get(asset.src);
|
|
1890
|
+
}
|
|
1891
|
+
/** The peaks if ready; otherwise `undefined`, and loading starts (once) unless it failed. */
|
|
1892
|
+
get(asset) {
|
|
1893
|
+
const peaks = this.ready.get(asset.src);
|
|
1894
|
+
if (!peaks && !this.failed.has(asset.src)) void this.load(asset).catch(() => {
|
|
1895
|
+
});
|
|
1896
|
+
return peaks;
|
|
1897
|
+
}
|
|
1898
|
+
/** Why loading an asset's peaks failed, if it did. */
|
|
1899
|
+
error(asset) {
|
|
1900
|
+
return this.failed.get(asset.src);
|
|
1901
|
+
}
|
|
1902
|
+
/** The peaks, loading them if needed. Rejects with a readable error. */
|
|
1903
|
+
load(asset) {
|
|
1904
|
+
const ready = this.ready.get(asset.src);
|
|
1905
|
+
if (ready) return Promise.resolve(ready);
|
|
1906
|
+
const failed = this.failed.get(asset.src);
|
|
1907
|
+
if (failed) return Promise.reject(new Error(failed));
|
|
1908
|
+
let pending = this.pending.get(asset.src);
|
|
1909
|
+
if (!pending) {
|
|
1910
|
+
pending = this.loader(asset).then(
|
|
1911
|
+
(peaks) => {
|
|
1912
|
+
this.pending.delete(asset.src);
|
|
1913
|
+
this.ready.set(asset.src, peaks);
|
|
1914
|
+
this.emit();
|
|
1915
|
+
return peaks;
|
|
1916
|
+
},
|
|
1917
|
+
(e) => {
|
|
1918
|
+
this.pending.delete(asset.src);
|
|
1919
|
+
const message = `The sound of "${asset.id}" could not be read: ${e instanceof Error ? e.message : String(e)}`;
|
|
1920
|
+
this.failed.set(asset.src, message);
|
|
1921
|
+
this.emit();
|
|
1922
|
+
throw new Error(message);
|
|
1923
|
+
}
|
|
1924
|
+
);
|
|
1925
|
+
this.pending.set(asset.src, pending);
|
|
1926
|
+
}
|
|
1927
|
+
return pending;
|
|
1928
|
+
}
|
|
1929
|
+
/** Uses peaks the host already has (computed earlier or stored). */
|
|
1930
|
+
set(src, peaks) {
|
|
1931
|
+
this.ready.set(src, peaks);
|
|
1932
|
+
this.failed.delete(src);
|
|
1933
|
+
this.emit();
|
|
1934
|
+
}
|
|
1935
|
+
subscribe(fn) {
|
|
1936
|
+
this.listeners.add(fn);
|
|
1937
|
+
return () => this.listeners.delete(fn);
|
|
1938
|
+
}
|
|
1939
|
+
emit() {
|
|
1940
|
+
for (const fn of this.listeners) fn();
|
|
1941
|
+
}
|
|
1942
|
+
}, _class2);
|
|
1943
|
+
|
|
1427
1944
|
// src/video-editor/render.ts
|
|
1428
1945
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1429
1946
|
var n = (v) => String(Math.round(v * 100) / 100);
|
|
1947
|
+
var videoIrisRadius = (frame, progress) => progress * Math.hypot(frame.width / 2, frame.height / 2);
|
|
1430
1948
|
function videoWipeRect(rect, wipe) {
|
|
1431
1949
|
const { direction, progress } = wipe;
|
|
1432
1950
|
const w = rect.width * progress;
|
|
@@ -1444,6 +1962,11 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
|
1444
1962
|
const { rect } = layer;
|
|
1445
1963
|
const attrs = [];
|
|
1446
1964
|
if (layer.opacity < 1) attrs.push(`opacity="${n(layer.opacity)}"`);
|
|
1965
|
+
if (layer.blend) attrs.push(`style="mix-blend-mode:${layer.blend === "add" ? "plus-lighter" : layer.blend}"`);
|
|
1966
|
+
if (layer.grade) {
|
|
1967
|
+
defs.push(videoGradeSvgFilter(`g${i}`, layer.grade));
|
|
1968
|
+
attrs.push(`filter="url(#g${i})"`);
|
|
1969
|
+
}
|
|
1447
1970
|
if (layer.rotation) attrs.push(`transform="rotate(${n(layer.rotation)} ${n(rect.x + rect.width / 2)} ${n(rect.y + rect.height / 2)})"`);
|
|
1448
1971
|
if (layer.wipe) {
|
|
1449
1972
|
const r = videoWipeRect(rect, layer.wipe);
|
|
@@ -1475,7 +1998,12 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
|
1475
1998
|
inner = `<rect ${box} fill="#334155" stroke="#94a3b8" stroke-width="2"/><text x="${n(rect.x + rect.width / 2)}" y="${n(rect.y + rect.height / 2)}" text-anchor="middle" dominant-baseline="central" font-family="sans-serif" font-size="${n(size)}" fill="#e2e8f0">${esc(label)}</text>`;
|
|
1476
1999
|
}
|
|
1477
2000
|
}
|
|
1478
|
-
|
|
2001
|
+
let item = attrs.length ? `<g ${attrs.join(" ")}>${inner}</g>` : inner;
|
|
2002
|
+
if (layer.iris) {
|
|
2003
|
+
defs.push(`<clipPath id="i${i}"><circle cx="${n(frame.width / 2)}" cy="${n(frame.height / 2)}" r="${n(videoIrisRadius(frame, layer.iris.progress))}"/></clipPath>`);
|
|
2004
|
+
item = `<g clip-path="url(#i${i})">${item}</g>`;
|
|
2005
|
+
}
|
|
2006
|
+
body.push(item);
|
|
1479
2007
|
});
|
|
1480
2008
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(frame.width)} ${n(frame.height)}" width="${n(frame.width)}" height="${n(frame.height)}">${defs.length ? `<defs>${defs.join("")}</defs>` : ""}<rect width="100%" height="100%" fill="${esc(frame.background)}"/>${body.join("")}</svg>`;
|
|
1481
2009
|
}
|
|
@@ -1499,7 +2027,7 @@ function videoContactSheetSvg(project, times, options = {}) {
|
|
|
1499
2027
|
});
|
|
1500
2028
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(W)} ${n(H)}" width="${n(W)}" height="${n(H)}"><rect width="100%" height="100%" fill="#f8fafc"/>${cells.join("")}</svg>`;
|
|
1501
2029
|
}
|
|
1502
|
-
function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
|
|
2030
|
+
function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
|
|
1503
2031
|
ctx.save();
|
|
1504
2032
|
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
1505
2033
|
ctx.globalAlpha = 1;
|
|
@@ -1509,6 +2037,18 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
|
|
|
1509
2037
|
const { rect } = layer;
|
|
1510
2038
|
ctx.save();
|
|
1511
2039
|
ctx.globalAlpha = layer.opacity;
|
|
2040
|
+
if (layer.iris) {
|
|
2041
|
+
ctx.beginPath();
|
|
2042
|
+
ctx.arc(frame.width / 2, frame.height / 2, videoIrisRadius(frame, layer.iris.progress), 0, 2 * Math.PI);
|
|
2043
|
+
ctx.clip();
|
|
2044
|
+
}
|
|
2045
|
+
if (layer.blend && layer.blend !== "normal") ctx.globalCompositeOperation = layer.blend === "add" ? "lighter" : layer.blend;
|
|
2046
|
+
if (layer.grade) {
|
|
2047
|
+
const defs = videoGradeSvgDefs(layer.grade);
|
|
2048
|
+
if (options.defineSvgFilter) for (const d of defs) options.defineSvgFilter(d.id, d.markup);
|
|
2049
|
+
const css = videoGradeCssFilter(layer.grade);
|
|
2050
|
+
ctx.filter = options.defineSvgFilter ? css : css.replace(/\s*url\([^)]*\)/g, "").trim() || "none";
|
|
2051
|
+
}
|
|
1512
2052
|
if (layer.rotation) {
|
|
1513
2053
|
ctx.translate(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
|
1514
2054
|
ctx.rotate(layer.rotation * Math.PI / 180);
|
|
@@ -1557,20 +2097,22 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
|
|
|
1557
2097
|
}
|
|
1558
2098
|
|
|
1559
2099
|
// src/video-editor/export.ts
|
|
1560
|
-
var reelName = (asset) => (_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess',
|
|
2100
|
+
var reelName = (asset) => (_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _39 => _39.name]), () => ( _optionalChain([asset, 'optionalAccess', _40 => _40.id]))), () => ( "AX"))).replace(/\.[a-z0-9]+$/i, "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 8) || "AX";
|
|
1561
2101
|
function toVideoEdl(project, options = {}) {
|
|
1562
2102
|
const track = options.track !== void 0 ? project.tracks.find((t) => t.id === options.track) : [...project.tracks].reverse().find((t) => t.kind === "video");
|
|
1563
2103
|
if (!track) throw new Error(options.track !== void 0 ? `There is no track "${options.track}".` : "The project has no video track.");
|
|
1564
2104
|
const fps = project.fps;
|
|
1565
|
-
const
|
|
1566
|
-
const
|
|
2105
|
+
const dropFrame = (_nullishCoalesce(options.dropFrame, () => ( true))) && isVideoDropFrameRate(fps);
|
|
2106
|
+
const tc = (ms) => formatVideoTimecode(ms, fps, dropFrame);
|
|
2107
|
+
const rec0 = options.recordStart !== void 0 ? videoTimecodeFrames(options.recordStart, fps) : videoHourFrames(fps, dropFrame);
|
|
2108
|
+
const rtc = (ms) => formatVideoTimecodeFrames(rec0 + videoTimecodeFrames(ms, fps), fps, dropFrame);
|
|
1567
2109
|
const assets = videoAssetMap(project);
|
|
1568
2110
|
const channel = track.kind === "video" ? "V" : "A";
|
|
1569
|
-
const lines = [`TITLE: ${(_nullishCoalesce(_nullishCoalesce(options.title, () => ( project.name)), () => ( "Untitled"))).toUpperCase()}`,
|
|
2111
|
+
const lines = [`TITLE: ${(_nullishCoalesce(_nullishCoalesce(options.title, () => ( project.name)), () => ( "Untitled"))).toUpperCase()}`, `FCM: ${dropFrame ? "DROP FRAME" : "NON-DROP FRAME"}`, ""];
|
|
1570
2112
|
let event = 0;
|
|
1571
2113
|
const num2 = () => String(++event).padStart(3, "0");
|
|
1572
2114
|
const sourceAt = (clip, time) => clip.type === "media" ? (_nullishCoalesce(clip.in, () => ( 0))) + (time - clip.start) * videoClipSpeed(clip) : time - clip.start;
|
|
1573
|
-
const row = (n2, reel, kind, srcIn, srcOut, recIn, recOut) => `${n2} ${reel.padEnd(8)} ${channel.padEnd(5)} ${kind.padEnd(8)} ${tc(srcIn)} ${tc(srcOut)} ${
|
|
2115
|
+
const row = (n2, reel, kind, srcIn, srcOut, recIn, recOut) => `${n2} ${reel.padEnd(8)} ${channel.padEnd(5)} ${kind.padEnd(8)} ${tc(srcIn)} ${tc(srcOut)} ${rtc(recIn)} ${rtc(recOut)}`;
|
|
1574
2116
|
for (const clip of sortedVideoClips(track)) {
|
|
1575
2117
|
const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
|
|
1576
2118
|
const reel = clip.type === "media" ? reelName(asset) : "BL";
|
|
@@ -1587,17 +2129,58 @@ function toVideoEdl(project, options = {}) {
|
|
|
1587
2129
|
const outReel = before.type === "media" ? reelName(assets.get(before.asset)) : "BL";
|
|
1588
2130
|
const at = sourceAt(before, recIn);
|
|
1589
2131
|
lines.push(row(n2, outReel, "C", at, at, recIn, recIn));
|
|
1590
|
-
lines.push(row(n2, reel, `${t.type === "
|
|
2132
|
+
lines.push(row(n2, reel, `${t.type === "crossfade" || t.type === "dip" ? "D" : "W001"} ${frames}`, sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
|
|
1591
2133
|
} else lines.push(row(n2, reel, "C", sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
|
|
1592
2134
|
if (clip.type === "media" && videoClipSpeed(clip) !== 1) lines.push(`M2 ${reel.padEnd(8)} ${(videoClipSpeed(clip) * fps).toFixed(1).padStart(6)} ${tc(sourceAt(clip, recIn))}`);
|
|
1593
|
-
if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess',
|
|
2135
|
+
if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _41 => _41.name]), () => ( _optionalChain([asset, 'optionalAccess', _42 => _42.src]))), () => ( clip.asset))}`);
|
|
1594
2136
|
if (clip.type === "text") lines.push(`* TITLE: ${clip.text.replace(/\n/g, " ")}`);
|
|
1595
2137
|
if (clip.type === "color") lines.push(`* COLOR: ${clip.color}`);
|
|
1596
2138
|
lines.push("");
|
|
1597
2139
|
}
|
|
1598
2140
|
return lines.join("\n");
|
|
1599
2141
|
}
|
|
2142
|
+
function loudnormFilter(target = {}, measure = false) {
|
|
2143
|
+
const I = _nullishCoalesce(target.integrated, () => ( -14));
|
|
2144
|
+
const TP = _nullishCoalesce(target.truePeak, () => ( -1));
|
|
2145
|
+
const LRA = _nullishCoalesce(target.range, () => ( 11));
|
|
2146
|
+
if (!(I >= -70 && I <= -5)) throw new Error(`The loudness target must be between -70 and -5 LUFS (got ${I}); -14 suits streaming, -23 broadcast.`);
|
|
2147
|
+
if (!(TP >= -9 && TP <= 0)) throw new Error(`The true peak ceiling must be between -9 and 0 dBTP (got ${TP}); -1 is usual.`);
|
|
2148
|
+
if (!(LRA >= 1 && LRA <= 50)) throw new Error(`The loudness range must be between 1 and 50 LU (got ${LRA}).`);
|
|
2149
|
+
const parts = [`loudnorm=I=${I}:TP=${TP}:LRA=${LRA}`];
|
|
2150
|
+
const m = target.measured;
|
|
2151
|
+
if (m && !measure) {
|
|
2152
|
+
for (const [k, v] of Object.entries(m)) if (!Number.isFinite(v)) throw new Error(`The measured ${k} is not a number (${v}); measure again, or leave "measured" out for a one-pass normalisation.`);
|
|
2153
|
+
parts.push(`measured_I=${m.integrated}:measured_TP=${m.truePeak}:measured_LRA=${m.range}:measured_thresh=${m.threshold}:offset=${m.offset}:linear=true`);
|
|
2154
|
+
}
|
|
2155
|
+
if (measure) parts.push("print_format=json");
|
|
2156
|
+
return `${parts.join(":")},aresample=48000`;
|
|
2157
|
+
}
|
|
2158
|
+
function parseVideoLoudnessMeasurement(output) {
|
|
2159
|
+
const blocks = output.match(/\{[^{}]*"input_i"[^{}]*\}/g);
|
|
2160
|
+
if (!blocks) throw new Error("No loudness measurement found: pass the stderr of the command from toVideoLoudnessMeasure.");
|
|
2161
|
+
const json = JSON.parse(blocks[blocks.length - 1]);
|
|
2162
|
+
const n2 = (k) => Number(json[k]);
|
|
2163
|
+
const m = { integrated: n2("input_i"), truePeak: n2("input_tp"), range: n2("input_lra"), threshold: n2("input_thresh"), offset: n2("target_offset") };
|
|
2164
|
+
if (!Number.isFinite(m.integrated)) throw new Error("The mix is silent, so its loudness cannot be normalised.");
|
|
2165
|
+
return m;
|
|
2166
|
+
}
|
|
1600
2167
|
var sec = (ms) => String(Math.round(ms) / 1e3);
|
|
2168
|
+
var FFMPEG_BLEND = {
|
|
2169
|
+
multiply: "multiply",
|
|
2170
|
+
screen: "screen",
|
|
2171
|
+
overlay: "overlay",
|
|
2172
|
+
darken: "darken",
|
|
2173
|
+
lighten: "lighten",
|
|
2174
|
+
"color-dodge": "dodge",
|
|
2175
|
+
"color-burn": "burn",
|
|
2176
|
+
"hard-light": "hardlight",
|
|
2177
|
+
"soft-light": "softlight",
|
|
2178
|
+
difference: "difference",
|
|
2179
|
+
exclusion: "exclusion",
|
|
2180
|
+
add: "addition"
|
|
2181
|
+
};
|
|
2182
|
+
var AFADE_CURVES = { linear: "tri", "equal-power": "qsin", "s-curve": "hsin", exponential: "cub", logarithmic: "ipar" };
|
|
2183
|
+
var afadeCurve = (curve) => curve && curve !== "linear" ? `:curve=${AFADE_CURVES[curve]}` : "";
|
|
1601
2184
|
var even = (v) => Math.max(2, 2 * Math.round(v / 2));
|
|
1602
2185
|
var ffmpegFilterValue = (s) => s.replace(/[\\':%]/g, (c) => `\\${c}`).replace(/[\\'[\],;]/g, (c) => `\\${c}`);
|
|
1603
2186
|
var color = (c) => {
|
|
@@ -1622,6 +2205,12 @@ function atempo(speed) {
|
|
|
1622
2205
|
return out;
|
|
1623
2206
|
}
|
|
1624
2207
|
function toVideoFfmpeg(project, options = {}) {
|
|
2208
|
+
return buildFfmpeg(project, options, false);
|
|
2209
|
+
}
|
|
2210
|
+
function toVideoLoudnessMeasure(project, options = {}) {
|
|
2211
|
+
return buildFfmpeg(project, { ...options, loudness: { ...options.loudness, measured: void 0 } }, true);
|
|
2212
|
+
}
|
|
2213
|
+
function buildFfmpeg(project, options, measure) {
|
|
1625
2214
|
const notes = [];
|
|
1626
2215
|
const assets = videoAssetMap(project);
|
|
1627
2216
|
const D = videoProjectDuration(project);
|
|
@@ -1630,11 +2219,12 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1630
2219
|
const H = even(project.height);
|
|
1631
2220
|
const fps = project.fps;
|
|
1632
2221
|
const inputs = [];
|
|
1633
|
-
const graph = [`color=c=${color(_nullishCoalesce(project.background, () => ( "#000000")))}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];
|
|
2222
|
+
const graph = measure ? [] : [`color=c=${color(_nullishCoalesce(project.background, () => ( "#000000")))}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];
|
|
1634
2223
|
const audio = [];
|
|
1635
2224
|
let cur = "base";
|
|
1636
2225
|
let usesDrawtext = false;
|
|
1637
2226
|
const animated = /* @__PURE__ */ new Set();
|
|
2227
|
+
const blended = /* @__PURE__ */ new Set();
|
|
1638
2228
|
let k = 0;
|
|
1639
2229
|
const step = 2e3 / fps;
|
|
1640
2230
|
const expr = (prop, clipTime, fallback, what) => {
|
|
@@ -1650,7 +2240,8 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1650
2240
|
return inputs.length - 1;
|
|
1651
2241
|
};
|
|
1652
2242
|
for (const track of [...project.tracks].reverse()) {
|
|
1653
|
-
for (const
|
|
2243
|
+
for (const raw of sortedVideoClips(track)) {
|
|
2244
|
+
const clip = videoClipAnimated(raw, project);
|
|
1654
2245
|
const { lead, tail } = videoClipOverhang(track, clip);
|
|
1655
2246
|
const t0 = clip.start - lead;
|
|
1656
2247
|
const span = clip.duration + lead + tail;
|
|
@@ -1661,32 +2252,34 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1661
2252
|
continue;
|
|
1662
2253
|
}
|
|
1663
2254
|
let index;
|
|
1664
|
-
if (asset && asset.kind !== "audio" && track.kind === "video" && !track.hidden) index = input(clip, asset, lead, tail);
|
|
1665
|
-
const audible =
|
|
2255
|
+
if (!measure && asset && asset.kind !== "audio" && track.kind === "video" && !track.hidden) index = input(clip, asset, lead, tail);
|
|
2256
|
+
const audible = videoAssetHasSound(asset);
|
|
1666
2257
|
if (audible && clip.type === "media" && !clip.muted && !track.muted) {
|
|
1667
2258
|
const ai = _nullishCoalesce(index, () => ( input(clip, asset, lead, tail)));
|
|
1668
2259
|
const trackGain = _nullishCoalesce(track.volume, () => ( 1));
|
|
1669
2260
|
const volume = isVideoKeyframes(clip.volume) ? `volume='${trackGain}*(${expr(clip.volume, `(t*1000${lead ? `-${Math.round(lead)}` : ""})`, 1, "volume")})':eval=frame` : `volume=${(_nullishCoalesce(clip.volume, () => ( 1))) * trackGain}`;
|
|
1670
2261
|
const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), volume];
|
|
1671
2262
|
const before2 = clip.transition ? videoClipBefore(track, clip) : null;
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
if (
|
|
1677
|
-
if (clip.
|
|
1678
|
-
if (
|
|
2263
|
+
const next2 = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition);
|
|
2264
|
+
const inCurve = clip.transition ? afadeCurve(videoTransitionAudioCurve(clip.transition)) : "";
|
|
2265
|
+
const outCurve = next2 ? afadeCurve(videoTransitionAudioCurve(next2.transition)) : "";
|
|
2266
|
+
if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}${inCurve}`);
|
|
2267
|
+
if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}${outCurve}`);
|
|
2268
|
+
if (before2 && clip.transition.type === "dip") chain2.push(`afade=t=in:st=0:d=${sec(clip.transition.duration / 2)}${inCurve}`);
|
|
2269
|
+
if (_optionalChain([next2, 'optionalAccess', _43 => _43.transition, 'access', _44 => _44.type]) === "dip") chain2.push(`afade=t=out:st=${sec(span - next2.transition.duration / 2)}:d=${sec(next2.transition.duration / 2)}${outCurve}`);
|
|
2270
|
+
if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}${afadeCurve(clip.fadeInCurve)}`);
|
|
2271
|
+
if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}${afadeCurve(clip.fadeOutCurve)}`);
|
|
1679
2272
|
chain2.push(`adelay=${Math.round(t0)}:all=1`);
|
|
1680
2273
|
graph.push(`[${ai}:a]${chain2.join(",")}[a${audio.length}]`);
|
|
1681
2274
|
audio.push(`[a${audio.length}]`);
|
|
1682
2275
|
}
|
|
1683
|
-
if (track.kind !== "video" || track.hidden) continue;
|
|
2276
|
+
if (measure || track.kind !== "video" || track.hidden) continue;
|
|
1684
2277
|
const enable = `enable='between(t,${sec(t0)},${sec(t0 + span)})'`;
|
|
1685
2278
|
const next = `v${k++}`;
|
|
1686
2279
|
const onTimeline = `(t*1000-${Math.round(clip.start)})`;
|
|
1687
2280
|
const onChain = (v) => `(${v}*1000${lead ? `-${Math.round(lead)}` : ""})`;
|
|
1688
2281
|
const tf = clip.transform;
|
|
1689
|
-
const moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(_optionalChain([tf, 'optionalAccess',
|
|
2282
|
+
const moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _45 => _45[p]])));
|
|
1690
2283
|
if (clip.type === "text" && options.titleImage) {
|
|
1691
2284
|
inputs.push(["-loop", "1", "-framerate", String(fps), "-t", sec(span), "-i", options.titleImage(moving ? { ...clip, transform: void 0 } : clip, { width: project.width, height: project.height })]);
|
|
1692
2285
|
index = inputs.length - 1;
|
|
@@ -1695,7 +2288,7 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1695
2288
|
const op = isVideoKeyframes(clip.opacity) ? `clip(${expr(clip.opacity, onTimeline, 1, "opacity")},0,1)` : String(_nullishCoalesce(clip.opacity, () => ( 1)));
|
|
1696
2289
|
const alpha = [op, clip.fadeIn ? `min(1,(t-${sec(clip.start)})/${sec(clip.fadeIn)})` : "", clip.fadeOut ? `min(1,(${sec(end)}-t)/${sec(clip.fadeOut)})` : ""].filter(Boolean).join("*");
|
|
1697
2290
|
const coord = (axis) => {
|
|
1698
|
-
const v = _optionalChain([tf, 'optionalAccess',
|
|
2291
|
+
const v = _optionalChain([tf, 'optionalAccess', _46 => _46[axis]]);
|
|
1699
2292
|
const base = `(${axis === "x" ? "w-text_w" : "h-text_h"})/2`;
|
|
1700
2293
|
return isVideoKeyframes(v) ? `${axis}='${base}+(${expr(v, onTimeline, 0, "position")})'` : `${axis}=${base}+${_nullishCoalesce(v, () => ( 0))}`;
|
|
1701
2294
|
};
|
|
@@ -1703,7 +2296,7 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1703
2296
|
const opts = [
|
|
1704
2297
|
`text=${ffmpegFilterValue(clip.text)}`,
|
|
1705
2298
|
options.fontFile ? `fontfile=${ffmpegFilterValue(options.fontFile)}` : `font=${ffmpegFilterValue(_nullishCoalesce(clip.fontFamily, () => ( "sans-serif")))}`,
|
|
1706
|
-
isVideoKeyframes(_optionalChain([tf, 'optionalAccess',
|
|
2299
|
+
isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _47 => _47.scale])) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, "scale")})'` : `fontsize=${Math.round(fontSize * (_nullishCoalesce(_optionalChain([tf, 'optionalAccess', _48 => _48.scale]), () => ( 1))))}`,
|
|
1707
2300
|
`fontcolor=${color(_nullishCoalesce(clip.color, () => ( "#ffffff")))}`,
|
|
1708
2301
|
coord("x"),
|
|
1709
2302
|
coord("y"),
|
|
@@ -1711,7 +2304,9 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1711
2304
|
...clip.background ? ["box=1", `boxcolor=${color(clip.background)}`, `boxborderw=${Math.round((_nullishCoalesce(clip.fontSize, () => ( 64))) / 2)}`] : [],
|
|
1712
2305
|
`enable='between(t,${sec(clip.start)},${sec(end)})'`
|
|
1713
2306
|
];
|
|
1714
|
-
if (_optionalChain([tf, 'optionalAccess',
|
|
2307
|
+
if (_optionalChain([tf, 'optionalAccess', _49 => _49.rotation])) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
|
|
2308
|
+
if (videoGradeFfmpegFilters(clip.grade).length) notes.push(`Title "${clip.id}" is drawn without its grade (drawtext draws straight onto the picture); pass titleImage to grade titles.`);
|
|
2309
|
+
if (clip.blend && clip.blend !== "normal") notes.push(`Title "${clip.id}" is drawn without its blend mode (drawtext draws straight onto the picture); pass titleImage to blend titles.`);
|
|
1715
2310
|
graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
|
|
1716
2311
|
cur = next;
|
|
1717
2312
|
continue;
|
|
@@ -1727,10 +2322,8 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1727
2322
|
source = `[${index}:v]`;
|
|
1728
2323
|
chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
|
|
1729
2324
|
}
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
chain.push(`scale=w='max(2,trunc(${even(rect.width)}*(${s})/2)*2)':h='max(2,trunc(${even(rect.height)}*(${s})/2)*2)':eval=frame`);
|
|
1733
|
-
}
|
|
2325
|
+
const grade = videoGradeFfmpegFilters(clip.grade);
|
|
2326
|
+
if (grade.length) chain.push("format=rgba", ...grade);
|
|
1734
2327
|
chain.push("format=yuva420p");
|
|
1735
2328
|
if (isVideoKeyframes(clip.opacity)) chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='alpha(X,Y)*clip(${expr(clip.opacity, onChain("T"), 1, "opacity")},0,1)'`);
|
|
1736
2329
|
else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
|
|
@@ -1744,18 +2337,50 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1744
2337
|
const test = dir === "right" ? `lt(X,W*${p})` : dir === "left" ? `gt(X,W*(1-${p}))` : dir === "down" ? `lt(Y,H*${p})` : `gt(Y,H*(1-${p}))`;
|
|
1745
2338
|
chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
|
|
1746
2339
|
}
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
2340
|
+
if (lead && clip.transition.type === "iris") {
|
|
2341
|
+
const r = videoIrisRadius({ width: W, height: H }, 1);
|
|
2342
|
+
chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(lte(hypot(X+${Math.round(rect.x)}-${W / 2},Y+${Math.round(rect.y)}-${H / 2}),${Math.round(r)}*min(1,T/${sec(2 * lead)})),alpha(X,Y),0)'`);
|
|
2343
|
+
}
|
|
2344
|
+
const rotation = _optionalChain([placed, 'optionalAccess', _50 => _50.rotation]);
|
|
2345
|
+
if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=black@0:ow='hypot(iw,ih)':oh=ow`);
|
|
2346
|
+
else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=black@0:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
|
|
2347
|
+
if (isVideoKeyframes(_optionalChain([placed, 'optionalAccess', _51 => _51.scale]))) {
|
|
2348
|
+
const s = expr(placed.scale, onChain("t"), 1, "scale");
|
|
2349
|
+
chain.push(`scale=w='max(2,trunc(iw*(${s})/2)*2)':h='max(2,trunc(ih*(${s})/2)*2)':eval=frame`);
|
|
2350
|
+
}
|
|
1750
2351
|
chain.push(`setpts=PTS+${sec(t0)}/TB`);
|
|
1751
2352
|
const cx = rect.x + rect.width / 2;
|
|
1752
2353
|
const cy = rect.y + rect.height / 2;
|
|
2354
|
+
const shifts = { x: [], y: [] };
|
|
2355
|
+
const shiftBy = (direction, from, d, entering) => {
|
|
2356
|
+
const dir = _nullishCoalesce(direction, () => ( "right"));
|
|
2357
|
+
const axis = dir === "left" || dir === "right" ? "x" : "y";
|
|
2358
|
+
const size2 = axis === "x" ? W : H;
|
|
2359
|
+
const sign = dir === "left" || dir === "up" ? -1 : 1;
|
|
2360
|
+
const ease = `(1-cos(PI*clip((t-${sec(from)})/${sec(d)},0,1)))/2`;
|
|
2361
|
+
shifts[axis].push(`${sign * size2}*(${ease}${entering ? "-1" : ""})`);
|
|
2362
|
+
};
|
|
2363
|
+
const into = lead ? clip.transition.type : void 0;
|
|
2364
|
+
if (into === "push" || into === "slide") shiftBy(clip.transition.direction, clip.start - lead, 2 * lead, true);
|
|
2365
|
+
const after = tail ? sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition) : void 0;
|
|
2366
|
+
if (_optionalChain([after, 'optionalAccess', _52 => _52.transition, 'access', _53 => _53.type]) === "push") shiftBy(after.transition.direction, end - tail, 2 * tail, false);
|
|
1753
2367
|
const at = (axis, c) => {
|
|
1754
|
-
const v = _optionalChain([placed, 'optionalAccess',
|
|
1755
|
-
|
|
2368
|
+
const v = _optionalChain([placed, 'optionalAccess', _54 => _54[axis]]);
|
|
2369
|
+
const extra = shifts[axis].map((s) => `+${s}`).join("");
|
|
2370
|
+
return isVideoKeyframes(v) ? `${axis}='${Math.round(axis === "x" ? project.width / 2 : project.height / 2)}+(${expr(v, onTimeline, 0, "position")})-${axis === "x" ? "w" : "h"}/2${extra}'` : `${axis}='${Math.round(c)}-${axis === "x" ? "w" : "h"}/2${extra}'`;
|
|
1756
2371
|
};
|
|
1757
2372
|
graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
|
|
1758
|
-
|
|
2373
|
+
if (clip.blend && clip.blend !== "normal") {
|
|
2374
|
+
graph.push(`color=c=black@0:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[t${next}]`);
|
|
2375
|
+
graph.push(`[t${next}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable},format=gbrap,split[f${next}][g${next}]`);
|
|
2376
|
+
graph.push(`[${cur}]format=gbrap,split[c${next}][d${next}]`);
|
|
2377
|
+
const layerFirst = clip.blend === "color-dodge" || clip.blend === "color-burn";
|
|
2378
|
+
graph.push(`${layerFirst ? `[f${next}][c${next}]` : `[c${next}][f${next}]`}blend=all_mode=${FFMPEG_BLEND[clip.blend]}[b${next}]`);
|
|
2379
|
+
graph.push(`[g${next}]alphaextract[m${next}]`);
|
|
2380
|
+
graph.push(`[b${next}][m${next}]alphamerge[x${next}]`);
|
|
2381
|
+
graph.push(`[d${next}][x${next}]overlay=format=gbrp,format=yuva420p[${next}]`);
|
|
2382
|
+
blended.add(clip.blend);
|
|
2383
|
+
} else graph.push(`[${cur}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable}[${next}]`);
|
|
1759
2384
|
cur = next;
|
|
1760
2385
|
const before = clip.transition ? videoClipBefore(track, clip) : null;
|
|
1761
2386
|
if (before && clip.transition.type === "dip") {
|
|
@@ -1767,14 +2392,24 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1767
2392
|
}
|
|
1768
2393
|
}
|
|
1769
2394
|
}
|
|
1770
|
-
graph.push(`[${cur}]format=yuv420p[vout]`);
|
|
1771
|
-
if (audio.length)
|
|
1772
|
-
|
|
2395
|
+
if (!measure) graph.push(`[${cur}]format=yuv420p[vout]`);
|
|
2396
|
+
if (measure && !audio.length) throw new Error("Nothing on the timeline makes a sound, so there is no loudness to measure.");
|
|
2397
|
+
const loudness = options.loudness || measure ? `,${loudnormFilter(options.loudness, measure)}` : "";
|
|
2398
|
+
if (audio.length) graph.push(`${audio.join("")}amix=inputs=${audio.length}:normalize=0:duration=longest,apad,atrim=0:${sec(D)}${loudness}[aout]`);
|
|
2399
|
+
if (options.loudness && !audio.length) notes.push("Nothing makes a sound, so there was no loudness to normalise.");
|
|
2400
|
+
else if (options.loudness && !options.loudness.measured)
|
|
2401
|
+
notes.push("Loudness is normalised in one pass, which adjusts the level as it goes; for a fixed gain that keeps the dynamics, run toVideoLoudnessMeasure first and pass its result as loudness.measured.");
|
|
2402
|
+
if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && _optionalChain([assets, 'access', _55 => _55.get, 'call', _56 => _56(c.asset), 'optionalAccess', _57 => _57.kind]) === "video" && _optionalChain([assets, 'access', _58 => _58.get, 'call', _59 => _59(c.asset), 'optionalAccess', _60 => _60.hasAudio]) === void 0)))
|
|
1773
2403
|
notes.push('Video assets are assumed to have sound; set "hasAudio": false on any that do not, or ffmpeg will stop at a missing audio stream.');
|
|
1774
2404
|
if (animated.size)
|
|
1775
2405
|
notes.push(`Keyframed ${[...animated].sort().join(", ")} ${animated.size > 1 ? "are" : "is"} rendered as straight lines between keys, with eased segments sampled every two frames${animated.has("scale") ? "; animated scale needs ffmpeg 5 or later (scale with eval=frame)" : ""}.`);
|
|
2406
|
+
if (blended.has("soft-light")) notes.push("ffmpeg's soft-light blend uses a slightly different formula from the browser's, so the render can differ a little from the preview.");
|
|
1776
2407
|
if (usesDrawtext) notes.push("Titles use drawtext, which needs an ffmpeg built with libfreetype (Homebrew's is not: check `ffmpeg -filters | grep drawtext`); pass titleImage to render them as images instead.");
|
|
1777
2408
|
const filterComplex = graph.join(";");
|
|
2409
|
+
if (measure) {
|
|
2410
|
+
const args2 = ["-hide_banner", "-nostats", ...inputs.flat(), "-filter_complex", filterComplex, "-map", "[aout]", "-f", "null", "-"];
|
|
2411
|
+
return { args: args2, command: ["ffmpeg", ...args2].map(shellQuote).join(" "), filterComplex, duration: D, notes };
|
|
2412
|
+
}
|
|
1778
2413
|
const args = [
|
|
1779
2414
|
"-y",
|
|
1780
2415
|
...inputs.flat(),
|
|
@@ -1798,12 +2433,12 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1798
2433
|
}
|
|
1799
2434
|
|
|
1800
2435
|
// src/video-editor/preview.ts
|
|
1801
|
-
var VideoPreviewEngine = (
|
|
1802
|
-
constructor(source, canvas, options = {}) {;
|
|
2436
|
+
var VideoPreviewEngine = (_class3 = class {
|
|
2437
|
+
constructor(source, canvas, options = {}) {;_class3.prototype.__init6.call(this);_class3.prototype.__init7.call(this);_class3.prototype.__init8.call(this);_class3.prototype.__init9.call(this);_class3.prototype.__init10.call(this);_class3.prototype.__init11.call(this);_class3.prototype.__init12.call(this);_class3.prototype.__init13.call(this);
|
|
1803
2438
|
this.source = source;
|
|
1804
2439
|
this.options = options;
|
|
1805
2440
|
this.canvas = canvas;
|
|
1806
|
-
this.ctx = options.context !== void 0 ? options.context : _nullishCoalesce(_optionalChain([canvas, 'access',
|
|
2441
|
+
this.ctx = options.context !== void 0 ? options.context : _nullishCoalesce(_optionalChain([canvas, 'access', _61 => _61.getContext, 'optionalCall', _62 => _62("2d")]), () => ( null));
|
|
1807
2442
|
this.unsubscribe = source.subscribe(() => this.update());
|
|
1808
2443
|
this.update();
|
|
1809
2444
|
}
|
|
@@ -1811,11 +2446,13 @@ var VideoPreviewEngine = (_class2 = class {
|
|
|
1811
2446
|
|
|
1812
2447
|
|
|
1813
2448
|
|
|
1814
|
-
|
|
1815
|
-
|
|
2449
|
+
__init6() {this.media = /* @__PURE__ */ new Map()}
|
|
2450
|
+
__init7() {this.images = /* @__PURE__ */ new Map()}
|
|
1816
2451
|
|
|
1817
|
-
|
|
1818
|
-
|
|
2452
|
+
__init8() {this.scheduled = false}
|
|
2453
|
+
__init9() {this.destroyed = false}
|
|
2454
|
+
__init10() {this.filterHost = null}
|
|
2455
|
+
__init11() {this.filters = /* @__PURE__ */ new Set()}
|
|
1819
2456
|
createMedia(kind) {
|
|
1820
2457
|
if (this.options.createMedia) return this.options.createMedia(kind);
|
|
1821
2458
|
const el = document.createElement(kind);
|
|
@@ -1851,8 +2488,8 @@ var VideoPreviewEngine = (_class2 = class {
|
|
|
1851
2488
|
entry.media.pause();
|
|
1852
2489
|
entry.media.removeEventListener("seeked", entry.onReady);
|
|
1853
2490
|
entry.media.removeEventListener("loadeddata", entry.onReady);
|
|
1854
|
-
_optionalChain([entry, 'access',
|
|
1855
|
-
_optionalChain([entry, 'access',
|
|
2491
|
+
_optionalChain([entry, 'access', _63 => _63.media, 'access', _64 => _64.removeAttribute, 'optionalCall', _65 => _65("src")]);
|
|
2492
|
+
_optionalChain([entry, 'access', _66 => _66.media, 'access', _67 => _67.load, 'optionalCall', _68 => _68()]);
|
|
1856
2493
|
this.media.delete(entry.clip);
|
|
1857
2494
|
}
|
|
1858
2495
|
/** Brings the media elements in step with the editor and draws the frame. */
|
|
@@ -1885,7 +2522,7 @@ var VideoPreviewEngine = (_class2 = class {
|
|
|
1885
2522
|
for (const layer of frame.layers) {
|
|
1886
2523
|
if (layer.type !== "media" || layer.assetKind !== "video" || !layer.src) continue;
|
|
1887
2524
|
const sound = sounding.get(layer.clip);
|
|
1888
|
-
sync(layer.clip, layer.src, "video", _nullishCoalesce(layer.sourceTime, () => ( 0)), _nullishCoalesce(_optionalChain([sound, 'optionalAccess',
|
|
2525
|
+
sync(layer.clip, layer.src, "video", _nullishCoalesce(layer.sourceTime, () => ( 0)), _nullishCoalesce(_optionalChain([sound, 'optionalAccess', _69 => _69.speed]), () => ( 1)), sound ? sound.gain : null);
|
|
1889
2526
|
sounding.delete(layer.clip);
|
|
1890
2527
|
}
|
|
1891
2528
|
for (const a of sounding.values()) sync(a.clip, a.src, "audio", a.sourceTime, a.speed, a.gain);
|
|
@@ -1900,7 +2537,7 @@ var VideoPreviewEngine = (_class2 = class {
|
|
|
1900
2537
|
if (!this.destroyed) this.draw(videoFrameAt(this.source.getState().project, this.source.getState().time));
|
|
1901
2538
|
});
|
|
1902
2539
|
}
|
|
1903
|
-
|
|
2540
|
+
__init12() {this.sourceFor = (layer) => {
|
|
1904
2541
|
if (layer.assetKind === "image" && layer.src) {
|
|
1905
2542
|
const img = this.image(layer.src);
|
|
1906
2543
|
return img.complete ? img : null;
|
|
@@ -1917,35 +2554,57 @@ var VideoPreviewEngine = (_class2 = class {
|
|
|
1917
2554
|
const h = Math.max(1, Math.round(frame.height * resolution));
|
|
1918
2555
|
if (this.canvas.width !== w) this.canvas.width = w;
|
|
1919
2556
|
if (this.canvas.height !== h) this.canvas.height = h;
|
|
1920
|
-
drawVideoFrame(this.ctx, frame, this.sourceFor, resolution);
|
|
1921
|
-
_optionalChain([this, 'access',
|
|
1922
|
-
}
|
|
2557
|
+
drawVideoFrame(this.ctx, frame, this.sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
|
|
2558
|
+
_optionalChain([this, 'access', _70 => _70.options, 'access', _71 => _71.onFrame, 'optionalCall', _72 => _72(frame)]);
|
|
2559
|
+
}
|
|
2560
|
+
__init13() {this.defineSvgFilter = (id, markup) => {
|
|
2561
|
+
if (this.options.defineSvgFilter === null) return;
|
|
2562
|
+
if (this.options.defineSvgFilter) return this.options.defineSvgFilter(id, markup);
|
|
2563
|
+
if (this.filters.has(id) || typeof document === "undefined") return;
|
|
2564
|
+
if (!this.filterHost) {
|
|
2565
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
2566
|
+
svg.setAttribute("width", "0");
|
|
2567
|
+
svg.setAttribute("height", "0");
|
|
2568
|
+
svg.setAttribute("aria-hidden", "true");
|
|
2569
|
+
svg.setAttribute("style", "position:absolute;width:0;height:0;overflow:hidden");
|
|
2570
|
+
document.body.appendChild(svg);
|
|
2571
|
+
this.filterHost = svg;
|
|
2572
|
+
}
|
|
2573
|
+
this.filterHost.insertAdjacentHTML("beforeend", markup);
|
|
2574
|
+
this.filters.add(id);
|
|
2575
|
+
}}
|
|
1923
2576
|
/** Stops following the editor and releases every media element. */
|
|
1924
2577
|
destroy() {
|
|
1925
2578
|
this.destroyed = true;
|
|
2579
|
+
_optionalChain([this, 'access', _73 => _73.filterHost, 'optionalAccess', _74 => _74.remove, 'call', _75 => _75()]);
|
|
2580
|
+
this.filterHost = null;
|
|
2581
|
+
this.filters.clear();
|
|
1926
2582
|
this.unsubscribe();
|
|
1927
2583
|
for (const entry of [...this.media.values()]) this.release(entry);
|
|
1928
2584
|
this.images.clear();
|
|
1929
2585
|
}
|
|
1930
|
-
},
|
|
2586
|
+
}, _class3);
|
|
1931
2587
|
|
|
1932
2588
|
// src/video-editor/controller.ts
|
|
1933
|
-
var VideoEditorController = (
|
|
2589
|
+
var VideoEditorController = (_class4 = class {
|
|
2590
|
+
|
|
1934
2591
|
|
|
2592
|
+
/** Sound levels per asset, loaded on demand for waveforms and agents. */
|
|
1935
2593
|
|
|
1936
2594
|
|
|
1937
2595
|
|
|
1938
|
-
|
|
2596
|
+
__init14() {this.selection = []}
|
|
1939
2597
|
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
2598
|
+
__init15() {this.error = null}
|
|
2599
|
+
__init16() {this.revision = 0}
|
|
2600
|
+
__init17() {this.applying = false}
|
|
1943
2601
|
|
|
1944
|
-
|
|
1945
|
-
|
|
2602
|
+
__init18() {this.listeners = /* @__PURE__ */ new Set()}
|
|
2603
|
+
__init19() {this.unsubscribers = []}
|
|
1946
2604
|
|
|
1947
|
-
constructor(options = {}) {;
|
|
2605
|
+
constructor(options = {}) {;_class4.prototype.__init14.call(this);_class4.prototype.__init15.call(this);_class4.prototype.__init16.call(this);_class4.prototype.__init17.call(this);_class4.prototype.__init18.call(this);_class4.prototype.__init19.call(this);_class4.prototype.__init20.call(this);_class4.prototype.__init21.call(this);
|
|
1948
2606
|
this.history = _nullishCoalesce(options.history, () => ( new (0, _chunkQKLRAA4Tcjs.HistoryManager)()));
|
|
2607
|
+
this.audioPeaks = options.audioPeaks instanceof VideoAudioPeaksCache ? options.audioPeaks : new VideoAudioPeaksCache(options.audioPeaks);
|
|
1949
2608
|
this.project = options.project ? parseVideoProject(options.project) : createVideoProject();
|
|
1950
2609
|
this.issues = checkVideoProject(this.project);
|
|
1951
2610
|
this.readOnly = _nullishCoalesce(options.readOnly, () => ( false));
|
|
@@ -1968,8 +2627,8 @@ var VideoEditorController = (_class3 = class {
|
|
|
1968
2627
|
this.player.pause();
|
|
1969
2628
|
this.unsubscribers.splice(0).forEach((u) => u());
|
|
1970
2629
|
}
|
|
1971
|
-
|
|
1972
|
-
|
|
2630
|
+
__init20() {this.getState = () => this.state}
|
|
2631
|
+
__init21() {this.subscribe = (listener) => {
|
|
1973
2632
|
this.connect();
|
|
1974
2633
|
this.listeners.add(listener);
|
|
1975
2634
|
return () => {
|
|
@@ -2025,7 +2684,7 @@ var VideoEditorController = (_class3 = class {
|
|
|
2025
2684
|
this.applying = false;
|
|
2026
2685
|
}
|
|
2027
2686
|
this.notify();
|
|
2028
|
-
_optionalChain([this, 'access',
|
|
2687
|
+
_optionalChain([this, 'access', _76 => _76.onChange, 'optionalCall', _77 => _77(project)]);
|
|
2029
2688
|
}
|
|
2030
2689
|
commit(label, next) {
|
|
2031
2690
|
const before = this.project;
|
|
@@ -2089,7 +2748,7 @@ var VideoEditorController = (_class3 = class {
|
|
|
2089
2748
|
}
|
|
2090
2749
|
/** Selects clips (unknown ids are dropped). `add` toggles them into the current selection. */
|
|
2091
2750
|
select(clips, options = {}) {
|
|
2092
|
-
const known = _nullishCoalesce(_optionalChain([clips, 'optionalAccess',
|
|
2751
|
+
const known = _nullishCoalesce(_optionalChain([clips, 'optionalAccess', _78 => _78.filter, 'call', _79 => _79((id) => findVideoClip(this.project, id))]), () => ( []));
|
|
2093
2752
|
let next;
|
|
2094
2753
|
if (options.add) {
|
|
2095
2754
|
next = [...this.selection];
|
|
@@ -2162,13 +2821,18 @@ var VideoEditorController = (_class3 = class {
|
|
|
2162
2821
|
toFfmpeg(options = {}) {
|
|
2163
2822
|
return toVideoFfmpeg(this.project, options);
|
|
2164
2823
|
}
|
|
2824
|
+
/** The ffmpeg command that measures the mix's loudness (see `toVideoLoudnessMeasure`). */
|
|
2825
|
+
toLoudnessMeasure(options = {}) {
|
|
2826
|
+
return toVideoLoudnessMeasure(this.project, options);
|
|
2827
|
+
}
|
|
2165
2828
|
/** One track as a CMX 3600 EDL. */
|
|
2166
2829
|
toEdl(options = {}) {
|
|
2167
2830
|
return toVideoEdl(this.project, options);
|
|
2168
2831
|
}
|
|
2169
|
-
},
|
|
2832
|
+
}, _class4);
|
|
2170
2833
|
|
|
2171
2834
|
// src/video-editor/tools.ts
|
|
2835
|
+
var round = (ms) => Math.round(ms * 10) / 10;
|
|
2172
2836
|
var READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
2173
2837
|
var WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
2174
2838
|
var WRITE_IDEMPOTENT = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
@@ -2208,19 +2872,37 @@ var TRANSFORM = object(
|
|
|
2208
2872
|
);
|
|
2209
2873
|
var TRANSITION = object(
|
|
2210
2874
|
{
|
|
2211
|
-
type: { type: "string", enum: [...VIDEO_TRANSITION_TYPES], description: "crossfade (this clip fades up over the one before), dip (down into a colour and up out of it)
|
|
2212
|
-
duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms, centred on the cut.
|
|
2875
|
+
type: { type: "string", enum: [...VIDEO_TRANSITION_TYPES], description: "crossfade (this clip fades up over the one before), dip (down into a colour and up out of it), wipe (an edge travels across revealing this clip), push (this clip slides in and pushes the one before out), slide (this clip slides in over the one before) or iris (a circle opens from the centre)." },
|
|
2876
|
+
duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms, centred on the cut. Every type but dip needs half of it in media past the cut on both sides (handles); a dip needs none." },
|
|
2213
2877
|
color: COLOR("For dip: the colour passed through (default black)"),
|
|
2214
|
-
direction: { type: "string", enum: [...VIDEO_WIPE_DIRECTIONS], description: "For wipe: the way the edge travels (default right)." }
|
|
2878
|
+
direction: { type: "string", enum: [...VIDEO_WIPE_DIRECTIONS], description: "For wipe, push and slide: the way the edge or picture travels (default right, coming in from the left)." },
|
|
2879
|
+
audioCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fades through it: default equal-power for every type but dip (steady loudness while the two sounds overlap), linear for dip; or s-curve, exponential, logarithmic." }
|
|
2215
2880
|
},
|
|
2216
2881
|
["type", "duration"],
|
|
2217
2882
|
"A transition from the clip that ends where this one starts on the same track."
|
|
2218
2883
|
);
|
|
2884
|
+
var GRADE = object(
|
|
2885
|
+
Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, { type: "number", minimum: c.min, maximum: c.max, description: c.description }])),
|
|
2886
|
+
[],
|
|
2887
|
+
"Colour correction of the picture, applied in order: exposure, contrast, saturation, hue, white balance (temperature, tint). Leave a setting out for no change; the object replaces the clip's whole grade."
|
|
2888
|
+
);
|
|
2889
|
+
var ANIMATION = (when) => object(
|
|
2890
|
+
{
|
|
2891
|
+
effect: { type: "string", enum: [...VIDEO_CLIP_ANIMATION_EFFECTS], description: "fade; rise (up into place) or drop (down into place); slide-left / slide-right (moving that way); zoom (grows into place); pop (grows past its size and settles). Each also fades; an exit plays the movement backwards." },
|
|
2892
|
+
duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms (default 500)." }
|
|
2893
|
+
},
|
|
2894
|
+
["effect"],
|
|
2895
|
+
when
|
|
2896
|
+
);
|
|
2219
2897
|
var CLIP_COMMON = {
|
|
2220
2898
|
name: { type: "string", description: "A label for the clip." },
|
|
2221
2899
|
opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1). A number replaces any keyframes; set_video_keyframes animates it." },
|
|
2222
2900
|
transform: TRANSFORM,
|
|
2223
2901
|
fit: { type: "string", enum: [...VIDEO_FITS], description: "How the picture fits the frame before the transform, as CSS object-fit (default contain for media, fill for colours)." },
|
|
2902
|
+
grade: GRADE,
|
|
2903
|
+
animateIn: ANIMATION("How the picture enters, from the clip's start (on top of any fade in). Titles usually rise, pop or fade in."),
|
|
2904
|
+
animateOut: ANIMATION("How the picture leaves, ending at the clip's end."),
|
|
2905
|
+
blend: { type: "string", enum: [...VIDEO_BLEND_MODES], description: "How the picture mixes with what is beneath it, as CSS mix-blend-mode (default normal): multiply darkens (shadows, textures), screen and add lighten (light leaks, flares, glows), overlay and soft-light add contrast, difference and exclusion invert." },
|
|
2224
2906
|
fadeIn: MS("Fade up from nothing (picture and sound) over this long"),
|
|
2225
2907
|
fadeOut: MS("Fade down to nothing over this long")
|
|
2226
2908
|
};
|
|
@@ -2228,7 +2910,9 @@ var MEDIA_FIELDS = {
|
|
|
2228
2910
|
in: MS("Where in the source it starts (default 0)"),
|
|
2229
2911
|
speed: { type: "number", minimum: 0.05, maximum: 20, description: "Playback speed (default 1). Changing it keeps the stretch of source, so the clip gets shorter or longer." },
|
|
2230
2912
|
volume: { type: "number", minimum: 0, maximum: 4, description: "Gain (1 = unchanged, default 1). A number replaces any keyframes; set_video_keyframes animates it." },
|
|
2231
|
-
muted: { type: "boolean", description: "Silence this clip (default false)." }
|
|
2913
|
+
muted: { type: "boolean", description: "Silence this clip (default false)." },
|
|
2914
|
+
fadeInCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fade in (default linear; the picture always fades linearly): linear, equal-power (steady loudness, best for crossfades), s-curve (gentle at both ends), exponential (slow start, natural from silence) or logarithmic (fast start)." },
|
|
2915
|
+
fadeOutCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fade out (default linear), the same choices as fadeInCurve; exponential sounds natural fading to silence." }
|
|
2232
2916
|
};
|
|
2233
2917
|
var TEXT_FIELDS = {
|
|
2234
2918
|
text: { type: "string", minLength: 1, description: `The title's text ("\\n" breaks lines).` },
|
|
@@ -2354,14 +3038,14 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2354
3038
|
return null;
|
|
2355
3039
|
};
|
|
2356
3040
|
const placed = (o) => ({
|
|
2357
|
-
..._optionalChain([o, 'access',
|
|
2358
|
-
..._optionalChain([o, 'access',
|
|
2359
|
-
..._optionalChain([o, 'access',
|
|
3041
|
+
..._optionalChain([o, 'access', _80 => _80.created, 'optionalAccess', _81 => _81.length]) ? { created: o.created } : {},
|
|
3042
|
+
..._optionalChain([o, 'access', _82 => _82.trimmed, 'optionalAccess', _83 => _83.length]) ? { trimmed: o.trimmed } : {},
|
|
3043
|
+
..._optionalChain([o, 'access', _84 => _84.removed, 'optionalAccess', _85 => _85.length]) ? { removed: o.removed } : {},
|
|
2360
3044
|
...o.rippled ? { rippled: o.rippled } : {}
|
|
2361
3045
|
});
|
|
2362
3046
|
const keysOut = (p, clip, property) => {
|
|
2363
3047
|
const c = clipOut(p, clip);
|
|
2364
|
-
const value = VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([c, 'optionalAccess',
|
|
3048
|
+
const value = VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([c, 'optionalAccess', _86 => _86.transform, 'optionalAccess', _87 => _87[property]]) : _optionalChain([c, 'optionalAccess', _88 => _88[property]]);
|
|
2365
3049
|
return typeof value === "object" ? { keys: value.keys } : { keys: [], value: _nullishCoalesce(value, () => ( null)) };
|
|
2366
3050
|
};
|
|
2367
3051
|
const getProject = {
|
|
@@ -2379,7 +3063,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2379
3063
|
const getFrame = {
|
|
2380
3064
|
name: "get_video_frame",
|
|
2381
3065
|
title: "What plays at a moment",
|
|
2382
|
-
description: "Returns what plays at a time without changing anything: the pictures back to front (clip, track, asset, source time in ms, opacity, placement rect, rotation, any wipe) and the sounds (clip, source time, gain after volumes, fades and transitions).",
|
|
3066
|
+
description: "Returns what plays at a time without changing anything: the pictures back to front (clip, track, asset, source time in ms, opacity, placement rect, rotation, any wipe or iris) and the sounds (clip, source time, gain after volumes, fades and transitions).",
|
|
2383
3067
|
inputSchema: object({ time: MS("Time from the start (default: the playhead)") }),
|
|
2384
3068
|
annotations: { title: "What plays at a moment", ...READ },
|
|
2385
3069
|
handler: ({ time }) => {
|
|
@@ -2401,7 +3085,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2401
3085
|
const checkProject = {
|
|
2402
3086
|
name: "check_video_project",
|
|
2403
3087
|
title: "Check the video project",
|
|
2404
|
-
description: "Lists the project's problems, errors first, each with a code, the clip / track / asset it is about and how to fix it: missing assets, overlaps, clips past the end of their source, transitions without a cut or handles, fades longer than the clip, keyframes outside their clip, assets with no duration, unused assets.",
|
|
3088
|
+
description: "Lists the project's problems, errors first, each with a code, the clip / track / asset it is about and how to fix it: missing assets, overlaps, clips past the end of their source, transitions without a cut or handles, fades or animations longer than the clip, animations a clip's own keyframes override, keyframes outside their clip, assets with no duration, unused assets.",
|
|
2405
3089
|
inputSchema: object({}),
|
|
2406
3090
|
annotations: { title: "Check the video project", ...READ },
|
|
2407
3091
|
handler: () => {
|
|
@@ -2432,27 +3116,59 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2432
3116
|
return { svg };
|
|
2433
3117
|
}
|
|
2434
3118
|
};
|
|
3119
|
+
const LOUDNESS_TARGET = {
|
|
3120
|
+
integrated: { type: "number", minimum: -70, maximum: -5, description: "Integrated loudness to reach, in LUFS (default -14, what streaming services play at; -16 for podcasts, -23 for EBU R128 broadcast)." },
|
|
3121
|
+
truePeak: { type: "number", minimum: -9, maximum: 0, description: "Highest true peak, in dBTP (default -1)." },
|
|
3122
|
+
range: { type: "number", minimum: 1, maximum: 50, description: "Loudness range, in LU (default 11)." }
|
|
3123
|
+
};
|
|
2435
3124
|
const exportFfmpeg = {
|
|
2436
3125
|
name: "export_video_ffmpeg",
|
|
2437
3126
|
title: "Export an ffmpeg command",
|
|
2438
|
-
description: "Returns the ffmpeg command that renders the project to a file: { args (for spawn), command (one shell line), filterComplex, duration, notes on anything approximated }. Fails when the project has no clips.",
|
|
3127
|
+
description: "Returns the ffmpeg command that renders the project to a file: { args (for spawn), command (one shell line), filterComplex, duration, notes on anything approximated }. With loudness, the mix is normalised to a target loudness (EBU R128); add loudness.measured from a measuring pass (export_video_loudness_measure) for an exact fixed gain. Fails when the project has no clips.",
|
|
2439
3128
|
inputSchema: object({
|
|
2440
3129
|
output: { type: "string", minLength: 1, description: "Output file name (default output.mp4)." },
|
|
2441
|
-
crf: { type: "integer", minimum: 0, maximum: 51, description: "x264 quality, lower is better (default 18)." }
|
|
3130
|
+
crf: { type: "integer", minimum: 0, maximum: 51, description: "x264 quality, lower is better (default 18)." },
|
|
3131
|
+
loudness: object(
|
|
3132
|
+
{
|
|
3133
|
+
...LOUDNESS_TARGET,
|
|
3134
|
+
measured: object(
|
|
3135
|
+
{
|
|
3136
|
+
integrated: { type: "number", description: `Measured integrated loudness, LUFS (loudnorm's "input_i").` },
|
|
3137
|
+
truePeak: { type: "number", description: 'Measured true peak, dBTP ("input_tp").' },
|
|
3138
|
+
range: { type: "number", description: 'Measured loudness range, LU ("input_lra").' },
|
|
3139
|
+
threshold: { type: "number", description: 'Measured gating threshold, LUFS ("input_thresh").' },
|
|
3140
|
+
offset: { type: "number", description: 'Offset for the second pass, LU ("target_offset").' }
|
|
3141
|
+
},
|
|
3142
|
+
["integrated", "truePeak", "range", "threshold", "offset"],
|
|
3143
|
+
"What the measuring pass printed (the JSON at the end of its stderr), for a linear second pass that keeps the dynamics. Leave out for one-pass normalisation."
|
|
3144
|
+
)
|
|
3145
|
+
},
|
|
3146
|
+
[],
|
|
3147
|
+
"Normalise the mix's loudness (default: not normalised). An empty object uses -14 LUFS, -1 dBTP."
|
|
3148
|
+
)
|
|
2442
3149
|
}),
|
|
2443
3150
|
annotations: { title: "Export an ffmpeg command", ...READ },
|
|
2444
|
-
handler: ({ output, crf }) => controller.toFfmpeg({ ...output ? { output } : {}, ...crf !== void 0 ? { crf } : {} })
|
|
3151
|
+
handler: ({ output, crf, loudness }) => controller.toFfmpeg({ ...output ? { output } : {}, ...crf !== void 0 ? { crf } : {}, ...loudness ? { loudness } : {} })
|
|
3152
|
+
};
|
|
3153
|
+
const exportLoudnessMeasure = {
|
|
3154
|
+
name: "export_video_loudness_measure",
|
|
3155
|
+
title: "Export a loudness measuring command",
|
|
3156
|
+
description: "Returns the ffmpeg command for the first pass of a loudness normalisation: it mixes only the sound and prints its loudness as JSON at the end of stderr (input_i, input_tp, input_lra, input_thresh, target_offset) instead of writing a file. Pass those numbers as loudness.measured (integrated, truePeak, range, threshold, offset) to export_video_ffmpeg, with the same target. Fails when nothing makes a sound.",
|
|
3157
|
+
inputSchema: object(LOUDNESS_TARGET),
|
|
3158
|
+
annotations: { title: "Export a loudness measuring command", ...READ },
|
|
3159
|
+
handler: (target) => controller.toLoudnessMeasure({ loudness: target })
|
|
2445
3160
|
};
|
|
2446
3161
|
const exportEdl = {
|
|
2447
3162
|
name: "export_video_edl",
|
|
2448
3163
|
title: "Export an EDL",
|
|
2449
|
-
description: "Returns one track as a CMX 3600 edit decision list (text) for conforming in another editor: events with source and record timecode, dissolves, speed changes and clip names.",
|
|
3164
|
+
description: "Returns one track as a CMX 3600 edit decision list (text) for conforming in another editor: events with source and record timecode (drop-frame at 29.97 and 59.94 fps unless dropFrame is false), dissolves, speed changes and clip names.",
|
|
2450
3165
|
inputSchema: object({
|
|
2451
3166
|
track: { ...TRACK_ID, description: "Track to export (default: the bottom video track)." },
|
|
2452
|
-
title: { type: "string", description: "TITLE line (default: the project name)." }
|
|
3167
|
+
title: { type: "string", description: "TITLE line (default: the project name)." },
|
|
3168
|
+
dropFrame: { type: "boolean", description: "Drop-frame timecode (HH:MM:SS;FF). Default true at 29.97 and 59.94 fps, where it is the norm; ignored at other rates, which have no drop-frame form." }
|
|
2453
3169
|
}),
|
|
2454
3170
|
annotations: { title: "Export an EDL", ...READ },
|
|
2455
|
-
handler: ({ track, title }) => ({ edl: controller.toEdl({ ...track ? { track } : {}, ...title ? { title } : {} }) })
|
|
3171
|
+
handler: ({ track, title, dropFrame }) => ({ edl: controller.toEdl({ ...track ? { track } : {}, ...title ? { title } : {}, ...dropFrame !== void 0 ? { dropFrame } : {} }) })
|
|
2456
3172
|
};
|
|
2457
3173
|
const seek = {
|
|
2458
3174
|
name: "seek_video",
|
|
@@ -2601,12 +3317,12 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2601
3317
|
const updateClip = {
|
|
2602
3318
|
name: "update_video_clip",
|
|
2603
3319
|
title: "Change a clip",
|
|
2604
|
-
description: `Changes a clip's properties (one undo step): opacity, transform, fit, fades, name; for media volume, mute, speed (keeps the stretch of source, so the length changes) or asset; for titles text and style; for colours the colour. Fields in "remove" go back to their defaults. Use move / trim for timing. Returns the clip.`,
|
|
3320
|
+
description: `Changes a clip's properties (one undo step): opacity, transform, fit, blend mode, colour grade, entrance and exit animations, fades, name; for media volume, mute, the sound's fade curves, speed (keeps the stretch of source, so the length changes) or asset; for titles text and style; for colours the colour. Fields in "remove" go back to their defaults. Use move / trim for timing. Returns the clip.`,
|
|
2605
3321
|
inputSchema: object(
|
|
2606
3322
|
{
|
|
2607
3323
|
clip: CLIP_ID,
|
|
2608
3324
|
set: object({ ...CLIP_COMMON, ...MEDIA_FIELDS, asset: { ...ASSET_ID, description: "For media: play another asset (same in point)." }, ...TEXT_FIELDS, color: COLOR("Text colour, or a colour clip's fill") }, [], "Fields to replace (only those that fit the clip's type)."),
|
|
2609
|
-
remove: { type: "array", items: { type: "string", enum: ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "in", "speed", "volume", "muted", "fontSize", "fontFamily", "fontWeight", "background"] }, description: "Fields to reset to their defaults." }
|
|
3325
|
+
remove: { type: "array", items: { type: "string", enum: ["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "in", "speed", "volume", "muted", "fontSize", "fontFamily", "fontWeight", "background"] }, description: "Fields to reset to their defaults." }
|
|
2610
3326
|
},
|
|
2611
3327
|
["clip"]
|
|
2612
3328
|
),
|
|
@@ -2614,17 +3330,60 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2614
3330
|
handler: ({ clip, set, remove }) => {
|
|
2615
3331
|
const current = clipOut(project(), clip);
|
|
2616
3332
|
if (current && set) {
|
|
2617
|
-
const allowed = /* @__PURE__ */ new Set(["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", ...current.type === "media" ? ["in", "speed", "volume", "muted", "asset"] : current.type === "text" ? Object.keys(TEXT_FIELDS) : ["color"]]);
|
|
3333
|
+
const allowed = /* @__PURE__ */ new Set(["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", ...current.type === "media" ? ["in", "speed", "volume", "muted", "asset", "fadeInCurve", "fadeOutCurve"] : current.type === "text" ? Object.keys(TEXT_FIELDS) : ["color"]]);
|
|
2618
3334
|
const wrong = Object.keys(set).filter((k) => !allowed.has(k));
|
|
2619
3335
|
if (wrong.length) throw new Error(`${wrong.map((k) => `"${k}"`).join(", ")} cannot be set on a ${current.type} clip; it takes ${[...allowed].join(", ")}.`);
|
|
2620
3336
|
}
|
|
2621
3337
|
return apply({ op: "update_clip", clip, set, remove }, "Change clip", (r) => ({ clip: clipOut(r.project, clip) }));
|
|
2622
3338
|
}
|
|
2623
3339
|
};
|
|
3340
|
+
const gradeTargets = (ids) => {
|
|
3341
|
+
const p = project();
|
|
3342
|
+
for (const id of ids) {
|
|
3343
|
+
const at = findVideoClip(p, id);
|
|
3344
|
+
if (!at) throw new Error(`There is no clip "${id}"; get_video_project lists the clips.`);
|
|
3345
|
+
const { clip } = at;
|
|
3346
|
+
if (at.track.kind !== "video" || clip.type === "media" && _optionalChain([p, 'access', _89 => _89.assets, 'access', _90 => _90.find, 'call', _91 => _91((a) => a.id === clip.asset), 'optionalAccess', _92 => _92.kind]) === "audio")
|
|
3347
|
+
throw new Error(`Clip "${id}" is sound only; grades change pictures. Pass clips on video tracks.`);
|
|
3348
|
+
}
|
|
3349
|
+
};
|
|
3350
|
+
const applyGradePreset = {
|
|
3351
|
+
name: "apply_video_grade_preset",
|
|
3352
|
+
title: "Apply a colour look",
|
|
3353
|
+
description: "Gives clips a named colour look at a strength (one undo step), replacing their grades: warm, cool, punchy (more contrast and colour), faded (lifted and muted), black-and-white, bleach-bypass (desaturated, contrasty), day-for-night (dark and blue). Returns each clip's resulting grade; tweak it with update_video_clip set.grade.",
|
|
3354
|
+
inputSchema: object(
|
|
3355
|
+
{
|
|
3356
|
+
clips: { type: "array", items: CLIP_ID, minItems: 1, description: "Clips on video tracks to grade." },
|
|
3357
|
+
preset: { type: "string", enum: [...VIDEO_GRADE_PRESET_NAMES], description: "The look." },
|
|
3358
|
+
amount: { type: "number", minimum: 0, maximum: 1, description: "Strength, 0 (none) to 1 (full, the default); 0.5 is a subtler version." }
|
|
3359
|
+
},
|
|
3360
|
+
["clips", "preset"]
|
|
3361
|
+
),
|
|
3362
|
+
annotations: { title: "Apply a colour look", ...WRITE_IDEMPOTENT },
|
|
3363
|
+
handler: ({ clips, preset, amount }) => {
|
|
3364
|
+
gradeTargets(clips);
|
|
3365
|
+
const grade = videoGradePreset(preset, _nullishCoalesce(amount, () => ( 1)));
|
|
3366
|
+
const edits = clips.map((clip) => grade ? { op: "update_clip", clip, set: { grade } } : { op: "update_clip", clip, remove: ["grade"] });
|
|
3367
|
+
return apply(edits, `Apply ${preset} look`, (r) => ({ clips: clips.map((id) => ({ id, grade: _nullishCoalesce(_optionalChain([findVideoClip, 'call', _93 => _93(r.project, id), 'optionalAccess', _94 => _94.clip, 'access', _95 => _95.grade]), () => ( null)) })) }));
|
|
3368
|
+
}
|
|
3369
|
+
};
|
|
3370
|
+
const copyGrade = {
|
|
3371
|
+
name: "copy_video_grade",
|
|
3372
|
+
title: "Copy a colour grade",
|
|
3373
|
+
description: "Gives clips the same colour grade as another clip (one undo step), to match shots in a scene; a source with no grade clears theirs. Returns the grade copied and the clips changed.",
|
|
3374
|
+
inputSchema: object({ from: { ...CLIP_ID, description: "The clip whose grade to copy." }, to: { type: "array", items: CLIP_ID, minItems: 1, description: "Clips on video tracks to give it to." } }, ["from", "to"]),
|
|
3375
|
+
annotations: { title: "Copy a colour grade", ...WRITE_IDEMPOTENT },
|
|
3376
|
+
handler: ({ from, to }) => {
|
|
3377
|
+
gradeTargets([from, ...to]);
|
|
3378
|
+
const grade = findVideoClip(project(), from).clip.grade;
|
|
3379
|
+
const edits = to.map((clip) => grade ? { op: "update_clip", clip, set: { grade: { ...grade } } } : { op: "update_clip", clip, remove: ["grade"] });
|
|
3380
|
+
return apply(edits, "Copy grade", () => ({ grade: _nullishCoalesce(grade, () => ( null)), clips: to }));
|
|
3381
|
+
}
|
|
3382
|
+
};
|
|
2624
3383
|
const setTransition = {
|
|
2625
3384
|
name: "set_video_transition",
|
|
2626
3385
|
title: "Set a transition",
|
|
2627
|
-
description: "Sets (or with null removes) the transition into a clip from the clip that ends where it starts on the same track (one undo step).
|
|
3386
|
+
description: "Sets (or with null removes) the transition into a clip from the clip that ends where it starts on the same track (one undo step). Every type but dip plays half before and half after the cut, so both clips need that much media past the cut; when they do not, the error says the longest that fits. Returns the clip.",
|
|
2628
3387
|
inputSchema: object({ clip: { ...CLIP_ID, description: "The incoming clip (the one after the cut)." }, transition: { anyOf: [TRANSITION, { type: "null" }], description: "The transition, or null to remove it." } }, ["clip", "transition"]),
|
|
2629
3388
|
annotations: { title: "Set a transition", ...WRITE_IDEMPOTENT },
|
|
2630
3389
|
handler: ({ clip, transition }) => apply({ op: "set_transition", clip, transition }, transition ? "Set transition" : "Remove transition", (r) => ({ clip: clipOut(r.project, clip) }))
|
|
@@ -2771,8 +3530,75 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2771
3530
|
return { redone, ...redone ? { label } : {} };
|
|
2772
3531
|
}
|
|
2773
3532
|
};
|
|
2774
|
-
const
|
|
2775
|
-
|
|
3533
|
+
const soundRange = (args) => {
|
|
3534
|
+
const p = project();
|
|
3535
|
+
if (args.clip === void 0 === (args.asset === void 0)) throw new Error('Pass either "clip" (a media clip, times on the timeline) or "asset" (times in the source), not both.');
|
|
3536
|
+
let asset;
|
|
3537
|
+
let toSource = (t) => t;
|
|
3538
|
+
let toOut = (t) => t;
|
|
3539
|
+
let from;
|
|
3540
|
+
let to;
|
|
3541
|
+
if (args.clip !== void 0) {
|
|
3542
|
+
const found = findVideoClip(p, args.clip);
|
|
3543
|
+
if (!found) throw new Error(`There is no clip "${args.clip}"; get_video_project lists the clips.`);
|
|
3544
|
+
const c = found.clip;
|
|
3545
|
+
if (c.type !== "media") throw new Error(`Clip "${c.id}" is a ${c.type} clip and has no sound; pass a media clip.`);
|
|
3546
|
+
asset = p.assets.find((a) => a.id === c.asset);
|
|
3547
|
+
const speed = _nullishCoalesce(c.speed, () => ( 1));
|
|
3548
|
+
toSource = (t) => (_nullishCoalesce(c.in, () => ( 0))) + (t - c.start) * speed;
|
|
3549
|
+
toOut = (s) => c.start + (s - (_nullishCoalesce(c.in, () => ( 0)))) / speed;
|
|
3550
|
+
from = Math.max(c.start, _nullishCoalesce(args.from, () => ( c.start)));
|
|
3551
|
+
to = Math.min(videoClipEnd(c), _nullishCoalesce(args.to, () => ( videoClipEnd(c))));
|
|
3552
|
+
} else {
|
|
3553
|
+
asset = p.assets.find((a) => a.id === args.asset);
|
|
3554
|
+
if (!asset) throw new Error(`There is no asset "${args.asset}"; the assets are ${p.assets.map((a) => `"${a.id}"`).join(", ") || "none"}.`);
|
|
3555
|
+
from = _nullishCoalesce(args.from, () => ( 0));
|
|
3556
|
+
to = _nullishCoalesce(_nullishCoalesce(args.to, () => ( asset.duration)), () => ( 0));
|
|
3557
|
+
}
|
|
3558
|
+
if (!asset) throw new Error(`Clip "${args.clip}" uses a missing asset.`);
|
|
3559
|
+
if (asset.kind === "image" || asset.hasAudio === false) throw new Error(`Asset "${asset.id}" has no sound.`);
|
|
3560
|
+
if (!(to > from)) throw new Error(`The stretch to read is empty (${from}\u2013${to} ms); "to" must be after "from"${args.clip !== void 0 ? " and both inside the clip" : asset.duration === void 0 ? ', and the asset has no duration, so pass "to"' : ""}.`);
|
|
3561
|
+
return { asset, from, to, toSource, toOut };
|
|
3562
|
+
};
|
|
3563
|
+
const SOUND_TARGET = {
|
|
3564
|
+
clip: { ...CLIP_ID, description: "A media clip: read the stretch of source it plays, with times on the timeline. Pass this or asset." },
|
|
3565
|
+
asset: { ...ASSET_ID, description: "A video or audio asset: read its source, with times in the source. Pass this or clip." },
|
|
3566
|
+
from: MS("Start of the stretch (default: the clip's start, or 0 for an asset)"),
|
|
3567
|
+
to: MS("End of the stretch (default: the clip's end, or the asset's duration)")
|
|
3568
|
+
};
|
|
3569
|
+
const SOUND_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
3570
|
+
const audioLevels = {
|
|
3571
|
+
name: "get_video_audio_levels",
|
|
3572
|
+
title: "Read sound levels",
|
|
3573
|
+
description: 'Measures how loud the source sound is over a clip or an asset, without changing anything: the peak level in dBFS (0 = full scale, -100 = silence) for each of "buckets" equal stretches, plus the loudest peak. Levels are the recording as it is, before clip volume and fades. Use it to find where speech or a beat starts, or to judge whether a clip is too loud. The first call for an asset decodes its file, which can take a moment.',
|
|
3574
|
+
inputSchema: object({ ...SOUND_TARGET, buckets: { type: "integer", minimum: 1, maximum: 500, description: "How many equal stretches to measure (default 50)." } }),
|
|
3575
|
+
annotations: { title: "Read sound levels", ...SOUND_ANNOTATIONS },
|
|
3576
|
+
handler: async (args) => {
|
|
3577
|
+
const r = soundRange(args);
|
|
3578
|
+
const peaks = await controller.audioPeaks.load(r.asset);
|
|
3579
|
+
const levels = videoAudioLevels(peaks, r.toSource(r.from), r.toSource(r.to), _nullishCoalesce(args.buckets, () => ( 50))).map((l) => ({ start: round(r.toOut(l.start)), end: round(r.toOut(l.end)), peakDb: l.peakDb }));
|
|
3580
|
+
return { asset: r.asset.id, from: r.from, to: r.to, peakDb: Math.max(...levels.map((l) => l.peakDb)), levels };
|
|
3581
|
+
}
|
|
3582
|
+
};
|
|
3583
|
+
const findSilences = {
|
|
3584
|
+
name: "find_video_silences",
|
|
3585
|
+
title: "Find silences",
|
|
3586
|
+
description: "Lists the stretches of a clip or an asset whose source sound stays below a level for at least a minimum length, as { start, end } ms (timeline times for a clip, so they can go straight to extract_video_range or split_video_at; source times for an asset). Use it to cut pauses, dead air or the gaps between takes. The first call for an asset decodes its file.",
|
|
3587
|
+
inputSchema: object({
|
|
3588
|
+
...SOUND_TARGET,
|
|
3589
|
+
thresholdDb: { type: "number", minimum: -100, maximum: 0, description: "Quieter than this peak level counts as silence, in dBFS (default -40; -50 for a quiet room, -30 over background noise)." },
|
|
3590
|
+
minDuration: { type: "number", exclusiveMinimum: 0, description: "Shortest silence to report, in ms (default 300)." }
|
|
3591
|
+
}),
|
|
3592
|
+
annotations: { title: "Find silences", ...SOUND_ANNOTATIONS },
|
|
3593
|
+
handler: async (args) => {
|
|
3594
|
+
const r = soundRange(args);
|
|
3595
|
+
const peaks = await controller.audioPeaks.load(r.asset);
|
|
3596
|
+
const silences = findVideoAudioSilences(peaks, r.toSource(r.from), r.toSource(r.to), { thresholdDb: args.thresholdDb, minDuration: args.minDuration }).map((x) => ({ start: round(r.toOut(x.start)), end: round(r.toOut(x.end)) }));
|
|
3597
|
+
return { asset: r.asset.id, from: r.from, to: r.to, silences, total: round(silences.reduce((n2, x) => n2 + x.end - x.start, 0)) };
|
|
3598
|
+
}
|
|
3599
|
+
};
|
|
3600
|
+
const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
|
|
3601
|
+
const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, applyGradePreset, copyGrade, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
|
|
2776
3602
|
return {
|
|
2777
3603
|
id: _nullishCoalesce(options.id, () => ( "video")),
|
|
2778
3604
|
namespace: options.namespace,
|
|
@@ -2804,6 +3630,50 @@ function videoRulerTicks(duration, pxPerSecond, fps) {
|
|
|
2804
3630
|
}
|
|
2805
3631
|
return ticks;
|
|
2806
3632
|
}
|
|
3633
|
+
function videoClipShowsWaveform(clip, asset, mode = "audio") {
|
|
3634
|
+
if (clip.type !== "media" || mode === "none" || !videoAssetHasSound(asset)) return false;
|
|
3635
|
+
return mode === "all" || asset.kind === "audio";
|
|
3636
|
+
}
|
|
3637
|
+
var VIDEO_CLIP_ANIMATION_LABELS = {
|
|
3638
|
+
fade: "Fade",
|
|
3639
|
+
rise: "Rise",
|
|
3640
|
+
drop: "Drop",
|
|
3641
|
+
"slide-left": "Slide left",
|
|
3642
|
+
"slide-right": "Slide right",
|
|
3643
|
+
zoom: "Zoom",
|
|
3644
|
+
pop: "Pop"
|
|
3645
|
+
};
|
|
3646
|
+
var VIDEO_TRANSITION_LABELS = {
|
|
3647
|
+
crossfade: "Crossfade",
|
|
3648
|
+
dip: "Dip to colour",
|
|
3649
|
+
wipe: "Wipe",
|
|
3650
|
+
push: "Push",
|
|
3651
|
+
slide: "Slide",
|
|
3652
|
+
iris: "Iris"
|
|
3653
|
+
};
|
|
3654
|
+
var videoTransitionHasDirection = (type) => type === "wipe" || type === "push" || type === "slide";
|
|
3655
|
+
var VIDEO_BLEND_MODE_LABELS = {
|
|
3656
|
+
normal: "Normal",
|
|
3657
|
+
multiply: "Multiply",
|
|
3658
|
+
screen: "Screen",
|
|
3659
|
+
overlay: "Overlay",
|
|
3660
|
+
darken: "Darken",
|
|
3661
|
+
lighten: "Lighten",
|
|
3662
|
+
"color-dodge": "Colour dodge",
|
|
3663
|
+
"color-burn": "Colour burn",
|
|
3664
|
+
"hard-light": "Hard light",
|
|
3665
|
+
"soft-light": "Soft light",
|
|
3666
|
+
difference: "Difference",
|
|
3667
|
+
exclusion: "Exclusion",
|
|
3668
|
+
add: "Add"
|
|
3669
|
+
};
|
|
3670
|
+
var VIDEO_FADE_CURVE_LABELS = {
|
|
3671
|
+
linear: "Linear",
|
|
3672
|
+
"equal-power": "Equal power",
|
|
3673
|
+
"s-curve": "S-curve",
|
|
3674
|
+
exponential: "Exponential",
|
|
3675
|
+
logarithmic: "Logarithmic"
|
|
3676
|
+
};
|
|
2807
3677
|
function videoClipTint(clip, asset) {
|
|
2808
3678
|
if (clip.type === "text") return "#a855f7";
|
|
2809
3679
|
if (clip.type === "color") return clip.color;
|
|
@@ -2814,7 +3684,7 @@ function videoClipLabel(clip, asset) {
|
|
|
2814
3684
|
if (clip.name) return clip.name;
|
|
2815
3685
|
if (clip.type === "text") return clip.text.split("\n")[0];
|
|
2816
3686
|
if (clip.type === "color") return clip.color;
|
|
2817
|
-
const base = _nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess',
|
|
3687
|
+
const base = _nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _96 => _96.name]), () => ( _optionalChain([asset, 'optionalAccess', _97 => _97.src, 'access', _98 => _98.replace, 'call', _99 => _99(/^.*[\\/]/, "")]))), () => ( clip.asset));
|
|
2818
3688
|
const speed = videoClipSpeed(clip);
|
|
2819
3689
|
return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
|
|
2820
3690
|
}
|
|
@@ -2858,7 +3728,7 @@ function videoAssetDropEdit(project, assetId, options = {}) {
|
|
|
2858
3728
|
const asset = videoAssetMap(project).get(assetId);
|
|
2859
3729
|
if (!asset) return null;
|
|
2860
3730
|
const kind = asset.kind === "audio" ? "audio" : "video";
|
|
2861
|
-
const track = _nullishCoalesce(options.track, () => ( _optionalChain([project, 'access',
|
|
3731
|
+
const track = _nullishCoalesce(options.track, () => ( _optionalChain([project, 'access', _100 => _100.tracks, 'access', _101 => _101.find, 'call', _102 => _102((t) => t.kind === kind && !t.locked), 'optionalAccess', _103 => _103.id])));
|
|
2862
3732
|
if (!track) return null;
|
|
2863
3733
|
return { op: "add_clip", track, clip: { type: "media", asset: asset.id, ...options.start !== void 0 ? { start: options.start } : {} }, ...options.ripple ? { ripple: true } : {}, ...options.mode && options.mode !== "refuse" ? { mode: options.mode } : {} };
|
|
2864
3734
|
}
|
|
@@ -2907,15 +3777,15 @@ var KEY_LABELS = {
|
|
|
2907
3777
|
scale: { label: "Scale", step: 0.05 },
|
|
2908
3778
|
rotation: { label: "Rotation", step: 1 }
|
|
2909
3779
|
};
|
|
2910
|
-
var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([clip, 'access',
|
|
3780
|
+
var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([clip, 'access', _104 => _104.transform, 'optionalAccess', _105 => _105[property]]) : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
|
|
2911
3781
|
var playheadInClip = (clip, time) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));
|
|
2912
3782
|
function videoKeyframeRows(project, clipId, time) {
|
|
2913
3783
|
const at = findVideoClip(project, clipId);
|
|
2914
3784
|
if (!at) return [];
|
|
2915
3785
|
const { clip } = at;
|
|
2916
3786
|
const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
|
|
2917
|
-
const visual = !(_optionalChain([asset, 'optionalAccess',
|
|
2918
|
-
const audible = clip.type === "media" && (
|
|
3787
|
+
const visual = !(_optionalChain([asset, 'optionalAccess', _106 => _106.kind]) === "audio" || at.track.kind === "audio");
|
|
3788
|
+
const audible = clip.type === "media" && videoAssetHasSound(asset);
|
|
2919
3789
|
const properties = [...visual ? ["opacity"] : [], ...audible ? ["volume"] : [], ...visual ? VIDEO_TRANSFORM_PROPERTIES : []];
|
|
2920
3790
|
const clipTime = playheadInClip(clip, time);
|
|
2921
3791
|
return properties.map((property) => {
|
|
@@ -3060,5 +3930,59 @@ function videoKeyframeToggleEdit(project, clipId, property, time) {
|
|
|
3060
3930
|
|
|
3061
3931
|
|
|
3062
3932
|
|
|
3063
|
-
|
|
3933
|
+
|
|
3934
|
+
|
|
3935
|
+
|
|
3936
|
+
|
|
3937
|
+
|
|
3938
|
+
|
|
3939
|
+
|
|
3940
|
+
|
|
3941
|
+
|
|
3942
|
+
|
|
3943
|
+
|
|
3944
|
+
|
|
3945
|
+
|
|
3946
|
+
|
|
3947
|
+
|
|
3948
|
+
|
|
3949
|
+
|
|
3950
|
+
|
|
3951
|
+
|
|
3952
|
+
|
|
3953
|
+
|
|
3954
|
+
|
|
3955
|
+
|
|
3956
|
+
|
|
3957
|
+
|
|
3958
|
+
|
|
3959
|
+
|
|
3960
|
+
|
|
3961
|
+
|
|
3962
|
+
|
|
3963
|
+
|
|
3964
|
+
|
|
3965
|
+
|
|
3966
|
+
|
|
3967
|
+
|
|
3968
|
+
|
|
3969
|
+
|
|
3970
|
+
|
|
3971
|
+
|
|
3972
|
+
|
|
3973
|
+
|
|
3974
|
+
|
|
3975
|
+
|
|
3976
|
+
|
|
3977
|
+
|
|
3978
|
+
|
|
3979
|
+
|
|
3980
|
+
|
|
3981
|
+
|
|
3982
|
+
|
|
3983
|
+
|
|
3984
|
+
|
|
3985
|
+
|
|
3986
|
+
|
|
3987
|
+
exports.VIDEO_BLEND_MODES = VIDEO_BLEND_MODES; exports.VIDEO_BLEND_MODE_LABELS = VIDEO_BLEND_MODE_LABELS; exports.VIDEO_CLIP_ANIMATION_DURATION = VIDEO_CLIP_ANIMATION_DURATION; exports.VIDEO_CLIP_ANIMATION_EFFECTS = VIDEO_CLIP_ANIMATION_EFFECTS; exports.VIDEO_CLIP_ANIMATION_LABELS = VIDEO_CLIP_ANIMATION_LABELS; exports.VIDEO_CLIP_TYPES = VIDEO_CLIP_TYPES; exports.VIDEO_DEFAULT_STILL_DURATION = VIDEO_DEFAULT_STILL_DURATION; exports.VIDEO_DEFAULT_TITLE_DURATION = VIDEO_DEFAULT_TITLE_DURATION; exports.VIDEO_EASE_NAMES = VIDEO_EASE_NAMES; exports.VIDEO_EDIT_OPS = VIDEO_EDIT_OPS; exports.VIDEO_FADE_CURVES = VIDEO_FADE_CURVES; exports.VIDEO_FADE_CURVE_LABELS = VIDEO_FADE_CURVE_LABELS; exports.VIDEO_FITS = VIDEO_FITS; exports.VIDEO_GRADE_CONTROLS = VIDEO_GRADE_CONTROLS; exports.VIDEO_GRADE_PRESETS = VIDEO_GRADE_PRESETS; exports.VIDEO_GRADE_PRESET_NAMES = VIDEO_GRADE_PRESET_NAMES; exports.VIDEO_ISSUE_CODES = VIDEO_ISSUE_CODES; exports.VIDEO_KEYFRAME_LIMITS = VIDEO_KEYFRAME_LIMITS; exports.VIDEO_KEYFRAME_PROPERTIES = VIDEO_KEYFRAME_PROPERTIES; exports.VIDEO_PEAK_BLOCK = VIDEO_PEAK_BLOCK; exports.VIDEO_PLACEMENT_MODES = VIDEO_PLACEMENT_MODES; exports.VIDEO_TIME_EPSILON = VIDEO_TIME_EPSILON; exports.VIDEO_TRANSFORM_PROPERTIES = VIDEO_TRANSFORM_PROPERTIES; exports.VIDEO_TRANSITION_LABELS = VIDEO_TRANSITION_LABELS; exports.VIDEO_TRANSITION_TYPES = VIDEO_TRANSITION_TYPES; exports.VIDEO_WIPE_DIRECTIONS = VIDEO_WIPE_DIRECTIONS; exports.VideoAudioPeaksCache = VideoAudioPeaksCache; exports.VideoEditorController = VideoEditorController; exports.VideoPreviewEngine = VideoPreviewEngine; exports.allVideoClipIds = allVideoClipIds; exports.applyVideoEdits = applyVideoEdits; exports.applyVideoGrade = applyVideoGrade; exports.buildVideoAudioPeaks = buildVideoAudioPeaks; exports.checkVideoProject = checkVideoProject; exports.createVideoEditorToolProvider = createVideoEditorToolProvider; exports.createVideoProject = createVideoProject; exports.decodeVideoAudioPeaks = decodeVideoAudioPeaks; exports.describeVideoProject = describeVideoProject; exports.drawVideoFrame = drawVideoFrame; exports.ffmpegFilterValue = ffmpegFilterValue; exports.findVideoAudioSilences = findVideoAudioSilences; exports.findVideoClip = findVideoClip; exports.findVideoGaps = findVideoGaps; exports.formatVideoSeconds = formatVideoSeconds; exports.formatVideoTimecode = formatVideoTimecode; exports.formatVideoTimecodeFrames = formatVideoTimecodeFrames; exports.isVideoDropFrameRate = isVideoDropFrameRate; exports.isVideoKeyframes = isVideoKeyframes; exports.nextFreeVideoSlot = nextFreeVideoSlot; exports.normalizeVideoGrade = normalizeVideoGrade; exports.parseVideoAnimatable = parseVideoAnimatable; exports.parseVideoAsset = parseVideoAsset; exports.parseVideoClip = parseVideoClip; exports.parseVideoClipAnimation = parseVideoClipAnimation; exports.parseVideoGrade = parseVideoGrade; exports.parseVideoKeyframes = parseVideoKeyframes; exports.parseVideoLoudnessMeasurement = parseVideoLoudnessMeasurement; exports.parseVideoMarker = parseVideoMarker; exports.parseVideoProject = parseVideoProject; exports.parseVideoTrack = parseVideoTrack; exports.parseVideoTransform = parseVideoTransform; exports.parseVideoTransition = parseVideoTransition; exports.placeVideoRect = placeVideoRect; exports.planVideoClipDrag = planVideoClipDrag; exports.probeVideoMedia = probeVideoMedia; exports.removeVideoKey = removeVideoKey; exports.retimeVideoAnimatable = retimeVideoAnimatable; exports.setVideoKey = setVideoKey; exports.snapToVideoFrame = snapToVideoFrame; exports.snapVideoTime = snapVideoTime; exports.sortVideoKeys = sortVideoKeys; exports.sortedVideoClips = sortedVideoClips; exports.splitVideoAnimatable = splitVideoAnimatable; exports.toVideoEdl = toVideoEdl; exports.toVideoFfmpeg = toVideoFfmpeg; exports.toVideoLoudnessMeasure = toVideoLoudnessMeasure; exports.videoAssetDropEdit = videoAssetDropEdit; exports.videoAssetHasSound = videoAssetHasSound; exports.videoAssetKindOf = videoAssetKindOf; exports.videoAssetMap = videoAssetMap; exports.videoAudioLevels = videoAudioLevels; exports.videoAudioPeakRange = videoAudioPeakRange; exports.videoAudioPeakSpans = videoAudioPeakSpans; exports.videoAudioPeaksDuration = videoAudioPeaksDuration; exports.videoClipAfter = videoClipAfter; exports.videoClipAnimated = videoClipAnimated; exports.videoClipAnimationProperties = videoClipAnimationProperties; exports.videoClipAnimationSpans = videoClipAnimationSpans; exports.videoClipBefore = videoClipBefore; exports.videoClipEnd = videoClipEnd; exports.videoClipHandles = videoClipHandles; exports.videoClipKeyframes = videoClipKeyframes; exports.videoClipLabel = videoClipLabel; exports.videoClipOverhang = videoClipOverhang; exports.videoClipShowsWaveform = videoClipShowsWaveform; exports.videoClipSoundGain = videoClipSoundGain; exports.videoClipSourceRange = videoClipSourceRange; exports.videoClipSpeed = videoClipSpeed; exports.videoClipTint = videoClipTint; exports.videoClipWaveform = videoClipWaveform; exports.videoClipWaveformPath = videoClipWaveformPath; exports.videoClipsAt = videoClipsAt; exports.videoContactSheetSvg = videoContactSheetSvg; exports.videoDragLabel = videoDragLabel; exports.videoDropTracks = videoDropTracks; exports.videoEaseBezier = videoEaseBezier; exports.videoEaseProgress = videoEaseProgress; exports.videoFadeCurveGain = videoFadeCurveGain; exports.videoFrameAt = videoFrameAt; exports.videoFrameDuration = videoFrameDuration; exports.videoFrameToSvg = videoFrameToSvg; exports.videoFrames = videoFrames; exports.videoGradeCssFilter = videoGradeCssFilter; exports.videoGradeFfmpegFilters = videoGradeFfmpegFilters; exports.videoGradeGainsFilterId = videoGradeGainsFilterId; exports.videoGradePreset = videoGradePreset; exports.videoGradeStepMatrix = videoGradeStepMatrix; exports.videoGradeStepSvgValues = videoGradeStepSvgValues; exports.videoGradeSteps = videoGradeSteps; exports.videoGradeSvgDefs = videoGradeSvgDefs; exports.videoGradeSvgFilter = videoGradeSvgFilter; exports.videoHourFrames = videoHourFrames; exports.videoIrisRadius = videoIrisRadius; exports.videoIssueKey = videoIssueKey; exports.videoKeyframeBreakpoints = videoKeyframeBreakpoints; exports.videoKeyframeFfmpegExpr = videoKeyframeFfmpegExpr; exports.videoKeyframeRows = videoKeyframeRows; exports.videoKeyframeToggleEdit = videoKeyframeToggleEdit; exports.videoKeyframeValueEdit = videoKeyframeValueEdit; exports.videoKeysOf = videoKeysOf; exports.videoOverlap = videoOverlap; exports.videoPeakDb = videoPeakDb; exports.videoProjectDuration = videoProjectDuration; exports.videoProjectSvg = videoProjectSvg; exports.videoRulerTicks = videoRulerTicks; exports.videoSnapPoints = videoSnapPoints; exports.videoTextStyle = videoTextStyle; exports.videoTimecodeFrames = videoTimecodeFrames; exports.videoTitleFrame = videoTitleFrame; exports.videoTrackSyncLocked = videoTrackSyncLocked; exports.videoTransformAt = videoTransformAt; exports.videoTransitionAudioCurve = videoTransitionAudioCurve; exports.videoTransitionEase = videoTransitionEase; exports.videoTransitionHasDirection = videoTransitionHasDirection; exports.videoValueAt = videoValueAt; exports.videoWaveformPath = videoWaveformPath; exports.videoWipeRect = videoWipeRect;
|
|
3064
3988
|
//# sourceMappingURL=index.cjs.map
|