@pixerate/editor 0.83.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 +556 -90
- package/dist/video-editor/index.cjs.map +1 -1
- package/dist/video-editor/index.d.cts +247 -8
- package/dist/video-editor/index.d.ts +247 -8
- package/dist/video-editor/index.js +503 -37
- package/dist/video-editor/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/pixerate-video-editor/SKILL.md +24 -6
|
@@ -13,8 +13,11 @@ 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"];
|
|
18
21
|
var VIDEO_FADE_CURVES = ["linear", "equal-power", "s-curve", "exponential", "logarithmic"];
|
|
19
22
|
var VIDEO_PLACEMENT_MODES = ["refuse", "overwrite", "insert"];
|
|
20
23
|
var VIDEO_CLIP_TYPES = ["media", "text", "color"];
|
|
@@ -207,6 +210,136 @@ function videoKeyframeFfmpegExpr(prop, time, fallback, step) {
|
|
|
207
210
|
return `if(lt(${time},${fmt(points[0][0])}),${fmt(points[0][1])},${expr})`;
|
|
208
211
|
}
|
|
209
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
|
+
|
|
210
343
|
// src/video-editor/parse.ts
|
|
211
344
|
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
212
345
|
var fail = (path, what) => {
|
|
@@ -275,6 +408,22 @@ function parseVideoTransform(v, path) {
|
|
|
275
408
|
rotation: parseVideoAnimatable(v.rotation, `${path}.rotation`, { optional: true })
|
|
276
409
|
});
|
|
277
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
|
+
}
|
|
278
427
|
function parseVideoTransition(v, path) {
|
|
279
428
|
if (v === void 0) return void 0;
|
|
280
429
|
if (!isObject(v)) return fail(path, "must be an object { type, duration }");
|
|
@@ -282,7 +431,8 @@ function parseVideoTransition(v, path) {
|
|
|
282
431
|
type: oneOf(v.type, VIDEO_TRANSITION_TYPES, `${path}.type`, false),
|
|
283
432
|
duration: num(v.duration, `${path}.duration`, { positive: true }),
|
|
284
433
|
color: str(v.color, `${path}.color`, true),
|
|
285
|
-
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`)
|
|
286
436
|
});
|
|
287
437
|
}
|
|
288
438
|
function parseVideoAsset(v, path = "asset") {
|
|
@@ -313,6 +463,10 @@ function parseVideoClip(v, path = "clip") {
|
|
|
313
463
|
opacity: parseVideoAnimatable(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),
|
|
314
464
|
transform: parseVideoTransform(v.transform, `${path}.transform`),
|
|
315
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`),
|
|
316
470
|
fadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),
|
|
317
471
|
fadeOut: num(v.fadeOut, `${path}.fadeOut`, { optional: true, min: 0 }),
|
|
318
472
|
transition: parseVideoTransition(v.transition, `${path}.transition`)
|
|
@@ -563,6 +717,66 @@ function videoClipsAt(project, time) {
|
|
|
563
717
|
return out;
|
|
564
718
|
}
|
|
565
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
|
+
|
|
566
780
|
// src/video-editor/check.ts
|
|
567
781
|
function videoClipKeyframes(clip) {
|
|
568
782
|
const out = [
|
|
@@ -581,6 +795,8 @@ var VIDEO_ISSUE_CODES = [
|
|
|
581
795
|
"transition-too-long",
|
|
582
796
|
"transition-handles",
|
|
583
797
|
"fades-too-long",
|
|
798
|
+
"animations-too-long",
|
|
799
|
+
"animation-keyed",
|
|
584
800
|
"keyframes-outside-clip",
|
|
585
801
|
"unknown-duration",
|
|
586
802
|
"unused-asset",
|
|
@@ -617,6 +833,16 @@ function checkVideoProject(project) {
|
|
|
617
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.` });
|
|
618
834
|
if ((clip.fadeIn ?? 0) + (clip.fadeOut ?? 0) > clip.duration + VIDEO_TIME_EPSILON)
|
|
619
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
|
+
}
|
|
620
846
|
for (const { property, keys } of videoClipKeyframes(clip)) {
|
|
621
847
|
const outside = keys.filter((k) => k.t < -VIDEO_TIME_EPSILON || k.t > clip.duration + VIDEO_TIME_EPSILON);
|
|
622
848
|
if (outside.length)
|
|
@@ -730,7 +956,7 @@ function retimeClipKeys(clip, fn) {
|
|
|
730
956
|
if (isVideoKeyframes(v)) a.set(fn(v));
|
|
731
957
|
}
|
|
732
958
|
}
|
|
733
|
-
var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "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"];
|
|
734
960
|
var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
|
|
735
961
|
var Editor = class {
|
|
736
962
|
constructor(p) {
|
|
@@ -827,6 +1053,7 @@ var Editor = class {
|
|
|
827
1053
|
delete right.transition;
|
|
828
1054
|
delete right.fadeIn;
|
|
829
1055
|
delete right.fadeInCurve;
|
|
1056
|
+
delete right.animateIn;
|
|
830
1057
|
const leftValues = clipAnimatables(clip);
|
|
831
1058
|
clipAnimatables(right).forEach((r, i) => {
|
|
832
1059
|
const [l, rr] = splitVideoAnimatable(leftValues[i].get(), at - clip.start);
|
|
@@ -836,6 +1063,7 @@ var Editor = class {
|
|
|
836
1063
|
this.moveIn(right, at - clip.start);
|
|
837
1064
|
clip.duration = at - clip.start;
|
|
838
1065
|
delete clip.fadeOut;
|
|
1066
|
+
delete clip.animateOut;
|
|
839
1067
|
if (clip.type === "media") delete clip.fadeOutCurve;
|
|
840
1068
|
track.clips.push(right);
|
|
841
1069
|
track.clips.sort((a, b) => a.start - b.start);
|
|
@@ -1387,6 +1615,7 @@ function videoTitleFrame(project, clip, clipTime = 0) {
|
|
|
1387
1615
|
return { time: clip.start, width: project.width, height: project.height, background: "rgba(0,0,0,0)", layers: [layer], audio: [] };
|
|
1388
1616
|
}
|
|
1389
1617
|
var clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
1618
|
+
var videoTransitionEase = (progress) => (1 - Math.cos(Math.PI * clamp01(progress))) / 2;
|
|
1390
1619
|
function videoFadeCurveGain(curve, x) {
|
|
1391
1620
|
const t = clamp01(x);
|
|
1392
1621
|
switch (curve) {
|
|
@@ -1440,8 +1669,8 @@ function videoFrameAt(project, time) {
|
|
|
1440
1669
|
if (outgoing?.type === "dip" && outgoing.progress >= 0.5) continue;
|
|
1441
1670
|
const inside = Math.min(Math.max(time, clip.start), videoClipEnd(clip));
|
|
1442
1671
|
const fade = fadeGain(clip, inside);
|
|
1443
|
-
const level = (t, into) => t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress;
|
|
1444
|
-
const mix = (incoming ? level(incoming, true) : 1) * (outgoing ? level(outgoing, false) : 1);
|
|
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);
|
|
1445
1674
|
const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
|
|
1446
1675
|
const sourceTime = sourceTimeAt(clip, time);
|
|
1447
1676
|
const clipTime = time - clip.start;
|
|
@@ -1452,20 +1681,39 @@ function videoFrameAt(project, time) {
|
|
|
1452
1681
|
}
|
|
1453
1682
|
if (track.kind !== "video" || track.hidden) continue;
|
|
1454
1683
|
if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
|
|
1455
|
-
|
|
1684
|
+
const shown = videoClipAnimated(clip, project);
|
|
1685
|
+
let opacity = videoValueAt(shown.opacity, clipTime, 1) * fade;
|
|
1456
1686
|
let wipe;
|
|
1457
1687
|
let dip = null;
|
|
1688
|
+
let iris;
|
|
1689
|
+
let shift = 0;
|
|
1690
|
+
let shiftDirection = "right";
|
|
1458
1691
|
if (incoming) {
|
|
1459
1692
|
if (incoming.type === "crossfade") opacity *= incoming.progress;
|
|
1460
1693
|
else if (incoming.type === "wipe") wipe = { direction: incoming.transition.direction ?? "right", progress: incoming.progress };
|
|
1461
|
-
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 };
|
|
1462
1699
|
}
|
|
1463
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
|
+
}
|
|
1464
1705
|
const size = clip.type === "media" ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
|
|
1465
1706
|
const fit = clip.fit ?? (clip.type === "media" ? "contain" : "fill");
|
|
1466
|
-
const
|
|
1467
|
-
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) };
|
|
1468
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;
|
|
1469
1717
|
if (clip.type === "media") Object.assign(layer, { asset: asset.id, assetKind: asset.kind, src: asset.src, sourceTime });
|
|
1470
1718
|
if (clip.type === "text") layer.text = videoTextStyle(clip);
|
|
1471
1719
|
if (clip.type === "color") layer.color = clip.color;
|
|
@@ -1696,6 +1944,7 @@ var VideoAudioPeaksCache = class {
|
|
|
1696
1944
|
// src/video-editor/render.ts
|
|
1697
1945
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1698
1946
|
var n = (v) => String(Math.round(v * 100) / 100);
|
|
1947
|
+
var videoIrisRadius = (frame, progress) => progress * Math.hypot(frame.width / 2, frame.height / 2);
|
|
1699
1948
|
function videoWipeRect(rect, wipe) {
|
|
1700
1949
|
const { direction, progress } = wipe;
|
|
1701
1950
|
const w = rect.width * progress;
|
|
@@ -1713,6 +1962,11 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
|
1713
1962
|
const { rect } = layer;
|
|
1714
1963
|
const attrs = [];
|
|
1715
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
|
+
}
|
|
1716
1970
|
if (layer.rotation) attrs.push(`transform="rotate(${n(layer.rotation)} ${n(rect.x + rect.width / 2)} ${n(rect.y + rect.height / 2)})"`);
|
|
1717
1971
|
if (layer.wipe) {
|
|
1718
1972
|
const r = videoWipeRect(rect, layer.wipe);
|
|
@@ -1744,7 +1998,12 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
|
1744
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>`;
|
|
1745
1999
|
}
|
|
1746
2000
|
}
|
|
1747
|
-
|
|
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);
|
|
1748
2007
|
});
|
|
1749
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>`;
|
|
1750
2009
|
}
|
|
@@ -1768,7 +2027,7 @@ function videoContactSheetSvg(project, times, options = {}) {
|
|
|
1768
2027
|
});
|
|
1769
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>`;
|
|
1770
2029
|
}
|
|
1771
|
-
function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
|
|
2030
|
+
function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
|
|
1772
2031
|
ctx.save();
|
|
1773
2032
|
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
1774
2033
|
ctx.globalAlpha = 1;
|
|
@@ -1778,6 +2037,18 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
|
|
|
1778
2037
|
const { rect } = layer;
|
|
1779
2038
|
ctx.save();
|
|
1780
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
|
+
}
|
|
1781
2052
|
if (layer.rotation) {
|
|
1782
2053
|
ctx.translate(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
|
1783
2054
|
ctx.rotate(layer.rotation * Math.PI / 180);
|
|
@@ -1858,7 +2129,7 @@ function toVideoEdl(project, options = {}) {
|
|
|
1858
2129
|
const outReel = before.type === "media" ? reelName(assets.get(before.asset)) : "BL";
|
|
1859
2130
|
const at = sourceAt(before, recIn);
|
|
1860
2131
|
lines.push(row(n2, outReel, "C", at, at, recIn, recIn));
|
|
1861
|
-
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));
|
|
1862
2133
|
} else lines.push(row(n2, reel, "C", sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
|
|
1863
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))}`);
|
|
1864
2135
|
if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${asset?.name ?? asset?.src ?? clip.asset}`);
|
|
@@ -1894,6 +2165,20 @@ function parseVideoLoudnessMeasurement(output) {
|
|
|
1894
2165
|
return m;
|
|
1895
2166
|
}
|
|
1896
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
|
+
};
|
|
1897
2182
|
var AFADE_CURVES = { linear: "tri", "equal-power": "qsin", "s-curve": "hsin", exponential: "cub", logarithmic: "ipar" };
|
|
1898
2183
|
var afadeCurve = (curve) => curve && curve !== "linear" ? `:curve=${AFADE_CURVES[curve]}` : "";
|
|
1899
2184
|
var even = (v) => Math.max(2, 2 * Math.round(v / 2));
|
|
@@ -1939,6 +2224,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
1939
2224
|
let cur = "base";
|
|
1940
2225
|
let usesDrawtext = false;
|
|
1941
2226
|
const animated = /* @__PURE__ */ new Set();
|
|
2227
|
+
const blended = /* @__PURE__ */ new Set();
|
|
1942
2228
|
let k = 0;
|
|
1943
2229
|
const step = 2e3 / fps;
|
|
1944
2230
|
const expr = (prop, clipTime, fallback, what) => {
|
|
@@ -1954,7 +2240,8 @@ function buildFfmpeg(project, options, measure) {
|
|
|
1954
2240
|
return inputs.length - 1;
|
|
1955
2241
|
};
|
|
1956
2242
|
for (const track of [...project.tracks].reverse()) {
|
|
1957
|
-
for (const
|
|
2243
|
+
for (const raw of sortedVideoClips(track)) {
|
|
2244
|
+
const clip = videoClipAnimated(raw, project);
|
|
1958
2245
|
const { lead, tail } = videoClipOverhang(track, clip);
|
|
1959
2246
|
const t0 = clip.start - lead;
|
|
1960
2247
|
const span = clip.duration + lead + tail;
|
|
@@ -1973,11 +2260,13 @@ function buildFfmpeg(project, options, measure) {
|
|
|
1973
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}`;
|
|
1974
2261
|
const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), volume];
|
|
1975
2262
|
const before2 = clip.transition ? videoClipBefore(track, clip) : null;
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
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}`);
|
|
1981
2270
|
if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}${afadeCurve(clip.fadeInCurve)}`);
|
|
1982
2271
|
if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}${afadeCurve(clip.fadeOutCurve)}`);
|
|
1983
2272
|
chain2.push(`adelay=${Math.round(t0)}:all=1`);
|
|
@@ -2016,6 +2305,8 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2016
2305
|
`enable='between(t,${sec(clip.start)},${sec(end)})'`
|
|
2017
2306
|
];
|
|
2018
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.`);
|
|
2019
2310
|
graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
|
|
2020
2311
|
cur = next;
|
|
2021
2312
|
continue;
|
|
@@ -2031,10 +2322,8 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2031
2322
|
source = `[${index}:v]`;
|
|
2032
2323
|
chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
|
|
2033
2324
|
}
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
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`);
|
|
2037
|
-
}
|
|
2325
|
+
const grade = videoGradeFfmpegFilters(clip.grade);
|
|
2326
|
+
if (grade.length) chain.push("format=rgba", ...grade);
|
|
2038
2327
|
chain.push("format=yuva420p");
|
|
2039
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)'`);
|
|
2040
2329
|
else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
|
|
@@ -2048,18 +2337,50 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2048
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}))`;
|
|
2049
2338
|
chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
|
|
2050
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
|
+
}
|
|
2051
2344
|
const rotation = placed?.rotation;
|
|
2052
|
-
if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=
|
|
2053
|
-
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
|
+
}
|
|
2054
2351
|
chain.push(`setpts=PTS+${sec(t0)}/TB`);
|
|
2055
2352
|
const cx = rect.x + rect.width / 2;
|
|
2056
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);
|
|
2057
2367
|
const at = (axis, c) => {
|
|
2058
2368
|
const v = placed?.[axis];
|
|
2059
|
-
|
|
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}'`;
|
|
2060
2371
|
};
|
|
2061
2372
|
graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
|
|
2062
|
-
|
|
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}]`);
|
|
2063
2384
|
cur = next;
|
|
2064
2385
|
const before = clip.transition ? videoClipBefore(track, clip) : null;
|
|
2065
2386
|
if (before && clip.transition.type === "dip") {
|
|
@@ -2082,6 +2403,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2082
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.');
|
|
2083
2404
|
if (animated.size)
|
|
2084
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.");
|
|
2085
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.");
|
|
2086
2408
|
const filterComplex = graph.join(";");
|
|
2087
2409
|
if (measure) {
|
|
@@ -2129,6 +2451,8 @@ var VideoPreviewEngine = class {
|
|
|
2129
2451
|
unsubscribe;
|
|
2130
2452
|
scheduled = false;
|
|
2131
2453
|
destroyed = false;
|
|
2454
|
+
filterHost = null;
|
|
2455
|
+
filters = /* @__PURE__ */ new Set();
|
|
2132
2456
|
createMedia(kind) {
|
|
2133
2457
|
if (this.options.createMedia) return this.options.createMedia(kind);
|
|
2134
2458
|
const el = document.createElement(kind);
|
|
@@ -2230,12 +2554,31 @@ var VideoPreviewEngine = class {
|
|
|
2230
2554
|
const h = Math.max(1, Math.round(frame.height * resolution));
|
|
2231
2555
|
if (this.canvas.width !== w) this.canvas.width = w;
|
|
2232
2556
|
if (this.canvas.height !== h) this.canvas.height = h;
|
|
2233
|
-
drawVideoFrame(this.ctx, frame, this.sourceFor, resolution);
|
|
2557
|
+
drawVideoFrame(this.ctx, frame, this.sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
|
|
2234
2558
|
this.options.onFrame?.(frame);
|
|
2235
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
|
+
};
|
|
2236
2576
|
/** Stops following the editor and releases every media element. */
|
|
2237
2577
|
destroy() {
|
|
2238
2578
|
this.destroyed = true;
|
|
2579
|
+
this.filterHost?.remove();
|
|
2580
|
+
this.filterHost = null;
|
|
2581
|
+
this.filters.clear();
|
|
2239
2582
|
this.unsubscribe();
|
|
2240
2583
|
for (const entry of [...this.media.values()]) this.release(entry);
|
|
2241
2584
|
this.images.clear();
|
|
@@ -2529,19 +2872,37 @@ var TRANSFORM = object(
|
|
|
2529
2872
|
);
|
|
2530
2873
|
var TRANSITION = object(
|
|
2531
2874
|
{
|
|
2532
|
-
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)
|
|
2533
|
-
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." },
|
|
2534
2877
|
color: COLOR("For dip: the colour passed through (default black)"),
|
|
2535
|
-
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." }
|
|
2536
2880
|
},
|
|
2537
2881
|
["type", "duration"],
|
|
2538
2882
|
"A transition from the clip that ends where this one starts on the same track."
|
|
2539
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
|
+
);
|
|
2540
2897
|
var CLIP_COMMON = {
|
|
2541
2898
|
name: { type: "string", description: "A label for the clip." },
|
|
2542
2899
|
opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1). A number replaces any keyframes; set_video_keyframes animates it." },
|
|
2543
2900
|
transform: TRANSFORM,
|
|
2544
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." },
|
|
2545
2906
|
fadeIn: MS("Fade up from nothing (picture and sound) over this long"),
|
|
2546
2907
|
fadeOut: MS("Fade down to nothing over this long")
|
|
2547
2908
|
};
|
|
@@ -2702,7 +3063,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2702
3063
|
const getFrame = {
|
|
2703
3064
|
name: "get_video_frame",
|
|
2704
3065
|
title: "What plays at a moment",
|
|
2705
|
-
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).",
|
|
2706
3067
|
inputSchema: object({ time: MS("Time from the start (default: the playhead)") }),
|
|
2707
3068
|
annotations: { title: "What plays at a moment", ...READ },
|
|
2708
3069
|
handler: ({ time }) => {
|
|
@@ -2724,7 +3085,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2724
3085
|
const checkProject = {
|
|
2725
3086
|
name: "check_video_project",
|
|
2726
3087
|
title: "Check the video project",
|
|
2727
|
-
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.",
|
|
2728
3089
|
inputSchema: object({}),
|
|
2729
3090
|
annotations: { title: "Check the video project", ...READ },
|
|
2730
3091
|
handler: () => {
|
|
@@ -2956,12 +3317,12 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2956
3317
|
const updateClip = {
|
|
2957
3318
|
name: "update_video_clip",
|
|
2958
3319
|
title: "Change a clip",
|
|
2959
|
-
description: `Changes a clip's properties (one undo step): opacity, transform, fit, 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.`,
|
|
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.`,
|
|
2960
3321
|
inputSchema: object(
|
|
2961
3322
|
{
|
|
2962
3323
|
clip: CLIP_ID,
|
|
2963
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)."),
|
|
2964
|
-
remove: { type: "array", items: { type: "string", enum: ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "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." }
|
|
2965
3326
|
},
|
|
2966
3327
|
["clip"]
|
|
2967
3328
|
),
|
|
@@ -2969,17 +3330,60 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2969
3330
|
handler: ({ clip, set, remove }) => {
|
|
2970
3331
|
const current = clipOut(project(), clip);
|
|
2971
3332
|
if (current && set) {
|
|
2972
|
-
const allowed = /* @__PURE__ */ new Set(["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", ...current.type === "media" ? ["in", "speed", "volume", "muted", "asset", "fadeInCurve", "fadeOutCurve"] : 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"]]);
|
|
2973
3334
|
const wrong = Object.keys(set).filter((k) => !allowed.has(k));
|
|
2974
3335
|
if (wrong.length) throw new Error(`${wrong.map((k) => `"${k}"`).join(", ")} cannot be set on a ${current.type} clip; it takes ${[...allowed].join(", ")}.`);
|
|
2975
3336
|
}
|
|
2976
3337
|
return apply({ op: "update_clip", clip, set, remove }, "Change clip", (r) => ({ clip: clipOut(r.project, clip) }));
|
|
2977
3338
|
}
|
|
2978
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
|
+
};
|
|
2979
3383
|
const setTransition = {
|
|
2980
3384
|
name: "set_video_transition",
|
|
2981
3385
|
title: "Set a transition",
|
|
2982
|
-
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.",
|
|
2983
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"]),
|
|
2984
3388
|
annotations: { title: "Set a transition", ...WRITE_IDEMPOTENT },
|
|
2985
3389
|
handler: ({ clip, transition }) => apply({ op: "set_transition", clip, transition }, transition ? "Set transition" : "Remove transition", (r) => ({ clip: clipOut(r.project, clip) }))
|
|
@@ -3194,7 +3598,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
3194
3598
|
}
|
|
3195
3599
|
};
|
|
3196
3600
|
const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
|
|
3197
|
-
const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
|
|
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];
|
|
3198
3602
|
return {
|
|
3199
3603
|
id: options.id ?? "video",
|
|
3200
3604
|
namespace: options.namespace,
|
|
@@ -3230,6 +3634,39 @@ function videoClipShowsWaveform(clip, asset, mode = "audio") {
|
|
|
3230
3634
|
if (clip.type !== "media" || mode === "none" || !videoAssetHasSound(asset)) return false;
|
|
3231
3635
|
return mode === "all" || asset.kind === "audio";
|
|
3232
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
|
+
};
|
|
3233
3670
|
var VIDEO_FADE_CURVE_LABELS = {
|
|
3234
3671
|
linear: "Linear",
|
|
3235
3672
|
"equal-power": "Equal power",
|
|
@@ -3400,6 +3837,11 @@ function videoKeyframeToggleEdit(project, clipId, property, time) {
|
|
|
3400
3837
|
return { op: "set_keyframes", clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };
|
|
3401
3838
|
}
|
|
3402
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,
|
|
3403
3845
|
VIDEO_CLIP_TYPES,
|
|
3404
3846
|
VIDEO_DEFAULT_STILL_DURATION,
|
|
3405
3847
|
VIDEO_DEFAULT_TITLE_DURATION,
|
|
@@ -3408,6 +3850,9 @@ export {
|
|
|
3408
3850
|
VIDEO_FADE_CURVES,
|
|
3409
3851
|
VIDEO_FADE_CURVE_LABELS,
|
|
3410
3852
|
VIDEO_FITS,
|
|
3853
|
+
VIDEO_GRADE_CONTROLS,
|
|
3854
|
+
VIDEO_GRADE_PRESETS,
|
|
3855
|
+
VIDEO_GRADE_PRESET_NAMES,
|
|
3411
3856
|
VIDEO_ISSUE_CODES,
|
|
3412
3857
|
VIDEO_KEYFRAME_LIMITS,
|
|
3413
3858
|
VIDEO_KEYFRAME_PROPERTIES,
|
|
@@ -3415,6 +3860,7 @@ export {
|
|
|
3415
3860
|
VIDEO_PLACEMENT_MODES,
|
|
3416
3861
|
VIDEO_TIME_EPSILON,
|
|
3417
3862
|
VIDEO_TRANSFORM_PROPERTIES,
|
|
3863
|
+
VIDEO_TRANSITION_LABELS,
|
|
3418
3864
|
VIDEO_TRANSITION_TYPES,
|
|
3419
3865
|
VIDEO_WIPE_DIRECTIONS,
|
|
3420
3866
|
VideoAudioPeaksCache,
|
|
@@ -3422,6 +3868,7 @@ export {
|
|
|
3422
3868
|
VideoPreviewEngine,
|
|
3423
3869
|
allVideoClipIds,
|
|
3424
3870
|
applyVideoEdits,
|
|
3871
|
+
applyVideoGrade,
|
|
3425
3872
|
buildVideoAudioPeaks,
|
|
3426
3873
|
checkVideoProject,
|
|
3427
3874
|
createVideoEditorToolProvider,
|
|
@@ -3439,9 +3886,12 @@ export {
|
|
|
3439
3886
|
isVideoDropFrameRate,
|
|
3440
3887
|
isVideoKeyframes,
|
|
3441
3888
|
nextFreeVideoSlot,
|
|
3889
|
+
normalizeVideoGrade,
|
|
3442
3890
|
parseVideoAnimatable,
|
|
3443
3891
|
parseVideoAsset,
|
|
3444
3892
|
parseVideoClip,
|
|
3893
|
+
parseVideoClipAnimation,
|
|
3894
|
+
parseVideoGrade,
|
|
3445
3895
|
parseVideoKeyframes,
|
|
3446
3896
|
parseVideoLoudnessMeasurement,
|
|
3447
3897
|
parseVideoMarker,
|
|
@@ -3472,6 +3922,9 @@ export {
|
|
|
3472
3922
|
videoAudioPeakSpans,
|
|
3473
3923
|
videoAudioPeaksDuration,
|
|
3474
3924
|
videoClipAfter,
|
|
3925
|
+
videoClipAnimated,
|
|
3926
|
+
videoClipAnimationProperties,
|
|
3927
|
+
videoClipAnimationSpans,
|
|
3475
3928
|
videoClipBefore,
|
|
3476
3929
|
videoClipEnd,
|
|
3477
3930
|
videoClipHandles,
|
|
@@ -3496,7 +3949,17 @@ export {
|
|
|
3496
3949
|
videoFrameDuration,
|
|
3497
3950
|
videoFrameToSvg,
|
|
3498
3951
|
videoFrames,
|
|
3952
|
+
videoGradeCssFilter,
|
|
3953
|
+
videoGradeFfmpegFilters,
|
|
3954
|
+
videoGradeGainsFilterId,
|
|
3955
|
+
videoGradePreset,
|
|
3956
|
+
videoGradeStepMatrix,
|
|
3957
|
+
videoGradeStepSvgValues,
|
|
3958
|
+
videoGradeSteps,
|
|
3959
|
+
videoGradeSvgDefs,
|
|
3960
|
+
videoGradeSvgFilter,
|
|
3499
3961
|
videoHourFrames,
|
|
3962
|
+
videoIrisRadius,
|
|
3500
3963
|
videoIssueKey,
|
|
3501
3964
|
videoKeyframeBreakpoints,
|
|
3502
3965
|
videoKeyframeFfmpegExpr,
|
|
@@ -3515,6 +3978,9 @@ export {
|
|
|
3515
3978
|
videoTitleFrame,
|
|
3516
3979
|
videoTrackSyncLocked,
|
|
3517
3980
|
videoTransformAt,
|
|
3981
|
+
videoTransitionAudioCurve,
|
|
3982
|
+
videoTransitionEase,
|
|
3983
|
+
videoTransitionHasDirection,
|
|
3518
3984
|
videoValueAt,
|
|
3519
3985
|
videoWaveformPath,
|
|
3520
3986
|
videoWipeRect
|