@pixerate/editor 0.83.0 → 0.85.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 +865 -94
- package/dist/video-editor/index.cjs.map +1 -1
- package/dist/video-editor/index.d.cts +364 -9
- package/dist/video-editor/index.d.ts +364 -9
- package/dist/video-editor/index.js +814 -43
- package/dist/video-editor/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/pixerate-video-editor/SKILL.md +44 -9
|
@@ -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,9 +431,22 @@ 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
|
}
|
|
438
|
+
function parseVideoTranscriptWords(v, path) {
|
|
439
|
+
if (v === void 0) return void 0;
|
|
440
|
+
if (!Array.isArray(v)) return fail(path, "must be an array of { word, start, end } (ms in the source)");
|
|
441
|
+
const words = v.map((w, i) => {
|
|
442
|
+
if (!isObject(w)) return fail(`${path}[${i}]`, "must be an object { word, start, end }");
|
|
443
|
+
const word = str(w.word, `${path}[${i}].word`);
|
|
444
|
+
const start = num(w.start, `${path}[${i}].start`, { min: 0 });
|
|
445
|
+
const end = num(w.end, `${path}[${i}].end`, { min: start });
|
|
446
|
+
return { word, start, end };
|
|
447
|
+
});
|
|
448
|
+
return words.sort((a, b) => a.start - b.start);
|
|
449
|
+
}
|
|
288
450
|
function parseVideoAsset(v, path = "asset") {
|
|
289
451
|
if (!isObject(v)) return fail(path, "must be an object");
|
|
290
452
|
const kind = oneOf(v.kind, ["video", "audio", "image"], `${path}.kind`, false);
|
|
@@ -297,8 +459,10 @@ function parseVideoAsset(v, path = "asset") {
|
|
|
297
459
|
width: num(v.width, `${path}.width`, { optional: true, positive: true }),
|
|
298
460
|
height: num(v.height, `${path}.height`, { optional: true, positive: true }),
|
|
299
461
|
hasAudio: bool(v.hasAudio, `${path}.hasAudio`),
|
|
300
|
-
poster: str(v.poster, `${path}.poster`, true)
|
|
462
|
+
poster: str(v.poster, `${path}.poster`, true),
|
|
463
|
+
transcript: parseVideoTranscriptWords(v.transcript, `${path}.transcript`)
|
|
301
464
|
});
|
|
465
|
+
if (kind === "image" && asset.transcript) fail(`${path}.transcript`, "is only for video and audio assets");
|
|
302
466
|
if (kind === "image" && asset.duration !== void 0) fail(`${path}.duration`, "is only for video and audio assets");
|
|
303
467
|
return asset;
|
|
304
468
|
}
|
|
@@ -313,6 +477,10 @@ function parseVideoClip(v, path = "clip") {
|
|
|
313
477
|
opacity: parseVideoAnimatable(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),
|
|
314
478
|
transform: parseVideoTransform(v.transform, `${path}.transform`),
|
|
315
479
|
fit: oneOf(v.fit, VIDEO_FITS, `${path}.fit`),
|
|
480
|
+
blend: oneOf(v.blend, VIDEO_BLEND_MODES, `${path}.blend`),
|
|
481
|
+
grade: parseVideoGrade(v.grade, `${path}.grade`),
|
|
482
|
+
animateIn: parseVideoClipAnimation(v.animateIn, `${path}.animateIn`),
|
|
483
|
+
animateOut: parseVideoClipAnimation(v.animateOut, `${path}.animateOut`),
|
|
316
484
|
fadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),
|
|
317
485
|
fadeOut: num(v.fadeOut, `${path}.fadeOut`, { optional: true, min: 0 }),
|
|
318
486
|
transition: parseVideoTransition(v.transition, `${path}.transition`)
|
|
@@ -563,6 +731,66 @@ function videoClipsAt(project, time) {
|
|
|
563
731
|
return out;
|
|
564
732
|
}
|
|
565
733
|
|
|
734
|
+
// src/video-editor/clip-animation.ts
|
|
735
|
+
var VIDEO_CLIP_ANIMATION_DURATION = 500;
|
|
736
|
+
var EFFECTS = {
|
|
737
|
+
fade: { easeIn: "linear", easeOut: "linear" },
|
|
738
|
+
rise: { y: 0.06, easeIn: "ease-out", easeOut: "ease-in" },
|
|
739
|
+
drop: { y: -0.06, easeIn: "ease-out", easeOut: "ease-in" },
|
|
740
|
+
"slide-left": { x: 0.15, easeIn: "ease-out", easeOut: "ease-in" },
|
|
741
|
+
"slide-right": { x: -0.15, easeIn: "ease-out", easeOut: "ease-in" },
|
|
742
|
+
zoom: { scale: 0.6, easeIn: "ease-out", easeOut: "ease-in" },
|
|
743
|
+
// Overshoots past full size, then settles (a "back" curve).
|
|
744
|
+
pop: { scale: 0.3, easeIn: [0.34, 1.56, 0.64, 1], easeOut: [0.36, 0, 0.66, -0.56] }
|
|
745
|
+
};
|
|
746
|
+
var PROPS = ["opacity", "x", "y", "scale"];
|
|
747
|
+
function videoClipAnimationProperties(animation) {
|
|
748
|
+
const spec = EFFECTS[animation.effect];
|
|
749
|
+
return PROPS.filter((p) => p === "opacity" || spec[p] !== void 0);
|
|
750
|
+
}
|
|
751
|
+
function videoClipAnimationSpans(clip) {
|
|
752
|
+
const a = clip.animateIn ? clip.animateIn.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0;
|
|
753
|
+
const b = clip.animateOut ? clip.animateOut.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0;
|
|
754
|
+
const k = a + b > clip.duration ? clip.duration / (a + b) : 1;
|
|
755
|
+
return { in: a * k, out: b * k };
|
|
756
|
+
}
|
|
757
|
+
function videoClipAnimated(clip, project) {
|
|
758
|
+
if (!clip.animateIn && !clip.animateOut) return clip;
|
|
759
|
+
const spans = videoClipAnimationSpans(clip);
|
|
760
|
+
const out = { ...clip, transform: { ...clip.transform } };
|
|
761
|
+
for (const prop of PROPS) {
|
|
762
|
+
const current = prop === "opacity" ? clip.opacity : clip.transform?.[prop];
|
|
763
|
+
if (isVideoKeyframes(current)) continue;
|
|
764
|
+
const base = current ?? (prop === "opacity" || prop === "scale" ? 1 : 0);
|
|
765
|
+
const away = (spec) => {
|
|
766
|
+
if (prop === "opacity") return 0;
|
|
767
|
+
const v = spec[prop];
|
|
768
|
+
if (v === void 0) return void 0;
|
|
769
|
+
return prop === "scale" ? base * v : base + v * (prop === "x" ? project.width : project.height);
|
|
770
|
+
};
|
|
771
|
+
const keys = [];
|
|
772
|
+
if (clip.animateIn) {
|
|
773
|
+
const spec = EFFECTS[clip.animateIn.effect];
|
|
774
|
+
const from = away(spec);
|
|
775
|
+
if (from !== void 0) keys.push({ t: 0, v: from, ease: spec.easeIn }, { t: spans.in, v: base });
|
|
776
|
+
}
|
|
777
|
+
if (clip.animateOut) {
|
|
778
|
+
const spec = EFFECTS[clip.animateOut.effect];
|
|
779
|
+
const to = away(spec);
|
|
780
|
+
if (to !== void 0) {
|
|
781
|
+
const start = clip.duration - spans.out;
|
|
782
|
+
if (!keys.length || keys[keys.length - 1].t < start) keys.push({ t: start, v: base, ease: spec.easeOut });
|
|
783
|
+
else keys[keys.length - 1] = { ...keys[keys.length - 1], ease: spec.easeOut };
|
|
784
|
+
keys.push({ t: clip.duration, v: to });
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (!keys.length) continue;
|
|
788
|
+
if (prop === "opacity") out.opacity = { keys };
|
|
789
|
+
else out.transform[prop] = { keys };
|
|
790
|
+
}
|
|
791
|
+
return out;
|
|
792
|
+
}
|
|
793
|
+
|
|
566
794
|
// src/video-editor/check.ts
|
|
567
795
|
function videoClipKeyframes(clip) {
|
|
568
796
|
const out = [
|
|
@@ -581,6 +809,8 @@ var VIDEO_ISSUE_CODES = [
|
|
|
581
809
|
"transition-too-long",
|
|
582
810
|
"transition-handles",
|
|
583
811
|
"fades-too-long",
|
|
812
|
+
"animations-too-long",
|
|
813
|
+
"animation-keyed",
|
|
584
814
|
"keyframes-outside-clip",
|
|
585
815
|
"unknown-duration",
|
|
586
816
|
"unused-asset",
|
|
@@ -617,6 +847,16 @@ function checkVideoProject(project) {
|
|
|
617
847
|
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
848
|
if ((clip.fadeIn ?? 0) + (clip.fadeOut ?? 0) > clip.duration + VIDEO_TIME_EPSILON)
|
|
619
849
|
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.` });
|
|
850
|
+
const anim = (clip.animateIn ? clip.animateIn.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0) + (clip.animateOut ? clip.animateOut.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0);
|
|
851
|
+
if (anim > clip.duration + VIDEO_TIME_EPSILON)
|
|
852
|
+
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.` });
|
|
853
|
+
for (const which of ["animateIn", "animateOut"]) {
|
|
854
|
+
const a = clip[which];
|
|
855
|
+
if (!a) continue;
|
|
856
|
+
const keyed = videoClipAnimationProperties(a).filter((p) => videoKeysOf(p === "opacity" ? clip.opacity : clip.transform?.[p]).length);
|
|
857
|
+
if (keyed.length)
|
|
858
|
+
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.` });
|
|
859
|
+
}
|
|
620
860
|
for (const { property, keys } of videoClipKeyframes(clip)) {
|
|
621
861
|
const outside = keys.filter((k) => k.t < -VIDEO_TIME_EPSILON || k.t > clip.duration + VIDEO_TIME_EPSILON);
|
|
622
862
|
if (outside.length)
|
|
@@ -730,7 +970,7 @@ function retimeClipKeys(clip, fn) {
|
|
|
730
970
|
if (isVideoKeyframes(v)) a.set(fn(v));
|
|
731
971
|
}
|
|
732
972
|
}
|
|
733
|
-
var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
|
|
973
|
+
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
974
|
var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
|
|
735
975
|
var Editor = class {
|
|
736
976
|
constructor(p) {
|
|
@@ -827,6 +1067,7 @@ var Editor = class {
|
|
|
827
1067
|
delete right.transition;
|
|
828
1068
|
delete right.fadeIn;
|
|
829
1069
|
delete right.fadeInCurve;
|
|
1070
|
+
delete right.animateIn;
|
|
830
1071
|
const leftValues = clipAnimatables(clip);
|
|
831
1072
|
clipAnimatables(right).forEach((r, i) => {
|
|
832
1073
|
const [l, rr] = splitVideoAnimatable(leftValues[i].get(), at - clip.start);
|
|
@@ -836,6 +1077,7 @@ var Editor = class {
|
|
|
836
1077
|
this.moveIn(right, at - clip.start);
|
|
837
1078
|
clip.duration = at - clip.start;
|
|
838
1079
|
delete clip.fadeOut;
|
|
1080
|
+
delete clip.animateOut;
|
|
839
1081
|
if (clip.type === "media") delete clip.fadeOutCurve;
|
|
840
1082
|
track.clips.push(right);
|
|
841
1083
|
track.clips.sort((a, b) => a.start - b.start);
|
|
@@ -1387,6 +1629,7 @@ function videoTitleFrame(project, clip, clipTime = 0) {
|
|
|
1387
1629
|
return { time: clip.start, width: project.width, height: project.height, background: "rgba(0,0,0,0)", layers: [layer], audio: [] };
|
|
1388
1630
|
}
|
|
1389
1631
|
var clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
1632
|
+
var videoTransitionEase = (progress) => (1 - Math.cos(Math.PI * clamp01(progress))) / 2;
|
|
1390
1633
|
function videoFadeCurveGain(curve, x) {
|
|
1391
1634
|
const t = clamp01(x);
|
|
1392
1635
|
switch (curve) {
|
|
@@ -1440,8 +1683,8 @@ function videoFrameAt(project, time) {
|
|
|
1440
1683
|
if (outgoing?.type === "dip" && outgoing.progress >= 0.5) continue;
|
|
1441
1684
|
const inside = Math.min(Math.max(time, clip.start), videoClipEnd(clip));
|
|
1442
1685
|
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);
|
|
1686
|
+
const level = (t, transition, into) => videoFadeCurveGain(videoTransitionAudioCurve(transition), t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress);
|
|
1687
|
+
const mix = (incoming ? level(incoming, incoming.transition, true) : 1) * (outgoing ? level(outgoing, outgoing.next.transition, false) : 1);
|
|
1445
1688
|
const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
|
|
1446
1689
|
const sourceTime = sourceTimeAt(clip, time);
|
|
1447
1690
|
const clipTime = time - clip.start;
|
|
@@ -1452,20 +1695,39 @@ function videoFrameAt(project, time) {
|
|
|
1452
1695
|
}
|
|
1453
1696
|
if (track.kind !== "video" || track.hidden) continue;
|
|
1454
1697
|
if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
|
|
1455
|
-
|
|
1698
|
+
const shown = videoClipAnimated(clip, project);
|
|
1699
|
+
let opacity = videoValueAt(shown.opacity, clipTime, 1) * fade;
|
|
1456
1700
|
let wipe;
|
|
1457
1701
|
let dip = null;
|
|
1702
|
+
let iris;
|
|
1703
|
+
let shift = 0;
|
|
1704
|
+
let shiftDirection = "right";
|
|
1458
1705
|
if (incoming) {
|
|
1459
1706
|
if (incoming.type === "crossfade") opacity *= incoming.progress;
|
|
1460
1707
|
else if (incoming.type === "wipe") wipe = { direction: incoming.transition.direction ?? "right", progress: incoming.progress };
|
|
1461
|
-
else
|
|
1708
|
+
else if (incoming.type === "iris") iris = { progress: incoming.progress };
|
|
1709
|
+
else if (incoming.type === "push" || incoming.type === "slide") {
|
|
1710
|
+
shiftDirection = incoming.transition.direction ?? "right";
|
|
1711
|
+
shift = videoTransitionEase(incoming.progress) - 1;
|
|
1712
|
+
} else dip = { color: incoming.transition.color ?? "#000000", opacity: (1 - incoming.progress) * 2 };
|
|
1462
1713
|
}
|
|
1463
1714
|
if (outgoing?.type === "dip") dip = { color: outgoing.next.transition.color ?? "#000000", opacity: outgoing.progress * 2 };
|
|
1715
|
+
if (outgoing?.type === "push") {
|
|
1716
|
+
shiftDirection = outgoing.next.transition.direction ?? "right";
|
|
1717
|
+
shift = videoTransitionEase(outgoing.progress);
|
|
1718
|
+
}
|
|
1464
1719
|
const size = clip.type === "media" ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
|
|
1465
1720
|
const fit = clip.fit ?? (clip.type === "media" ? "contain" : "fill");
|
|
1466
|
-
const
|
|
1467
|
-
const
|
|
1721
|
+
const placed = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, shown.transform, clipTime);
|
|
1722
|
+
const across = shiftDirection === "left" || shiftDirection === "right";
|
|
1723
|
+
const sign = shiftDirection === "left" || shiftDirection === "up" ? -1 : 1;
|
|
1724
|
+
const rect = shift ? { ...placed, ...across ? { x: placed.x + sign * shift * project.width } : { y: placed.y + sign * shift * project.height } } : placed;
|
|
1725
|
+
const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(shown.transform?.rotation, clipTime, 0) };
|
|
1468
1726
|
if (wipe) layer.wipe = wipe;
|
|
1727
|
+
if (iris) layer.iris = iris;
|
|
1728
|
+
if (clip.blend && clip.blend !== "normal") layer.blend = clip.blend;
|
|
1729
|
+
const grade = normalizeVideoGrade(clip.grade);
|
|
1730
|
+
if (grade) layer.grade = grade;
|
|
1469
1731
|
if (clip.type === "media") Object.assign(layer, { asset: asset.id, assetKind: asset.kind, src: asset.src, sourceTime });
|
|
1470
1732
|
if (clip.type === "text") layer.text = videoTextStyle(clip);
|
|
1471
1733
|
if (clip.type === "color") layer.color = clip.color;
|
|
@@ -1693,9 +1955,175 @@ var VideoAudioPeaksCache = class {
|
|
|
1693
1955
|
}
|
|
1694
1956
|
};
|
|
1695
1957
|
|
|
1958
|
+
// src/video-editor/transcript.ts
|
|
1959
|
+
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1960
|
+
function cueTime(s) {
|
|
1961
|
+
const m = s.trim().match(/^(?:(\d+):)?(\d{1,2}):(\d{1,2})[.,](\d{1,3})$/);
|
|
1962
|
+
if (!m) throw new Error(`"${s.trim()}" is not a cue time (HH:MM:SS,mmm).`);
|
|
1963
|
+
return ((Number(m[1] ?? 0) * 60 + Number(m[2])) * 60 + Number(m[3])) * 1e3 + Number(m[4].padEnd(3, "0"));
|
|
1964
|
+
}
|
|
1965
|
+
function cueWords(text, start, end) {
|
|
1966
|
+
const words = text.replace(/<[^>]+>/g, "").split(/\s+/).filter(Boolean);
|
|
1967
|
+
const step = (end - start) / Math.max(1, words.length);
|
|
1968
|
+
return words.map((word, i) => ({ word, start: Math.round(start + i * step), end: Math.round(start + (i + 1) * step) }));
|
|
1969
|
+
}
|
|
1970
|
+
function parseVideoTranscript(input) {
|
|
1971
|
+
let data = input;
|
|
1972
|
+
if (typeof input === "string") {
|
|
1973
|
+
const text = input.replace(/^/, "").trim();
|
|
1974
|
+
if (text.startsWith("{") || text.startsWith("[")) {
|
|
1975
|
+
try {
|
|
1976
|
+
data = JSON.parse(text);
|
|
1977
|
+
} catch {
|
|
1978
|
+
throw new Error("The transcript looks like JSON but is not valid JSON.");
|
|
1979
|
+
}
|
|
1980
|
+
} else {
|
|
1981
|
+
const words2 = [];
|
|
1982
|
+
for (const block of text.replace(/\r\n?/g, "\n").split(/\n{2,}/)) {
|
|
1983
|
+
const lines = block.split("\n");
|
|
1984
|
+
const at = lines.findIndex((l) => l.includes("-->"));
|
|
1985
|
+
if (at < 0) continue;
|
|
1986
|
+
const [a, b] = lines[at].split("-->");
|
|
1987
|
+
words2.push(...cueWords(lines.slice(at + 1).join(" "), cueTime(a), cueTime(b.trim().split(/\s+/)[0])));
|
|
1988
|
+
}
|
|
1989
|
+
if (!words2.length) throw new Error("No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.");
|
|
1990
|
+
return { words: words2, approximate: true };
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
const seconds = (v) => typeof v === "number" && Number.isFinite(v) ? Math.round(v * 1e3) : NaN;
|
|
1994
|
+
const fromList = (list, toMs) => list.map((w, i) => {
|
|
1995
|
+
if (!isRecord(w)) throw new Error(`Word ${i + 1} is not an object { word, start, end }.`);
|
|
1996
|
+
const word = String(w.word ?? w.text ?? "").trim();
|
|
1997
|
+
const start = toMs(w.start);
|
|
1998
|
+
const end = toMs(w.end);
|
|
1999
|
+
if (!word || !Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Word ${i + 1} needs a word and numeric start and end.`);
|
|
2000
|
+
return { word, start, end: Math.max(start, end) };
|
|
2001
|
+
});
|
|
2002
|
+
let words = [];
|
|
2003
|
+
if (Array.isArray(data)) words = fromList(data, (v) => typeof v === "number" ? v : NaN);
|
|
2004
|
+
else if (isRecord(data) && Array.isArray(data.words)) words = fromList(data.words, seconds);
|
|
2005
|
+
else if (isRecord(data) && Array.isArray(data.segments)) {
|
|
2006
|
+
const segments = data.segments.filter(isRecord);
|
|
2007
|
+
if (segments.some((s) => Array.isArray(s.words))) words = fromList(segments.flatMap((s) => Array.isArray(s.words) ? s.words : []), seconds);
|
|
2008
|
+
else {
|
|
2009
|
+
for (const s of segments) words.push(...cueWords(String(s.text ?? ""), seconds(s.start), seconds(s.end)));
|
|
2010
|
+
if (!words.length) throw new Error("No words found in the segments.");
|
|
2011
|
+
return { words, approximate: true };
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
if (!words.length) throw new Error("No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.");
|
|
2015
|
+
return { words: words.sort((a, b) => a.start - b.start), approximate: false };
|
|
2016
|
+
}
|
|
2017
|
+
function videoTranscriptTrack(project, track) {
|
|
2018
|
+
if (track !== void 0) {
|
|
2019
|
+
if (!project.tracks.some((t) => t.id === track)) throw new Error(`There is no track "${track}"; the tracks are ${project.tracks.map((t) => `"${t.id}"`).join(", ")}.`);
|
|
2020
|
+
return track;
|
|
2021
|
+
}
|
|
2022
|
+
const assets = videoAssetMap(project);
|
|
2023
|
+
const transcribed = (t) => t.clips.some((c) => c.type === "media" && assets.get(c.asset)?.transcript?.length);
|
|
2024
|
+
return (project.tracks.find((t) => t.kind === "audio" && transcribed(t)) ?? project.tracks.find(transcribed))?.id ?? null;
|
|
2025
|
+
}
|
|
2026
|
+
function videoTimelineTranscript(project, options = {}) {
|
|
2027
|
+
const id = videoTranscriptTrack(project, options.track);
|
|
2028
|
+
if (!id) return [];
|
|
2029
|
+
const track = project.tracks.find((t) => t.id === id);
|
|
2030
|
+
const assets = videoAssetMap(project);
|
|
2031
|
+
const out = [];
|
|
2032
|
+
for (const clip of sortedVideoClips(track)) {
|
|
2033
|
+
if (clip.type !== "media") continue;
|
|
2034
|
+
const words = assets.get(clip.asset)?.transcript;
|
|
2035
|
+
if (!words?.length) continue;
|
|
2036
|
+
const speed = videoClipSpeed(clip);
|
|
2037
|
+
const from = clip.in ?? 0;
|
|
2038
|
+
const to = from + clip.duration * speed;
|
|
2039
|
+
const toTimeline = (s) => Math.min(videoClipEnd(clip), Math.max(clip.start, clip.start + (s - from) / speed));
|
|
2040
|
+
words.forEach((w, index) => {
|
|
2041
|
+
const mid = (w.start + w.end) / 2;
|
|
2042
|
+
if (mid < from || mid >= to) return;
|
|
2043
|
+
out.push({ word: w.word, start: toTimeline(w.start), end: toTimeline(w.end), clip: clip.id, track: track.id, index });
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
return out;
|
|
2047
|
+
}
|
|
2048
|
+
var normalizeVideoWord = (word) => word.toLowerCase().replace(/^[^\p{L}\p{N}']+|[^\p{L}\p{N}']+$/gu, "");
|
|
2049
|
+
var rangeOf = (words, i, n2) => ({ from: words[i].start, to: words[i + n2 - 1].end, text: words.slice(i, i + n2).map((w) => w.word).join(" ") });
|
|
2050
|
+
function findVideoTranscriptText(words, query) {
|
|
2051
|
+
const q = query.split(/\s+/).map(normalizeVideoWord).filter(Boolean);
|
|
2052
|
+
if (!q.length) throw new Error("Search for at least one word.");
|
|
2053
|
+
const norm = words.map((w) => normalizeVideoWord(w.word));
|
|
2054
|
+
const out = [];
|
|
2055
|
+
for (let i = 0; i + q.length <= norm.length; i++) if (q.every((x, k) => norm[i + k] === x)) out.push(rangeOf(words, i, q.length));
|
|
2056
|
+
return out;
|
|
2057
|
+
}
|
|
2058
|
+
var VIDEO_DEFAULT_FILLERS = ["um", "umm", "uh", "uhh", "uhm", "erm", "er", "ah", "hmm", "mm", "you know", "i mean"];
|
|
2059
|
+
function findVideoFillers(words, fillers = VIDEO_DEFAULT_FILLERS) {
|
|
2060
|
+
const phrases = fillers.map((f) => f.split(/\s+/).map(normalizeVideoWord).filter(Boolean)).filter((p) => p.length).sort((a, b) => b.length - a.length);
|
|
2061
|
+
const norm = words.map((w) => normalizeVideoWord(w.word));
|
|
2062
|
+
const out = [];
|
|
2063
|
+
for (let i = 0; i < norm.length; ) {
|
|
2064
|
+
const hit = phrases.find((p) => i + p.length <= norm.length && p.every((x, k) => norm[i + k] === x));
|
|
2065
|
+
if (hit) {
|
|
2066
|
+
out.push(rangeOf(words, i, hit.length));
|
|
2067
|
+
i += hit.length;
|
|
2068
|
+
} else i++;
|
|
2069
|
+
}
|
|
2070
|
+
return out;
|
|
2071
|
+
}
|
|
2072
|
+
function findVideoTranscriptPauses(words, options = {}) {
|
|
2073
|
+
const min = options.min ?? 700;
|
|
2074
|
+
const keep = options.keep ?? 150;
|
|
2075
|
+
const out = [];
|
|
2076
|
+
for (let i = 1; i < words.length; i++) {
|
|
2077
|
+
const a = words[i - 1];
|
|
2078
|
+
const b = words[i];
|
|
2079
|
+
if (a.clip !== b.clip || b.start - a.end < min) continue;
|
|
2080
|
+
let from = a.end + keep;
|
|
2081
|
+
let to = b.start - keep;
|
|
2082
|
+
if (options.fps) {
|
|
2083
|
+
const f = 1e3 / options.fps;
|
|
2084
|
+
from = snapToVideoFrame(Math.ceil(from / f - 1e-9) * f, options.fps);
|
|
2085
|
+
to = snapToVideoFrame(Math.floor(to / f + 1e-9) * f, options.fps);
|
|
2086
|
+
}
|
|
2087
|
+
if (to - from > VIDEO_TIME_EPSILON) out.push({ from, to, text: `(pause after "${a.word}")` });
|
|
2088
|
+
}
|
|
2089
|
+
return out;
|
|
2090
|
+
}
|
|
2091
|
+
function mergeVideoRanges(ranges) {
|
|
2092
|
+
const sorted = ranges.filter((r) => r.to - r.from > VIDEO_TIME_EPSILON).map((r) => ({ from: r.from, to: r.to })).sort((a, b) => a.from - b.from);
|
|
2093
|
+
const out = [];
|
|
2094
|
+
for (const r of sorted) {
|
|
2095
|
+
const last = out[out.length - 1];
|
|
2096
|
+
if (last && r.from <= last.to + VIDEO_TIME_EPSILON) last.to = Math.max(last.to, r.to);
|
|
2097
|
+
else out.push(r);
|
|
2098
|
+
}
|
|
2099
|
+
return out;
|
|
2100
|
+
}
|
|
2101
|
+
function videoRemoveRangesEdits(ranges, tracks) {
|
|
2102
|
+
return mergeVideoRanges(ranges).reverse().map((r) => ({ op: "extract_range", from: r.from, to: r.to, ...tracks ? { tracks } : {} }));
|
|
2103
|
+
}
|
|
2104
|
+
function videoWordsRange(words, a, b) {
|
|
2105
|
+
const [i, j] = a <= b ? [a, b] : [b, a];
|
|
2106
|
+
return { from: words[i].start, to: words[j].end };
|
|
2107
|
+
}
|
|
2108
|
+
function videoTranscriptEdit(assetId, text) {
|
|
2109
|
+
return { op: "update_asset", asset: assetId, set: { transcript: parseVideoTranscript(text).words } };
|
|
2110
|
+
}
|
|
2111
|
+
function videoWordAt(words, time) {
|
|
2112
|
+
let lo = 0;
|
|
2113
|
+
let hi = words.length - 1;
|
|
2114
|
+
while (lo <= hi) {
|
|
2115
|
+
const mid = lo + hi >> 1;
|
|
2116
|
+
if (words[mid].end <= time) lo = mid + 1;
|
|
2117
|
+
else if (words[mid].start > time) hi = mid - 1;
|
|
2118
|
+
else return mid;
|
|
2119
|
+
}
|
|
2120
|
+
return -1;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
1696
2123
|
// src/video-editor/render.ts
|
|
1697
2124
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1698
2125
|
var n = (v) => String(Math.round(v * 100) / 100);
|
|
2126
|
+
var videoIrisRadius = (frame, progress) => progress * Math.hypot(frame.width / 2, frame.height / 2);
|
|
1699
2127
|
function videoWipeRect(rect, wipe) {
|
|
1700
2128
|
const { direction, progress } = wipe;
|
|
1701
2129
|
const w = rect.width * progress;
|
|
@@ -1713,6 +2141,11 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
|
1713
2141
|
const { rect } = layer;
|
|
1714
2142
|
const attrs = [];
|
|
1715
2143
|
if (layer.opacity < 1) attrs.push(`opacity="${n(layer.opacity)}"`);
|
|
2144
|
+
if (layer.blend) attrs.push(`style="mix-blend-mode:${layer.blend === "add" ? "plus-lighter" : layer.blend}"`);
|
|
2145
|
+
if (layer.grade) {
|
|
2146
|
+
defs.push(videoGradeSvgFilter(`g${i}`, layer.grade));
|
|
2147
|
+
attrs.push(`filter="url(#g${i})"`);
|
|
2148
|
+
}
|
|
1716
2149
|
if (layer.rotation) attrs.push(`transform="rotate(${n(layer.rotation)} ${n(rect.x + rect.width / 2)} ${n(rect.y + rect.height / 2)})"`);
|
|
1717
2150
|
if (layer.wipe) {
|
|
1718
2151
|
const r = videoWipeRect(rect, layer.wipe);
|
|
@@ -1744,7 +2177,12 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
|
1744
2177
|
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
2178
|
}
|
|
1746
2179
|
}
|
|
1747
|
-
|
|
2180
|
+
let item = attrs.length ? `<g ${attrs.join(" ")}>${inner}</g>` : inner;
|
|
2181
|
+
if (layer.iris) {
|
|
2182
|
+
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>`);
|
|
2183
|
+
item = `<g clip-path="url(#i${i})">${item}</g>`;
|
|
2184
|
+
}
|
|
2185
|
+
body.push(item);
|
|
1748
2186
|
});
|
|
1749
2187
|
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
2188
|
}
|
|
@@ -1768,7 +2206,7 @@ function videoContactSheetSvg(project, times, options = {}) {
|
|
|
1768
2206
|
});
|
|
1769
2207
|
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
2208
|
}
|
|
1771
|
-
function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
|
|
2209
|
+
function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
|
|
1772
2210
|
ctx.save();
|
|
1773
2211
|
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
1774
2212
|
ctx.globalAlpha = 1;
|
|
@@ -1778,6 +2216,18 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
|
|
|
1778
2216
|
const { rect } = layer;
|
|
1779
2217
|
ctx.save();
|
|
1780
2218
|
ctx.globalAlpha = layer.opacity;
|
|
2219
|
+
if (layer.iris) {
|
|
2220
|
+
ctx.beginPath();
|
|
2221
|
+
ctx.arc(frame.width / 2, frame.height / 2, videoIrisRadius(frame, layer.iris.progress), 0, 2 * Math.PI);
|
|
2222
|
+
ctx.clip();
|
|
2223
|
+
}
|
|
2224
|
+
if (layer.blend && layer.blend !== "normal") ctx.globalCompositeOperation = layer.blend === "add" ? "lighter" : layer.blend;
|
|
2225
|
+
if (layer.grade) {
|
|
2226
|
+
const defs = videoGradeSvgDefs(layer.grade);
|
|
2227
|
+
if (options.defineSvgFilter) for (const d of defs) options.defineSvgFilter(d.id, d.markup);
|
|
2228
|
+
const css = videoGradeCssFilter(layer.grade);
|
|
2229
|
+
ctx.filter = options.defineSvgFilter ? css : css.replace(/\s*url\([^)]*\)/g, "").trim() || "none";
|
|
2230
|
+
}
|
|
1781
2231
|
if (layer.rotation) {
|
|
1782
2232
|
ctx.translate(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
|
1783
2233
|
ctx.rotate(layer.rotation * Math.PI / 180);
|
|
@@ -1858,7 +2308,7 @@ function toVideoEdl(project, options = {}) {
|
|
|
1858
2308
|
const outReel = before.type === "media" ? reelName(assets.get(before.asset)) : "BL";
|
|
1859
2309
|
const at = sourceAt(before, recIn);
|
|
1860
2310
|
lines.push(row(n2, outReel, "C", at, at, recIn, recIn));
|
|
1861
|
-
lines.push(row(n2, reel, `${t.type === "
|
|
2311
|
+
lines.push(row(n2, reel, `${t.type === "crossfade" || t.type === "dip" ? "D" : "W001"} ${frames}`, sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
|
|
1862
2312
|
} else lines.push(row(n2, reel, "C", sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
|
|
1863
2313
|
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
2314
|
if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${asset?.name ?? asset?.src ?? clip.asset}`);
|
|
@@ -1894,6 +2344,20 @@ function parseVideoLoudnessMeasurement(output) {
|
|
|
1894
2344
|
return m;
|
|
1895
2345
|
}
|
|
1896
2346
|
var sec = (ms) => String(Math.round(ms) / 1e3);
|
|
2347
|
+
var FFMPEG_BLEND = {
|
|
2348
|
+
multiply: "multiply",
|
|
2349
|
+
screen: "screen",
|
|
2350
|
+
overlay: "overlay",
|
|
2351
|
+
darken: "darken",
|
|
2352
|
+
lighten: "lighten",
|
|
2353
|
+
"color-dodge": "dodge",
|
|
2354
|
+
"color-burn": "burn",
|
|
2355
|
+
"hard-light": "hardlight",
|
|
2356
|
+
"soft-light": "softlight",
|
|
2357
|
+
difference: "difference",
|
|
2358
|
+
exclusion: "exclusion",
|
|
2359
|
+
add: "addition"
|
|
2360
|
+
};
|
|
1897
2361
|
var AFADE_CURVES = { linear: "tri", "equal-power": "qsin", "s-curve": "hsin", exponential: "cub", logarithmic: "ipar" };
|
|
1898
2362
|
var afadeCurve = (curve) => curve && curve !== "linear" ? `:curve=${AFADE_CURVES[curve]}` : "";
|
|
1899
2363
|
var even = (v) => Math.max(2, 2 * Math.round(v / 2));
|
|
@@ -1939,6 +2403,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
1939
2403
|
let cur = "base";
|
|
1940
2404
|
let usesDrawtext = false;
|
|
1941
2405
|
const animated = /* @__PURE__ */ new Set();
|
|
2406
|
+
const blended = /* @__PURE__ */ new Set();
|
|
1942
2407
|
let k = 0;
|
|
1943
2408
|
const step = 2e3 / fps;
|
|
1944
2409
|
const expr = (prop, clipTime, fallback, what) => {
|
|
@@ -1954,7 +2419,8 @@ function buildFfmpeg(project, options, measure) {
|
|
|
1954
2419
|
return inputs.length - 1;
|
|
1955
2420
|
};
|
|
1956
2421
|
for (const track of [...project.tracks].reverse()) {
|
|
1957
|
-
for (const
|
|
2422
|
+
for (const raw of sortedVideoClips(track)) {
|
|
2423
|
+
const clip = videoClipAnimated(raw, project);
|
|
1958
2424
|
const { lead, tail } = videoClipOverhang(track, clip);
|
|
1959
2425
|
const t0 = clip.start - lead;
|
|
1960
2426
|
const span = clip.duration + lead + tail;
|
|
@@ -1973,11 +2439,13 @@ function buildFfmpeg(project, options, measure) {
|
|
|
1973
2439
|
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
2440
|
const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), volume];
|
|
1975
2441
|
const before2 = clip.transition ? videoClipBefore(track, clip) : null;
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
if (
|
|
2442
|
+
const next2 = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition);
|
|
2443
|
+
const inCurve = clip.transition ? afadeCurve(videoTransitionAudioCurve(clip.transition)) : "";
|
|
2444
|
+
const outCurve = next2 ? afadeCurve(videoTransitionAudioCurve(next2.transition)) : "";
|
|
2445
|
+
if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}${inCurve}`);
|
|
2446
|
+
if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}${outCurve}`);
|
|
2447
|
+
if (before2 && clip.transition.type === "dip") chain2.push(`afade=t=in:st=0:d=${sec(clip.transition.duration / 2)}${inCurve}`);
|
|
2448
|
+
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
2449
|
if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}${afadeCurve(clip.fadeInCurve)}`);
|
|
1982
2450
|
if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}${afadeCurve(clip.fadeOutCurve)}`);
|
|
1983
2451
|
chain2.push(`adelay=${Math.round(t0)}:all=1`);
|
|
@@ -2016,6 +2484,8 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2016
2484
|
`enable='between(t,${sec(clip.start)},${sec(end)})'`
|
|
2017
2485
|
];
|
|
2018
2486
|
if (tf?.rotation) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
|
|
2487
|
+
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.`);
|
|
2488
|
+
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
2489
|
graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
|
|
2020
2490
|
cur = next;
|
|
2021
2491
|
continue;
|
|
@@ -2031,10 +2501,8 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2031
2501
|
source = `[${index}:v]`;
|
|
2032
2502
|
chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
|
|
2033
2503
|
}
|
|
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
|
-
}
|
|
2504
|
+
const grade = videoGradeFfmpegFilters(clip.grade);
|
|
2505
|
+
if (grade.length) chain.push("format=rgba", ...grade);
|
|
2038
2506
|
chain.push("format=yuva420p");
|
|
2039
2507
|
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
2508
|
else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
|
|
@@ -2048,18 +2516,50 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2048
2516
|
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
2517
|
chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
|
|
2050
2518
|
}
|
|
2519
|
+
if (lead && clip.transition.type === "iris") {
|
|
2520
|
+
const r = videoIrisRadius({ width: W, height: H }, 1);
|
|
2521
|
+
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)'`);
|
|
2522
|
+
}
|
|
2051
2523
|
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=
|
|
2524
|
+
if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=black@0:ow='hypot(iw,ih)':oh=ow`);
|
|
2525
|
+
else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=black@0:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
|
|
2526
|
+
if (isVideoKeyframes(placed?.scale)) {
|
|
2527
|
+
const s = expr(placed.scale, onChain("t"), 1, "scale");
|
|
2528
|
+
chain.push(`scale=w='max(2,trunc(iw*(${s})/2)*2)':h='max(2,trunc(ih*(${s})/2)*2)':eval=frame`);
|
|
2529
|
+
}
|
|
2054
2530
|
chain.push(`setpts=PTS+${sec(t0)}/TB`);
|
|
2055
2531
|
const cx = rect.x + rect.width / 2;
|
|
2056
2532
|
const cy = rect.y + rect.height / 2;
|
|
2533
|
+
const shifts = { x: [], y: [] };
|
|
2534
|
+
const shiftBy = (direction, from, d, entering) => {
|
|
2535
|
+
const dir = direction ?? "right";
|
|
2536
|
+
const axis = dir === "left" || dir === "right" ? "x" : "y";
|
|
2537
|
+
const size2 = axis === "x" ? W : H;
|
|
2538
|
+
const sign = dir === "left" || dir === "up" ? -1 : 1;
|
|
2539
|
+
const ease = `(1-cos(PI*clip((t-${sec(from)})/${sec(d)},0,1)))/2`;
|
|
2540
|
+
shifts[axis].push(`${sign * size2}*(${ease}${entering ? "-1" : ""})`);
|
|
2541
|
+
};
|
|
2542
|
+
const into = lead ? clip.transition.type : void 0;
|
|
2543
|
+
if (into === "push" || into === "slide") shiftBy(clip.transition.direction, clip.start - lead, 2 * lead, true);
|
|
2544
|
+
const after = tail ? sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition) : void 0;
|
|
2545
|
+
if (after?.transition.type === "push") shiftBy(after.transition.direction, end - tail, 2 * tail, false);
|
|
2057
2546
|
const at = (axis, c) => {
|
|
2058
2547
|
const v = placed?.[axis];
|
|
2059
|
-
|
|
2548
|
+
const extra = shifts[axis].map((s) => `+${s}`).join("");
|
|
2549
|
+
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
2550
|
};
|
|
2061
2551
|
graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
|
|
2062
|
-
|
|
2552
|
+
if (clip.blend && clip.blend !== "normal") {
|
|
2553
|
+
graph.push(`color=c=black@0:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[t${next}]`);
|
|
2554
|
+
graph.push(`[t${next}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable},format=gbrap,split[f${next}][g${next}]`);
|
|
2555
|
+
graph.push(`[${cur}]format=gbrap,split[c${next}][d${next}]`);
|
|
2556
|
+
const layerFirst = clip.blend === "color-dodge" || clip.blend === "color-burn";
|
|
2557
|
+
graph.push(`${layerFirst ? `[f${next}][c${next}]` : `[c${next}][f${next}]`}blend=all_mode=${FFMPEG_BLEND[clip.blend]}[b${next}]`);
|
|
2558
|
+
graph.push(`[g${next}]alphaextract[m${next}]`);
|
|
2559
|
+
graph.push(`[b${next}][m${next}]alphamerge[x${next}]`);
|
|
2560
|
+
graph.push(`[d${next}][x${next}]overlay=format=gbrp,format=yuva420p[${next}]`);
|
|
2561
|
+
blended.add(clip.blend);
|
|
2562
|
+
} else graph.push(`[${cur}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable}[${next}]`);
|
|
2063
2563
|
cur = next;
|
|
2064
2564
|
const before = clip.transition ? videoClipBefore(track, clip) : null;
|
|
2065
2565
|
if (before && clip.transition.type === "dip") {
|
|
@@ -2082,6 +2582,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2082
2582
|
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
2583
|
if (animated.size)
|
|
2084
2584
|
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)" : ""}.`);
|
|
2585
|
+
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
2586
|
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
2587
|
const filterComplex = graph.join(";");
|
|
2087
2588
|
if (measure) {
|
|
@@ -2129,6 +2630,8 @@ var VideoPreviewEngine = class {
|
|
|
2129
2630
|
unsubscribe;
|
|
2130
2631
|
scheduled = false;
|
|
2131
2632
|
destroyed = false;
|
|
2633
|
+
filterHost = null;
|
|
2634
|
+
filters = /* @__PURE__ */ new Set();
|
|
2132
2635
|
createMedia(kind) {
|
|
2133
2636
|
if (this.options.createMedia) return this.options.createMedia(kind);
|
|
2134
2637
|
const el = document.createElement(kind);
|
|
@@ -2230,12 +2733,31 @@ var VideoPreviewEngine = class {
|
|
|
2230
2733
|
const h = Math.max(1, Math.round(frame.height * resolution));
|
|
2231
2734
|
if (this.canvas.width !== w) this.canvas.width = w;
|
|
2232
2735
|
if (this.canvas.height !== h) this.canvas.height = h;
|
|
2233
|
-
drawVideoFrame(this.ctx, frame, this.sourceFor, resolution);
|
|
2736
|
+
drawVideoFrame(this.ctx, frame, this.sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
|
|
2234
2737
|
this.options.onFrame?.(frame);
|
|
2235
2738
|
}
|
|
2739
|
+
defineSvgFilter = (id, markup) => {
|
|
2740
|
+
if (this.options.defineSvgFilter === null) return;
|
|
2741
|
+
if (this.options.defineSvgFilter) return this.options.defineSvgFilter(id, markup);
|
|
2742
|
+
if (this.filters.has(id) || typeof document === "undefined") return;
|
|
2743
|
+
if (!this.filterHost) {
|
|
2744
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
2745
|
+
svg.setAttribute("width", "0");
|
|
2746
|
+
svg.setAttribute("height", "0");
|
|
2747
|
+
svg.setAttribute("aria-hidden", "true");
|
|
2748
|
+
svg.setAttribute("style", "position:absolute;width:0;height:0;overflow:hidden");
|
|
2749
|
+
document.body.appendChild(svg);
|
|
2750
|
+
this.filterHost = svg;
|
|
2751
|
+
}
|
|
2752
|
+
this.filterHost.insertAdjacentHTML("beforeend", markup);
|
|
2753
|
+
this.filters.add(id);
|
|
2754
|
+
};
|
|
2236
2755
|
/** Stops following the editor and releases every media element. */
|
|
2237
2756
|
destroy() {
|
|
2238
2757
|
this.destroyed = true;
|
|
2758
|
+
this.filterHost?.remove();
|
|
2759
|
+
this.filterHost = null;
|
|
2760
|
+
this.filters.clear();
|
|
2239
2761
|
this.unsubscribe();
|
|
2240
2762
|
for (const entry of [...this.media.values()]) this.release(entry);
|
|
2241
2763
|
this.images.clear();
|
|
@@ -2529,19 +3051,37 @@ var TRANSFORM = object(
|
|
|
2529
3051
|
);
|
|
2530
3052
|
var TRANSITION = object(
|
|
2531
3053
|
{
|
|
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.
|
|
3054
|
+
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)." },
|
|
3055
|
+
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
3056
|
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)." }
|
|
3057
|
+
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)." },
|
|
3058
|
+
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
3059
|
},
|
|
2537
3060
|
["type", "duration"],
|
|
2538
3061
|
"A transition from the clip that ends where this one starts on the same track."
|
|
2539
3062
|
);
|
|
3063
|
+
var GRADE = object(
|
|
3064
|
+
Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, { type: "number", minimum: c.min, maximum: c.max, description: c.description }])),
|
|
3065
|
+
[],
|
|
3066
|
+
"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."
|
|
3067
|
+
);
|
|
3068
|
+
var ANIMATION = (when) => object(
|
|
3069
|
+
{
|
|
3070
|
+
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." },
|
|
3071
|
+
duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms (default 500)." }
|
|
3072
|
+
},
|
|
3073
|
+
["effect"],
|
|
3074
|
+
when
|
|
3075
|
+
);
|
|
2540
3076
|
var CLIP_COMMON = {
|
|
2541
3077
|
name: { type: "string", description: "A label for the clip." },
|
|
2542
3078
|
opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1). A number replaces any keyframes; set_video_keyframes animates it." },
|
|
2543
3079
|
transform: TRANSFORM,
|
|
2544
3080
|
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)." },
|
|
3081
|
+
grade: GRADE,
|
|
3082
|
+
animateIn: ANIMATION("How the picture enters, from the clip's start (on top of any fade in). Titles usually rise, pop or fade in."),
|
|
3083
|
+
animateOut: ANIMATION("How the picture leaves, ending at the clip's end."),
|
|
3084
|
+
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
3085
|
fadeIn: MS("Fade up from nothing (picture and sound) over this long"),
|
|
2546
3086
|
fadeOut: MS("Fade down to nothing over this long")
|
|
2547
3087
|
};
|
|
@@ -2630,6 +3170,10 @@ var issueSummary = (issues) => ({
|
|
|
2630
3170
|
notes: issues.filter((i) => i.severity === "note").length,
|
|
2631
3171
|
lines: issues.map((i) => `${i.severity}: ${i.message}`)
|
|
2632
3172
|
});
|
|
3173
|
+
var videoAssetOut = (a) => {
|
|
3174
|
+
const { transcript, ...rest } = a;
|
|
3175
|
+
return transcript ? { ...rest, transcriptWords: transcript.length } : rest;
|
|
3176
|
+
};
|
|
2633
3177
|
function describeVideoProject(project) {
|
|
2634
3178
|
return {
|
|
2635
3179
|
...project.name ? { name: project.name } : {},
|
|
@@ -2638,7 +3182,7 @@ function describeVideoProject(project) {
|
|
|
2638
3182
|
fps: project.fps,
|
|
2639
3183
|
duration: videoProjectDuration(project),
|
|
2640
3184
|
...project.background ? { background: project.background } : {},
|
|
2641
|
-
assets: project.assets,
|
|
3185
|
+
assets: project.assets.map(videoAssetOut),
|
|
2642
3186
|
tracks: project.tracks.map((t, index) => ({
|
|
2643
3187
|
id: t.id,
|
|
2644
3188
|
kind: t.kind,
|
|
@@ -2702,7 +3246,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2702
3246
|
const getFrame = {
|
|
2703
3247
|
name: "get_video_frame",
|
|
2704
3248
|
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).",
|
|
3249
|
+
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
3250
|
inputSchema: object({ time: MS("Time from the start (default: the playhead)") }),
|
|
2707
3251
|
annotations: { title: "What plays at a moment", ...READ },
|
|
2708
3252
|
handler: ({ time }) => {
|
|
@@ -2724,7 +3268,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2724
3268
|
const checkProject = {
|
|
2725
3269
|
name: "check_video_project",
|
|
2726
3270
|
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.",
|
|
3271
|
+
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
3272
|
inputSchema: object({}),
|
|
2729
3273
|
annotations: { title: "Check the video project", ...READ },
|
|
2730
3274
|
handler: () => {
|
|
@@ -2860,9 +3404,9 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2860
3404
|
name: "update_video_asset",
|
|
2861
3405
|
title: "Change media details",
|
|
2862
3406
|
description: "Changes an asset's src, name, duration, size, sound or poster (e.g. relinking moved media). Clips that now run past its end are refused. Returns the asset.",
|
|
2863
|
-
inputSchema: object({ asset: ASSET_ID, set: object(ASSET_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "duration", "width", "height", "hasAudio", "poster"] }, description: "Fields to clear." } }, ["asset"]),
|
|
3407
|
+
inputSchema: object({ asset: ASSET_ID, set: object(ASSET_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "duration", "width", "height", "hasAudio", "poster", "transcript"] }, description: "Fields to clear (transcript removes its words)." } }, ["asset"]),
|
|
2864
3408
|
annotations: { title: "Change media details", ...WRITE_IDEMPOTENT },
|
|
2865
|
-
handler: ({ asset, set, remove }) => apply({ op: "update_asset", asset, set, remove }, "Change media", (r) => ({ asset: r.project.assets.find((a) => a.id === asset) }))
|
|
3409
|
+
handler: ({ asset, set, remove }) => apply({ op: "update_asset", asset, set, remove }, "Change media", (r) => ({ asset: videoAssetOut(r.project.assets.find((a) => a.id === asset)) }))
|
|
2866
3410
|
};
|
|
2867
3411
|
const removeAsset = {
|
|
2868
3412
|
name: "remove_video_asset",
|
|
@@ -2956,12 +3500,12 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2956
3500
|
const updateClip = {
|
|
2957
3501
|
name: "update_video_clip",
|
|
2958
3502
|
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.`,
|
|
3503
|
+
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
3504
|
inputSchema: object(
|
|
2961
3505
|
{
|
|
2962
3506
|
clip: CLIP_ID,
|
|
2963
3507
|
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." }
|
|
3508
|
+
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
3509
|
},
|
|
2966
3510
|
["clip"]
|
|
2967
3511
|
),
|
|
@@ -2969,17 +3513,168 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2969
3513
|
handler: ({ clip, set, remove }) => {
|
|
2970
3514
|
const current = clipOut(project(), clip);
|
|
2971
3515
|
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"]]);
|
|
3516
|
+
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
3517
|
const wrong = Object.keys(set).filter((k) => !allowed.has(k));
|
|
2974
3518
|
if (wrong.length) throw new Error(`${wrong.map((k) => `"${k}"`).join(", ")} cannot be set on a ${current.type} clip; it takes ${[...allowed].join(", ")}.`);
|
|
2975
3519
|
}
|
|
2976
3520
|
return apply({ op: "update_clip", clip, set, remove }, "Change clip", (r) => ({ clip: clipOut(r.project, clip) }));
|
|
2977
3521
|
}
|
|
2978
3522
|
};
|
|
3523
|
+
const gradeTargets = (ids) => {
|
|
3524
|
+
const p = project();
|
|
3525
|
+
for (const id of ids) {
|
|
3526
|
+
const at = findVideoClip(p, id);
|
|
3527
|
+
if (!at) throw new Error(`There is no clip "${id}"; get_video_project lists the clips.`);
|
|
3528
|
+
const { clip } = at;
|
|
3529
|
+
if (at.track.kind !== "video" || clip.type === "media" && p.assets.find((a) => a.id === clip.asset)?.kind === "audio")
|
|
3530
|
+
throw new Error(`Clip "${id}" is sound only; grades change pictures. Pass clips on video tracks.`);
|
|
3531
|
+
}
|
|
3532
|
+
};
|
|
3533
|
+
const applyGradePreset = {
|
|
3534
|
+
name: "apply_video_grade_preset",
|
|
3535
|
+
title: "Apply a colour look",
|
|
3536
|
+
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.",
|
|
3537
|
+
inputSchema: object(
|
|
3538
|
+
{
|
|
3539
|
+
clips: { type: "array", items: CLIP_ID, minItems: 1, description: "Clips on video tracks to grade." },
|
|
3540
|
+
preset: { type: "string", enum: [...VIDEO_GRADE_PRESET_NAMES], description: "The look." },
|
|
3541
|
+
amount: { type: "number", minimum: 0, maximum: 1, description: "Strength, 0 (none) to 1 (full, the default); 0.5 is a subtler version." }
|
|
3542
|
+
},
|
|
3543
|
+
["clips", "preset"]
|
|
3544
|
+
),
|
|
3545
|
+
annotations: { title: "Apply a colour look", ...WRITE_IDEMPOTENT },
|
|
3546
|
+
handler: ({ clips, preset, amount }) => {
|
|
3547
|
+
gradeTargets(clips);
|
|
3548
|
+
const grade = videoGradePreset(preset, amount ?? 1);
|
|
3549
|
+
const edits = clips.map((clip) => grade ? { op: "update_clip", clip, set: { grade } } : { op: "update_clip", clip, remove: ["grade"] });
|
|
3550
|
+
return apply(edits, `Apply ${preset} look`, (r) => ({ clips: clips.map((id) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));
|
|
3551
|
+
}
|
|
3552
|
+
};
|
|
3553
|
+
const copyGrade = {
|
|
3554
|
+
name: "copy_video_grade",
|
|
3555
|
+
title: "Copy a colour grade",
|
|
3556
|
+
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.",
|
|
3557
|
+
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"]),
|
|
3558
|
+
annotations: { title: "Copy a colour grade", ...WRITE_IDEMPOTENT },
|
|
3559
|
+
handler: ({ from, to }) => {
|
|
3560
|
+
gradeTargets([from, ...to]);
|
|
3561
|
+
const grade = findVideoClip(project(), from).clip.grade;
|
|
3562
|
+
const edits = to.map((clip) => grade ? { op: "update_clip", clip, set: { grade: { ...grade } } } : { op: "update_clip", clip, remove: ["grade"] });
|
|
3563
|
+
return apply(edits, "Copy grade", () => ({ grade: grade ?? null, clips: to }));
|
|
3564
|
+
}
|
|
3565
|
+
};
|
|
3566
|
+
const TRANSCRIPT_TRACK = { ...TRACK_ID, description: "Track whose words to read (default: the first audio track with a transcribed clip, else the first video track with one)." };
|
|
3567
|
+
const RANGE_OUT = (r) => ({ from: round(r.from), to: round(r.to), text: r.text });
|
|
3568
|
+
const timelineWords = (track) => {
|
|
3569
|
+
const words = videoTimelineTranscript(project(), { track });
|
|
3570
|
+
if (!words.length) throw new Error(track ? `No clip on track "${track}" plays a transcribed asset; attach one with set_video_transcript.` : "No clip plays a transcribed asset; attach a transcript to the media with set_video_transcript first.");
|
|
3571
|
+
return words;
|
|
3572
|
+
};
|
|
3573
|
+
const setTranscript = {
|
|
3574
|
+
name: "set_video_transcript",
|
|
3575
|
+
title: "Attach a transcript",
|
|
3576
|
+
description: 'Attaches what is said in a video or audio asset, word by word, so the edit can be cut by text (one undo step). Pass "text" (Whisper-style JSON with word timings, SubRip or WebVTT, as the file says it) or "words" ([{ word, start, end }] in ms of the source). Captions have no word timings, so their words are spread across each cue (approximate). Replaces any transcript the asset had. Returns the word count, whether timings are approximate, and the first and last words.',
|
|
3577
|
+
inputSchema: object(
|
|
3578
|
+
{
|
|
3579
|
+
asset: { ...ASSET_ID, description: "A video or audio asset." },
|
|
3580
|
+
text: { type: "string", minLength: 1, description: "The transcript file's contents: Whisper-style JSON (times in seconds), .srt or .vtt. Pass this or words." },
|
|
3581
|
+
words: { type: "array", minItems: 1, items: object({ word: { type: "string", minLength: 1, description: "The word as said." }, start: { type: "number", minimum: 0, description: "Start in ms of the source." }, end: { type: "number", minimum: 0, description: "End in ms of the source." } }, ["word", "start", "end"], "One word."), description: "Words with source times in ms. Pass this or text." }
|
|
3582
|
+
},
|
|
3583
|
+
["asset"]
|
|
3584
|
+
),
|
|
3585
|
+
annotations: { title: "Attach a transcript", ...WRITE_IDEMPOTENT },
|
|
3586
|
+
handler: ({ asset, text, words }) => {
|
|
3587
|
+
if (text === void 0 === (words === void 0)) throw new Error(`Pass either "text" (a transcript file's contents) or "words", not both.`);
|
|
3588
|
+
const a = project().assets.find((x) => x.id === asset);
|
|
3589
|
+
if (!a) throw new Error(`There is no asset "${asset}"; the assets are ${project().assets.map((x) => `"${x.id}"`).join(", ") || "none"}.`);
|
|
3590
|
+
if (a.kind === "image") throw new Error(`Asset "${asset}" is an image and has no sound to transcribe.`);
|
|
3591
|
+
const parsed = text !== void 0 ? parseVideoTranscript(text) : { words, approximate: false };
|
|
3592
|
+
const list = parsed.words;
|
|
3593
|
+
return apply({ op: "update_asset", asset, set: { transcript: list } }, "Attach transcript", () => ({
|
|
3594
|
+
asset,
|
|
3595
|
+
words: list.length,
|
|
3596
|
+
approximate: parsed.approximate,
|
|
3597
|
+
first: list[0],
|
|
3598
|
+
last: list[list.length - 1],
|
|
3599
|
+
...a.duration !== void 0 && list[list.length - 1].end > a.duration + VIDEO_TIME_EPSILON ? { warning: `The transcript runs to ${list[list.length - 1].end} ms, past the asset's ${a.duration} ms: is it for this asset?` } : {}
|
|
3600
|
+
}));
|
|
3601
|
+
}
|
|
3602
|
+
};
|
|
3603
|
+
const getTranscript = {
|
|
3604
|
+
name: "get_video_transcript",
|
|
3605
|
+
title: "Read the transcript",
|
|
3606
|
+
description: "Returns what the timeline says, in order, from the transcripts of the media its clips play on one track: the text, and each word with its timeline start and end (ms) and the clip that plays it, so ranges can go straight to remove_video_ranges or split_video_at. Words cut out of the edit are not included.",
|
|
3607
|
+
inputSchema: object({
|
|
3608
|
+
track: TRANSCRIPT_TRACK,
|
|
3609
|
+
from: MS("Only words from this timeline time (default 0)"),
|
|
3610
|
+
to: MS("Only words up to this timeline time (default the end)"),
|
|
3611
|
+
limit: { type: "integer", minimum: 1, maximum: 5e3, description: 'Most words to return (default 1000); "more" says if there are others.' }
|
|
3612
|
+
}),
|
|
3613
|
+
annotations: { title: "Read the transcript", ...READ },
|
|
3614
|
+
handler: ({ track, from, to, limit }) => {
|
|
3615
|
+
const all = timelineWords(track).filter((w) => w.end > (from ?? 0) && w.start < (to ?? Infinity));
|
|
3616
|
+
const words = all.slice(0, limit ?? 1e3);
|
|
3617
|
+
return { track: words[0]?.track ?? videoTranscriptTrack(project(), track), text: words.map((w) => w.word).join(" "), words: words.map((w) => ({ word: w.word, start: round(w.start), end: round(w.end), clip: w.clip })), more: all.length > words.length };
|
|
3618
|
+
}
|
|
3619
|
+
};
|
|
3620
|
+
const findText = {
|
|
3621
|
+
name: "find_video_transcript_text",
|
|
3622
|
+
title: "Find words in the transcript",
|
|
3623
|
+
description: "Lists every place a word or phrase is said on the timeline (case and punctuation ignored) as { from, to, text } ranges in timeline ms, ready for remove_video_ranges, split_video_at or seek_video.",
|
|
3624
|
+
inputSchema: object({ query: { type: "string", minLength: 1, description: "The word or phrase." }, track: TRANSCRIPT_TRACK }, ["query"]),
|
|
3625
|
+
annotations: { title: "Find words in the transcript", ...READ },
|
|
3626
|
+
handler: ({ query, track }) => ({ matches: findVideoTranscriptText(timelineWords(track), query).map(RANGE_OUT) })
|
|
3627
|
+
};
|
|
3628
|
+
const findFillers = {
|
|
3629
|
+
name: "find_video_fillers",
|
|
3630
|
+
title: "Find filler words",
|
|
3631
|
+
description: `Lists filler words and phrases on the timeline as { from, to, text } ranges in timeline ms, longest phrase first so "you know" is found whole. Default fillers: ${VIDEO_DEFAULT_FILLERS.join(", ")} ("like" and "so" are left out as too often meant). Check them, then pass the ones to cut to remove_video_ranges.`,
|
|
3632
|
+
inputSchema: object({ track: TRANSCRIPT_TRACK, fillers: { type: "array", minItems: 1, items: { type: "string", minLength: 1 }, description: "Words or phrases to treat as fillers instead of the defaults." } }),
|
|
3633
|
+
annotations: { title: "Find filler words", ...READ },
|
|
3634
|
+
handler: ({ track, fillers }) => {
|
|
3635
|
+
const found = findVideoFillers(timelineWords(track), fillers);
|
|
3636
|
+
return { fillers: found.map(RANGE_OUT), total: round(found.reduce((n2, r) => n2 + r.to - r.from, 0)) };
|
|
3637
|
+
}
|
|
3638
|
+
};
|
|
3639
|
+
const findPauses = {
|
|
3640
|
+
name: "find_video_pauses",
|
|
3641
|
+
title: "Find pauses between words",
|
|
3642
|
+
description: 'Lists the gaps between consecutive words on the timeline (within one clip) longer than "min", less "keep" next to each word so speech is not clipped, snapped inward to frames, as { from, to } ranges in timeline ms ready for remove_video_ranges. Uses word timings; find_video_silences measures the sound instead.',
|
|
3643
|
+
inputSchema: object({
|
|
3644
|
+
track: TRANSCRIPT_TRACK,
|
|
3645
|
+
min: { type: "number", exclusiveMinimum: 0, description: "Shortest gap that counts, in ms (default 700)." },
|
|
3646
|
+
keep: { type: "number", minimum: 0, description: "Silence to keep after the word before and before the word after, in ms (default 150)." }
|
|
3647
|
+
}),
|
|
3648
|
+
annotations: { title: "Find pauses between words", ...READ },
|
|
3649
|
+
handler: ({ track, min, keep }) => {
|
|
3650
|
+
const found = findVideoTranscriptPauses(timelineWords(track), { min, keep, fps: project().fps });
|
|
3651
|
+
return { pauses: found.map(RANGE_OUT), total: round(found.reduce((n2, r) => n2 + r.to - r.from, 0)) };
|
|
3652
|
+
}
|
|
3653
|
+
};
|
|
3654
|
+
const removeRanges = {
|
|
3655
|
+
name: "remove_video_ranges",
|
|
3656
|
+
title: "Cut out stretches",
|
|
3657
|
+
description: "Cuts stretches of the timeline out and closes them up, as one undo step: the ranges are merged where they touch and extracted from the last to the first, so the times from find_video_fillers, find_video_pauses, find_video_transcript_text or find_video_silences can be passed as they are. Like extract_video_range, every sync-locked track moves with the cut. Returns how many stretches and how much time were removed and the new length.",
|
|
3658
|
+
inputSchema: object(
|
|
3659
|
+
{
|
|
3660
|
+
ranges: { type: "array", minItems: 1, maxItems: 500, items: object({ from: MS("Start, on the timeline"), to: MS("End, on the timeline"), text: { type: "string", description: "Ignored: the words in the stretch, as the find tools return them." } }, ["from", "to"], "A stretch to remove."), description: "The stretches, in any order (timeline ms)." },
|
|
3661
|
+
tracks: { type: "array", items: TRACK_ID, minItems: 1, description: "Tracks to cut (default every unlocked track)." }
|
|
3662
|
+
},
|
|
3663
|
+
["ranges"]
|
|
3664
|
+
),
|
|
3665
|
+
annotations: { title: "Cut out stretches", ...DESTRUCTIVE },
|
|
3666
|
+
handler: ({ ranges, tracks }) => {
|
|
3667
|
+
const bad = ranges.findIndex((r) => !(r.to > r.from));
|
|
3668
|
+
if (bad >= 0) throw new Error(`Range ${bad + 1} is empty or backwards (${ranges[bad].from}\u2013${ranges[bad].to} ms); "to" must be after "from".`);
|
|
3669
|
+
const merged = mergeVideoRanges(ranges);
|
|
3670
|
+
const before = videoProjectDuration(project());
|
|
3671
|
+
return apply(videoRemoveRangesEdits(merged, tracks), merged.length > 1 ? `Cut ${merged.length} stretches` : "Cut stretch", (r) => ({ removed: merged.length, time: round(before - videoProjectDuration(r.project)), duration: videoProjectDuration(r.project) }));
|
|
3672
|
+
}
|
|
3673
|
+
};
|
|
2979
3674
|
const setTransition = {
|
|
2980
3675
|
name: "set_video_transition",
|
|
2981
3676
|
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).
|
|
3677
|
+
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
3678
|
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
3679
|
annotations: { title: "Set a transition", ...WRITE_IDEMPOTENT },
|
|
2985
3680
|
handler: ({ clip, transition }) => apply({ op: "set_transition", clip, transition }, transition ? "Set transition" : "Remove transition", (r) => ({ clip: clipOut(r.project, clip) }))
|
|
@@ -3193,8 +3888,8 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
3193
3888
|
return { asset: r.asset.id, from: r.from, to: r.to, silences, total: round(silences.reduce((n2, x) => n2 + x.end - x.start, 0)) };
|
|
3194
3889
|
}
|
|
3195
3890
|
};
|
|
3196
|
-
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];
|
|
3891
|
+
const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
|
|
3892
|
+
const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setTranscript, removeRanges, applyGradePreset, copyGrade, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
|
|
3198
3893
|
return {
|
|
3199
3894
|
id: options.id ?? "video",
|
|
3200
3895
|
namespace: options.namespace,
|
|
@@ -3230,6 +3925,39 @@ function videoClipShowsWaveform(clip, asset, mode = "audio") {
|
|
|
3230
3925
|
if (clip.type !== "media" || mode === "none" || !videoAssetHasSound(asset)) return false;
|
|
3231
3926
|
return mode === "all" || asset.kind === "audio";
|
|
3232
3927
|
}
|
|
3928
|
+
var VIDEO_CLIP_ANIMATION_LABELS = {
|
|
3929
|
+
fade: "Fade",
|
|
3930
|
+
rise: "Rise",
|
|
3931
|
+
drop: "Drop",
|
|
3932
|
+
"slide-left": "Slide left",
|
|
3933
|
+
"slide-right": "Slide right",
|
|
3934
|
+
zoom: "Zoom",
|
|
3935
|
+
pop: "Pop"
|
|
3936
|
+
};
|
|
3937
|
+
var VIDEO_TRANSITION_LABELS = {
|
|
3938
|
+
crossfade: "Crossfade",
|
|
3939
|
+
dip: "Dip to colour",
|
|
3940
|
+
wipe: "Wipe",
|
|
3941
|
+
push: "Push",
|
|
3942
|
+
slide: "Slide",
|
|
3943
|
+
iris: "Iris"
|
|
3944
|
+
};
|
|
3945
|
+
var videoTransitionHasDirection = (type) => type === "wipe" || type === "push" || type === "slide";
|
|
3946
|
+
var VIDEO_BLEND_MODE_LABELS = {
|
|
3947
|
+
normal: "Normal",
|
|
3948
|
+
multiply: "Multiply",
|
|
3949
|
+
screen: "Screen",
|
|
3950
|
+
overlay: "Overlay",
|
|
3951
|
+
darken: "Darken",
|
|
3952
|
+
lighten: "Lighten",
|
|
3953
|
+
"color-dodge": "Colour dodge",
|
|
3954
|
+
"color-burn": "Colour burn",
|
|
3955
|
+
"hard-light": "Hard light",
|
|
3956
|
+
"soft-light": "Soft light",
|
|
3957
|
+
difference: "Difference",
|
|
3958
|
+
exclusion: "Exclusion",
|
|
3959
|
+
add: "Add"
|
|
3960
|
+
};
|
|
3233
3961
|
var VIDEO_FADE_CURVE_LABELS = {
|
|
3234
3962
|
linear: "Linear",
|
|
3235
3963
|
"equal-power": "Equal power",
|
|
@@ -3400,7 +4128,13 @@ function videoKeyframeToggleEdit(project, clipId, property, time) {
|
|
|
3400
4128
|
return { op: "set_keyframes", clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };
|
|
3401
4129
|
}
|
|
3402
4130
|
export {
|
|
4131
|
+
VIDEO_BLEND_MODES,
|
|
4132
|
+
VIDEO_BLEND_MODE_LABELS,
|
|
4133
|
+
VIDEO_CLIP_ANIMATION_DURATION,
|
|
4134
|
+
VIDEO_CLIP_ANIMATION_EFFECTS,
|
|
4135
|
+
VIDEO_CLIP_ANIMATION_LABELS,
|
|
3403
4136
|
VIDEO_CLIP_TYPES,
|
|
4137
|
+
VIDEO_DEFAULT_FILLERS,
|
|
3404
4138
|
VIDEO_DEFAULT_STILL_DURATION,
|
|
3405
4139
|
VIDEO_DEFAULT_TITLE_DURATION,
|
|
3406
4140
|
VIDEO_EASE_NAMES,
|
|
@@ -3408,6 +4142,9 @@ export {
|
|
|
3408
4142
|
VIDEO_FADE_CURVES,
|
|
3409
4143
|
VIDEO_FADE_CURVE_LABELS,
|
|
3410
4144
|
VIDEO_FITS,
|
|
4145
|
+
VIDEO_GRADE_CONTROLS,
|
|
4146
|
+
VIDEO_GRADE_PRESETS,
|
|
4147
|
+
VIDEO_GRADE_PRESET_NAMES,
|
|
3411
4148
|
VIDEO_ISSUE_CODES,
|
|
3412
4149
|
VIDEO_KEYFRAME_LIMITS,
|
|
3413
4150
|
VIDEO_KEYFRAME_PROPERTIES,
|
|
@@ -3415,6 +4152,7 @@ export {
|
|
|
3415
4152
|
VIDEO_PLACEMENT_MODES,
|
|
3416
4153
|
VIDEO_TIME_EPSILON,
|
|
3417
4154
|
VIDEO_TRANSFORM_PROPERTIES,
|
|
4155
|
+
VIDEO_TRANSITION_LABELS,
|
|
3418
4156
|
VIDEO_TRANSITION_TYPES,
|
|
3419
4157
|
VIDEO_WIPE_DIRECTIONS,
|
|
3420
4158
|
VideoAudioPeaksCache,
|
|
@@ -3422,6 +4160,7 @@ export {
|
|
|
3422
4160
|
VideoPreviewEngine,
|
|
3423
4161
|
allVideoClipIds,
|
|
3424
4162
|
applyVideoEdits,
|
|
4163
|
+
applyVideoGrade,
|
|
3425
4164
|
buildVideoAudioPeaks,
|
|
3426
4165
|
checkVideoProject,
|
|
3427
4166
|
createVideoEditorToolProvider,
|
|
@@ -3432,21 +4171,31 @@ export {
|
|
|
3432
4171
|
ffmpegFilterValue,
|
|
3433
4172
|
findVideoAudioSilences,
|
|
3434
4173
|
findVideoClip,
|
|
4174
|
+
findVideoFillers,
|
|
3435
4175
|
findVideoGaps,
|
|
4176
|
+
findVideoTranscriptPauses,
|
|
4177
|
+
findVideoTranscriptText,
|
|
3436
4178
|
formatVideoSeconds,
|
|
3437
4179
|
formatVideoTimecode,
|
|
3438
4180
|
formatVideoTimecodeFrames,
|
|
3439
4181
|
isVideoDropFrameRate,
|
|
3440
4182
|
isVideoKeyframes,
|
|
4183
|
+
mergeVideoRanges,
|
|
3441
4184
|
nextFreeVideoSlot,
|
|
4185
|
+
normalizeVideoGrade,
|
|
4186
|
+
normalizeVideoWord,
|
|
3442
4187
|
parseVideoAnimatable,
|
|
3443
4188
|
parseVideoAsset,
|
|
3444
4189
|
parseVideoClip,
|
|
4190
|
+
parseVideoClipAnimation,
|
|
4191
|
+
parseVideoGrade,
|
|
3445
4192
|
parseVideoKeyframes,
|
|
3446
4193
|
parseVideoLoudnessMeasurement,
|
|
3447
4194
|
parseVideoMarker,
|
|
3448
4195
|
parseVideoProject,
|
|
3449
4196
|
parseVideoTrack,
|
|
4197
|
+
parseVideoTranscript,
|
|
4198
|
+
parseVideoTranscriptWords,
|
|
3450
4199
|
parseVideoTransform,
|
|
3451
4200
|
parseVideoTransition,
|
|
3452
4201
|
placeVideoRect,
|
|
@@ -3472,6 +4221,9 @@ export {
|
|
|
3472
4221
|
videoAudioPeakSpans,
|
|
3473
4222
|
videoAudioPeaksDuration,
|
|
3474
4223
|
videoClipAfter,
|
|
4224
|
+
videoClipAnimated,
|
|
4225
|
+
videoClipAnimationProperties,
|
|
4226
|
+
videoClipAnimationSpans,
|
|
3475
4227
|
videoClipBefore,
|
|
3476
4228
|
videoClipEnd,
|
|
3477
4229
|
videoClipHandles,
|
|
@@ -3496,7 +4248,17 @@ export {
|
|
|
3496
4248
|
videoFrameDuration,
|
|
3497
4249
|
videoFrameToSvg,
|
|
3498
4250
|
videoFrames,
|
|
4251
|
+
videoGradeCssFilter,
|
|
4252
|
+
videoGradeFfmpegFilters,
|
|
4253
|
+
videoGradeGainsFilterId,
|
|
4254
|
+
videoGradePreset,
|
|
4255
|
+
videoGradeStepMatrix,
|
|
4256
|
+
videoGradeStepSvgValues,
|
|
4257
|
+
videoGradeSteps,
|
|
4258
|
+
videoGradeSvgDefs,
|
|
4259
|
+
videoGradeSvgFilter,
|
|
3499
4260
|
videoHourFrames,
|
|
4261
|
+
videoIrisRadius,
|
|
3500
4262
|
videoIssueKey,
|
|
3501
4263
|
videoKeyframeBreakpoints,
|
|
3502
4264
|
videoKeyframeFfmpegExpr,
|
|
@@ -3508,15 +4270,24 @@ export {
|
|
|
3508
4270
|
videoPeakDb,
|
|
3509
4271
|
videoProjectDuration,
|
|
3510
4272
|
videoProjectSvg,
|
|
4273
|
+
videoRemoveRangesEdits,
|
|
3511
4274
|
videoRulerTicks,
|
|
3512
4275
|
videoSnapPoints,
|
|
3513
4276
|
videoTextStyle,
|
|
3514
4277
|
videoTimecodeFrames,
|
|
4278
|
+
videoTimelineTranscript,
|
|
3515
4279
|
videoTitleFrame,
|
|
3516
4280
|
videoTrackSyncLocked,
|
|
4281
|
+
videoTranscriptEdit,
|
|
4282
|
+
videoTranscriptTrack,
|
|
3517
4283
|
videoTransformAt,
|
|
4284
|
+
videoTransitionAudioCurve,
|
|
4285
|
+
videoTransitionEase,
|
|
4286
|
+
videoTransitionHasDirection,
|
|
3518
4287
|
videoValueAt,
|
|
3519
4288
|
videoWaveformPath,
|
|
3520
|
-
videoWipeRect
|
|
4289
|
+
videoWipeRect,
|
|
4290
|
+
videoWordAt,
|
|
4291
|
+
videoWordsRange
|
|
3521
4292
|
};
|
|
3522
4293
|
//# sourceMappingURL=index.js.map
|