@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
|
@@ -13,8 +13,12 @@ import "../chunk-AGR4P4UZ.js";
|
|
|
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) => 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 = g.temperature ?? 0;
|
|
258
|
+
steps.push({ kind: "gains", r: 1 + 0.25 * t, g: 1 - 0.25 * (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
|
}
|
|
@@ -534,6 +717,66 @@ 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 ? clip.animateIn.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0;
|
|
739
|
+
const b = clip.animateOut ? 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 : clip.transform?.[prop];
|
|
749
|
+
if (isVideoKeyframes(current)) continue;
|
|
750
|
+
const base = 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 = [
|
|
@@ -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 ((clip.fadeIn ?? 0) + (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 (${clip.fadeIn ?? 0} + ${clip.fadeOut ?? 0} ms) are longer than the clip (${clip.duration} ms); shorten them.` });
|
|
836
|
+
const anim = (clip.animateIn ? clip.animateIn.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0) + (clip.animateOut ? 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 : clip.transform?.[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)
|
|
@@ -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 {
|
|
707
962
|
constructor(p) {
|
|
@@ -797,6 +1052,8 @@ var Editor = class {
|
|
|
797
1052
|
const right = { ...structuredClone(clip), id: 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 {
|
|
|
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;
|
|
@@ -1356,12 +1615,33 @@ 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(shape?.fadeInCurve, (time - clip.start) / clip.fadeIn);
|
|
1638
|
+
if (clip.fadeOut) g *= videoFadeCurveGain(shape?.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)) {
|
|
@@ -1387,33 +1667,53 @@ function videoFrameAt(project, time) {
|
|
|
1387
1667
|
for (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {
|
|
1388
1668
|
if (incoming?.type === "dip" && incoming.progress < 0.5) continue;
|
|
1389
1669
|
if (outgoing?.type === "dip" && outgoing.progress >= 0.5) continue;
|
|
1390
|
-
const
|
|
1391
|
-
const
|
|
1392
|
-
const
|
|
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) * (track.volume ?? 1) *
|
|
1679
|
+
const gain = videoValueAt(clip.volume, clipTime, 1) * (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: 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 = incoming.transition.direction ?? "right";
|
|
1697
|
+
shift = videoTransitionEase(incoming.progress) - 1;
|
|
1698
|
+
} else dip = { color: incoming.transition.color ?? "#000000", opacity: (1 - incoming.progress) * 2 };
|
|
1410
1699
|
}
|
|
1411
1700
|
if (outgoing?.type === "dip") dip = { color: outgoing.next.transition.color ?? "#000000", opacity: outgoing.progress * 2 };
|
|
1701
|
+
if (outgoing?.type === "push") {
|
|
1702
|
+
shiftDirection = outgoing.next.transition.direction ?? "right";
|
|
1703
|
+
shift = videoTransitionEase(outgoing.progress);
|
|
1704
|
+
}
|
|
1412
1705
|
const size = clip.type === "media" ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
|
|
1413
1706
|
const fit = 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(shown.transform?.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: 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 = clip.speed ?? 1;
|
|
1812
|
+
const start = 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 = options.thresholdDb ?? -40;
|
|
1847
|
+
const minDuration = options.minDuration ?? 300;
|
|
1848
|
+
const resolution = Math.max(1, 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 {
|
|
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 = class {
|
|
1879
|
+
constructor(loader = (asset) => decodeVideoAudioPeaks(asset.src)) {
|
|
1880
|
+
this.loader = loader;
|
|
1881
|
+
}
|
|
1882
|
+
loader;
|
|
1883
|
+
ready = /* @__PURE__ */ new Map();
|
|
1884
|
+
pending = /* @__PURE__ */ new Map();
|
|
1885
|
+
failed = /* @__PURE__ */ new Map();
|
|
1886
|
+
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
|
+
};
|
|
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);
|
|
@@ -1562,15 +2102,17 @@ 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 = (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: ${(options.title ?? project.name ?? "Untitled").toUpperCase()}`,
|
|
2111
|
+
const lines = [`TITLE: ${(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" ? (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,7 +2129,7 @@ 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
2135
|
if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${asset?.name ?? asset?.src ?? clip.asset}`);
|
|
@@ -1597,7 +2139,48 @@ function toVideoEdl(project, options = {}) {
|
|
|
1597
2139
|
}
|
|
1598
2140
|
return lines.join("\n");
|
|
1599
2141
|
}
|
|
2142
|
+
function loudnormFilter(target = {}, measure = false) {
|
|
2143
|
+
const I = target.integrated ?? -14;
|
|
2144
|
+
const TP = target.truePeak ?? -1;
|
|
2145
|
+
const LRA = 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(project.background ?? "#000000")}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];
|
|
2222
|
+
const graph = measure ? [] : [`color=c=${color(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,26 +2252,28 @@ 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 = index ?? input(clip, asset, lead, tail);
|
|
1668
2259
|
const trackGain = 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=${(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 (next2?.transition.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)})`;
|
|
@@ -1712,6 +2305,8 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1712
2305
|
`enable='between(t,${sec(clip.start)},${sec(end)})'`
|
|
1713
2306
|
];
|
|
1714
2307
|
if (tf?.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
|
}
|
|
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
|
+
}
|
|
1747
2344
|
const rotation = placed?.rotation;
|
|
1748
|
-
if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=
|
|
1749
|
-
else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=
|
|
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(placed?.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 = 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 (after?.transition.type === "push") shiftBy(after.transition.direction, end - tail, 2 * tail, false);
|
|
1753
2367
|
const at = (axis, c) => {
|
|
1754
2368
|
const v = placed?.[axis];
|
|
1755
|
-
|
|
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)
|
|
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.");
|
|
1772
2402
|
if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && assets.get(c.asset)?.kind === "video" && assets.get(c.asset)?.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(),
|
|
@@ -1816,6 +2451,8 @@ var VideoPreviewEngine = class {
|
|
|
1816
2451
|
unsubscribe;
|
|
1817
2452
|
scheduled = false;
|
|
1818
2453
|
destroyed = false;
|
|
2454
|
+
filterHost = null;
|
|
2455
|
+
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);
|
|
@@ -1917,12 +2554,31 @@ var VideoPreviewEngine = 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);
|
|
2557
|
+
drawVideoFrame(this.ctx, frame, this.sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
|
|
1921
2558
|
this.options.onFrame?.(frame);
|
|
1922
2559
|
}
|
|
2560
|
+
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
|
+
this.filterHost?.remove();
|
|
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();
|
|
@@ -1933,6 +2589,8 @@ var VideoPreviewEngine = class {
|
|
|
1933
2589
|
var VideoEditorController = class {
|
|
1934
2590
|
history;
|
|
1935
2591
|
player;
|
|
2592
|
+
/** Sound levels per asset, loaded on demand for waveforms and agents. */
|
|
2593
|
+
audioPeaks;
|
|
1936
2594
|
project;
|
|
1937
2595
|
issues;
|
|
1938
2596
|
selection = [];
|
|
@@ -1946,6 +2604,7 @@ var VideoEditorController = class {
|
|
|
1946
2604
|
state;
|
|
1947
2605
|
constructor(options = {}) {
|
|
1948
2606
|
this.history = options.history ?? new 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 = options.readOnly ?? false;
|
|
@@ -2162,6 +2821,10 @@ var VideoEditorController = 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);
|
|
@@ -2169,6 +2832,7 @@ var VideoEditorController = class {
|
|
|
2169
2832
|
};
|
|
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).` },
|
|
@@ -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" && p.assets.find((a) => a.id === clip.asset)?.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, 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: findVideoClip(r.project, id)?.clip.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: 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 = c.speed ?? 1;
|
|
3548
|
+
toSource = (t) => (c.in ?? 0) + (t - c.start) * speed;
|
|
3549
|
+
toOut = (s) => c.start + (s - (c.in ?? 0)) / speed;
|
|
3550
|
+
from = Math.max(c.start, args.from ?? c.start);
|
|
3551
|
+
to = Math.min(videoClipEnd(c), 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 = args.from ?? 0;
|
|
3556
|
+
to = 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), 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: 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;
|
|
@@ -2915,7 +3785,7 @@ function videoKeyframeRows(project, clipId, time) {
|
|
|
2915
3785
|
const { clip } = at;
|
|
2916
3786
|
const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
|
|
2917
3787
|
const visual = !(asset?.kind === "audio" || at.track.kind === "audio");
|
|
2918
|
-
const audible = clip.type === "media" && (asset
|
|
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) => {
|
|
@@ -2967,40 +3837,63 @@ function videoKeyframeToggleEdit(project, clipId, property, time) {
|
|
|
2967
3837
|
return { op: "set_keyframes", clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };
|
|
2968
3838
|
}
|
|
2969
3839
|
export {
|
|
3840
|
+
VIDEO_BLEND_MODES,
|
|
3841
|
+
VIDEO_BLEND_MODE_LABELS,
|
|
3842
|
+
VIDEO_CLIP_ANIMATION_DURATION,
|
|
3843
|
+
VIDEO_CLIP_ANIMATION_EFFECTS,
|
|
3844
|
+
VIDEO_CLIP_ANIMATION_LABELS,
|
|
2970
3845
|
VIDEO_CLIP_TYPES,
|
|
2971
3846
|
VIDEO_DEFAULT_STILL_DURATION,
|
|
2972
3847
|
VIDEO_DEFAULT_TITLE_DURATION,
|
|
2973
3848
|
VIDEO_EASE_NAMES,
|
|
2974
3849
|
VIDEO_EDIT_OPS,
|
|
3850
|
+
VIDEO_FADE_CURVES,
|
|
3851
|
+
VIDEO_FADE_CURVE_LABELS,
|
|
2975
3852
|
VIDEO_FITS,
|
|
3853
|
+
VIDEO_GRADE_CONTROLS,
|
|
3854
|
+
VIDEO_GRADE_PRESETS,
|
|
3855
|
+
VIDEO_GRADE_PRESET_NAMES,
|
|
2976
3856
|
VIDEO_ISSUE_CODES,
|
|
2977
3857
|
VIDEO_KEYFRAME_LIMITS,
|
|
2978
3858
|
VIDEO_KEYFRAME_PROPERTIES,
|
|
3859
|
+
VIDEO_PEAK_BLOCK,
|
|
2979
3860
|
VIDEO_PLACEMENT_MODES,
|
|
2980
3861
|
VIDEO_TIME_EPSILON,
|
|
2981
3862
|
VIDEO_TRANSFORM_PROPERTIES,
|
|
3863
|
+
VIDEO_TRANSITION_LABELS,
|
|
2982
3864
|
VIDEO_TRANSITION_TYPES,
|
|
2983
3865
|
VIDEO_WIPE_DIRECTIONS,
|
|
3866
|
+
VideoAudioPeaksCache,
|
|
2984
3867
|
VideoEditorController,
|
|
2985
3868
|
VideoPreviewEngine,
|
|
2986
3869
|
allVideoClipIds,
|
|
2987
3870
|
applyVideoEdits,
|
|
3871
|
+
applyVideoGrade,
|
|
3872
|
+
buildVideoAudioPeaks,
|
|
2988
3873
|
checkVideoProject,
|
|
2989
3874
|
createVideoEditorToolProvider,
|
|
2990
3875
|
createVideoProject,
|
|
3876
|
+
decodeVideoAudioPeaks,
|
|
2991
3877
|
describeVideoProject,
|
|
2992
3878
|
drawVideoFrame,
|
|
2993
3879
|
ffmpegFilterValue,
|
|
3880
|
+
findVideoAudioSilences,
|
|
2994
3881
|
findVideoClip,
|
|
2995
3882
|
findVideoGaps,
|
|
2996
3883
|
formatVideoSeconds,
|
|
2997
3884
|
formatVideoTimecode,
|
|
3885
|
+
formatVideoTimecodeFrames,
|
|
3886
|
+
isVideoDropFrameRate,
|
|
2998
3887
|
isVideoKeyframes,
|
|
2999
3888
|
nextFreeVideoSlot,
|
|
3889
|
+
normalizeVideoGrade,
|
|
3000
3890
|
parseVideoAnimatable,
|
|
3001
3891
|
parseVideoAsset,
|
|
3002
3892
|
parseVideoClip,
|
|
3893
|
+
parseVideoClipAnimation,
|
|
3894
|
+
parseVideoGrade,
|
|
3003
3895
|
parseVideoKeyframes,
|
|
3896
|
+
parseVideoLoudnessMeasurement,
|
|
3004
3897
|
parseVideoMarker,
|
|
3005
3898
|
parseVideoProject,
|
|
3006
3899
|
parseVideoTrack,
|
|
@@ -3019,29 +3912,54 @@ export {
|
|
|
3019
3912
|
splitVideoAnimatable,
|
|
3020
3913
|
toVideoEdl,
|
|
3021
3914
|
toVideoFfmpeg,
|
|
3915
|
+
toVideoLoudnessMeasure,
|
|
3022
3916
|
videoAssetDropEdit,
|
|
3917
|
+
videoAssetHasSound,
|
|
3023
3918
|
videoAssetKindOf,
|
|
3024
3919
|
videoAssetMap,
|
|
3920
|
+
videoAudioLevels,
|
|
3921
|
+
videoAudioPeakRange,
|
|
3922
|
+
videoAudioPeakSpans,
|
|
3923
|
+
videoAudioPeaksDuration,
|
|
3025
3924
|
videoClipAfter,
|
|
3925
|
+
videoClipAnimated,
|
|
3926
|
+
videoClipAnimationProperties,
|
|
3927
|
+
videoClipAnimationSpans,
|
|
3026
3928
|
videoClipBefore,
|
|
3027
3929
|
videoClipEnd,
|
|
3028
3930
|
videoClipHandles,
|
|
3029
3931
|
videoClipKeyframes,
|
|
3030
3932
|
videoClipLabel,
|
|
3031
3933
|
videoClipOverhang,
|
|
3934
|
+
videoClipShowsWaveform,
|
|
3935
|
+
videoClipSoundGain,
|
|
3032
3936
|
videoClipSourceRange,
|
|
3033
3937
|
videoClipSpeed,
|
|
3034
3938
|
videoClipTint,
|
|
3939
|
+
videoClipWaveform,
|
|
3940
|
+
videoClipWaveformPath,
|
|
3035
3941
|
videoClipsAt,
|
|
3036
3942
|
videoContactSheetSvg,
|
|
3037
3943
|
videoDragLabel,
|
|
3038
3944
|
videoDropTracks,
|
|
3039
3945
|
videoEaseBezier,
|
|
3040
3946
|
videoEaseProgress,
|
|
3947
|
+
videoFadeCurveGain,
|
|
3041
3948
|
videoFrameAt,
|
|
3042
3949
|
videoFrameDuration,
|
|
3043
3950
|
videoFrameToSvg,
|
|
3044
3951
|
videoFrames,
|
|
3952
|
+
videoGradeCssFilter,
|
|
3953
|
+
videoGradeFfmpegFilters,
|
|
3954
|
+
videoGradeGainsFilterId,
|
|
3955
|
+
videoGradePreset,
|
|
3956
|
+
videoGradeStepMatrix,
|
|
3957
|
+
videoGradeStepSvgValues,
|
|
3958
|
+
videoGradeSteps,
|
|
3959
|
+
videoGradeSvgDefs,
|
|
3960
|
+
videoGradeSvgFilter,
|
|
3961
|
+
videoHourFrames,
|
|
3962
|
+
videoIrisRadius,
|
|
3045
3963
|
videoIssueKey,
|
|
3046
3964
|
videoKeyframeBreakpoints,
|
|
3047
3965
|
videoKeyframeFfmpegExpr,
|
|
@@ -3050,15 +3968,21 @@ export {
|
|
|
3050
3968
|
videoKeyframeValueEdit,
|
|
3051
3969
|
videoKeysOf,
|
|
3052
3970
|
videoOverlap,
|
|
3971
|
+
videoPeakDb,
|
|
3053
3972
|
videoProjectDuration,
|
|
3054
3973
|
videoProjectSvg,
|
|
3055
3974
|
videoRulerTicks,
|
|
3056
3975
|
videoSnapPoints,
|
|
3057
3976
|
videoTextStyle,
|
|
3977
|
+
videoTimecodeFrames,
|
|
3058
3978
|
videoTitleFrame,
|
|
3059
3979
|
videoTrackSyncLocked,
|
|
3060
3980
|
videoTransformAt,
|
|
3981
|
+
videoTransitionAudioCurve,
|
|
3982
|
+
videoTransitionEase,
|
|
3983
|
+
videoTransitionHasDirection,
|
|
3061
3984
|
videoValueAt,
|
|
3985
|
+
videoWaveformPath,
|
|
3062
3986
|
videoWipeRect
|
|
3063
3987
|
};
|
|
3064
3988
|
//# sourceMappingURL=index.js.map
|