@pixerate/editor 0.78.0 → 0.79.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 +529 -59
- package/dist/video-editor/index.cjs.map +1 -1
- package/dist/video-editor/index.d.cts +165 -18
- package/dist/video-editor/index.d.ts +165 -18
- package/dist/video-editor/index.js +507 -37
- package/dist/video-editor/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -17,6 +17,194 @@ var VIDEO_TRANSITION_TYPES = ["crossfade", "dip", "wipe"];
|
|
|
17
17
|
var VIDEO_WIPE_DIRECTIONS = ["left", "right", "up", "down"];
|
|
18
18
|
var VIDEO_CLIP_TYPES = ["media", "text", "color"];
|
|
19
19
|
|
|
20
|
+
// src/video-editor/keyframes.ts
|
|
21
|
+
var VIDEO_EASE_NAMES = ["linear", "hold", "ease-in", "ease-out", "ease-in-out"];
|
|
22
|
+
var VIDEO_KEYFRAME_PROPERTIES = ["opacity", "volume", "x", "y", "scale", "rotation"];
|
|
23
|
+
var VIDEO_TRANSFORM_PROPERTIES = ["x", "y", "scale", "rotation"];
|
|
24
|
+
var VIDEO_KEYFRAME_LIMITS = {
|
|
25
|
+
opacity: { default: 1, min: 0, max: 1 },
|
|
26
|
+
volume: { default: 1, min: 0, max: 4 },
|
|
27
|
+
x: { default: 0 },
|
|
28
|
+
y: { default: 0 },
|
|
29
|
+
scale: { default: 1, min: 0 },
|
|
30
|
+
rotation: { default: 0 }
|
|
31
|
+
};
|
|
32
|
+
var NAMED = {
|
|
33
|
+
"ease-in": [0.42, 0, 1, 1],
|
|
34
|
+
"ease-out": [0, 0, 0.58, 1],
|
|
35
|
+
"ease-in-out": [0.42, 0, 0.58, 1]
|
|
36
|
+
};
|
|
37
|
+
var isVideoKeyframes = (v) => typeof v === "object" && v !== null && Array.isArray(v.keys);
|
|
38
|
+
function videoEaseBezier(ease) {
|
|
39
|
+
if (ease === void 0 || ease === "linear" || ease === "hold") return null;
|
|
40
|
+
return Array.isArray(ease) ? ease : NAMED[ease];
|
|
41
|
+
}
|
|
42
|
+
var bez = (a, b, s) => 3 * a * (1 - s) * (1 - s) * s + 3 * b * (1 - s) * s * s + s * s * s;
|
|
43
|
+
var bezSlope = (a, b, s) => 3 * a * (1 - s) * (1 - s) + 6 * (b - a) * (1 - s) * s + 3 * (1 - b) * s * s;
|
|
44
|
+
function bezierParam(x1, x2, x) {
|
|
45
|
+
let s = x;
|
|
46
|
+
for (let i = 0; i < 8; i++) {
|
|
47
|
+
const err = bez(x1, x2, s) - x;
|
|
48
|
+
if (Math.abs(err) < 1e-7) return s;
|
|
49
|
+
const d = bezSlope(x1, x2, s);
|
|
50
|
+
if (Math.abs(d) < 1e-6) break;
|
|
51
|
+
s -= err / d;
|
|
52
|
+
if (s < 0 || s > 1) break;
|
|
53
|
+
}
|
|
54
|
+
let lo = 0;
|
|
55
|
+
let hi = 1;
|
|
56
|
+
s = x;
|
|
57
|
+
for (let i = 0; i < 60; i++) {
|
|
58
|
+
const v = bez(x1, x2, s);
|
|
59
|
+
if (Math.abs(v - x) < 1e-9) break;
|
|
60
|
+
if (v < x) lo = s;
|
|
61
|
+
else hi = s;
|
|
62
|
+
s = (lo + hi) / 2;
|
|
63
|
+
}
|
|
64
|
+
return s;
|
|
65
|
+
}
|
|
66
|
+
function videoEaseProgress(ease, p) {
|
|
67
|
+
if (p <= 0) return 0;
|
|
68
|
+
if (p >= 1) return ease === "hold" ? 0 : 1;
|
|
69
|
+
if (ease === "hold") return 0;
|
|
70
|
+
const b = videoEaseBezier(ease);
|
|
71
|
+
if (!b) return p;
|
|
72
|
+
return bez(b[1], b[3], bezierParam(b[0], b[2], p));
|
|
73
|
+
}
|
|
74
|
+
function keyBefore(keys, t) {
|
|
75
|
+
let lo = 0;
|
|
76
|
+
let hi = keys.length - 1;
|
|
77
|
+
if (t < keys[0].t) return -1;
|
|
78
|
+
while (lo < hi) {
|
|
79
|
+
const mid = lo + hi + 1 >> 1;
|
|
80
|
+
if (keys[mid].t <= t) lo = mid;
|
|
81
|
+
else hi = mid - 1;
|
|
82
|
+
}
|
|
83
|
+
return lo;
|
|
84
|
+
}
|
|
85
|
+
function videoValueAt(prop, clipTime, fallback = 0) {
|
|
86
|
+
if (prop === void 0) return fallback;
|
|
87
|
+
if (typeof prop === "number") return prop;
|
|
88
|
+
const { keys } = prop;
|
|
89
|
+
if (!keys.length) return fallback;
|
|
90
|
+
const i = keyBefore(keys, clipTime);
|
|
91
|
+
if (i < 0) return keys[0].v;
|
|
92
|
+
if (i >= keys.length - 1) return keys[keys.length - 1].v;
|
|
93
|
+
const a = keys[i];
|
|
94
|
+
const b = keys[i + 1];
|
|
95
|
+
if (a.ease === "hold") return a.v;
|
|
96
|
+
return a.v + (b.v - a.v) * videoEaseProgress(a.ease, (clipTime - a.t) / (b.t - a.t));
|
|
97
|
+
}
|
|
98
|
+
var videoKeysOf = (prop) => isVideoKeyframes(prop) ? prop.keys : [];
|
|
99
|
+
function retimeVideoAnimatable(prop, dt, k = 1) {
|
|
100
|
+
if (!isVideoKeyframes(prop)) return prop;
|
|
101
|
+
return { keys: prop.keys.map((key) => ({ ...key, t: key.t * k + dt })) };
|
|
102
|
+
}
|
|
103
|
+
function splitEase(ease, p) {
|
|
104
|
+
if (ease === "hold") return ["hold", "hold"];
|
|
105
|
+
const b = videoEaseBezier(ease);
|
|
106
|
+
if (!b) return [ease, ease];
|
|
107
|
+
const s = bezierParam(b[0], b[2], p);
|
|
108
|
+
const lerp = (u, v, k) => [u[0] + (v[0] - u[0]) * k, u[1] + (v[1] - u[1]) * k];
|
|
109
|
+
const P0 = [0, 0];
|
|
110
|
+
const P1 = [b[0], b[1]];
|
|
111
|
+
const P2 = [b[2], b[3]];
|
|
112
|
+
const P3 = [1, 1];
|
|
113
|
+
const A = lerp(P0, P1, s);
|
|
114
|
+
const B = lerp(P1, P2, s);
|
|
115
|
+
const C = lerp(P2, P3, s);
|
|
116
|
+
const AB = lerp(A, B, s);
|
|
117
|
+
const BC = lerp(B, C, s);
|
|
118
|
+
const M = lerp(AB, BC, s);
|
|
119
|
+
const r = (v) => Math.round(v * 1e6) / 1e6;
|
|
120
|
+
const left = Math.abs(M[1]) < 1e-9 ? "linear" : [r(A[0] / M[0]), r(A[1] / M[1]), r(AB[0] / M[0]), r(AB[1] / M[1])];
|
|
121
|
+
const dx = 1 - M[0];
|
|
122
|
+
const dy = 1 - M[1];
|
|
123
|
+
const right = Math.abs(dy) < 1e-9 ? "linear" : [r((BC[0] - M[0]) / dx), r((BC[1] - M[1]) / dy), r((C[0] - M[0]) / dx), r((C[1] - M[1]) / dy)];
|
|
124
|
+
return [left, right];
|
|
125
|
+
}
|
|
126
|
+
function splitVideoAnimatable(prop, at) {
|
|
127
|
+
if (!isVideoKeyframes(prop)) return [prop, prop];
|
|
128
|
+
const keys = prop.keys;
|
|
129
|
+
const eps = 1e-6;
|
|
130
|
+
const v = videoValueAt(prop, at);
|
|
131
|
+
const i = keyBefore(keys, at);
|
|
132
|
+
const exact = i >= 0 && Math.abs(keys[i].t - at) < eps;
|
|
133
|
+
let leftEase;
|
|
134
|
+
let rightEase;
|
|
135
|
+
if (exact) rightEase = keys[i].ease;
|
|
136
|
+
else if (i >= 0 && i < keys.length - 1) {
|
|
137
|
+
const a = keys[i];
|
|
138
|
+
[leftEase, rightEase] = splitEase(a.ease, (at - a.t) / (keys[i + 1].t - a.t));
|
|
139
|
+
}
|
|
140
|
+
const before = keys.slice(0, exact ? i : i + 1).map((k) => ({ ...k }));
|
|
141
|
+
if (before.length && leftEase !== void 0) before[before.length - 1] = withEase(before[before.length - 1], leftEase);
|
|
142
|
+
const left = [...before, { t: at, v }];
|
|
143
|
+
const right = [withEase({ t: 0, v }, rightEase), ...keys.slice(i + 1).map((k) => ({ ...k, t: k.t - at }))];
|
|
144
|
+
return [{ keys: left }, { keys: right }];
|
|
145
|
+
}
|
|
146
|
+
var withEase = (key, ease) => {
|
|
147
|
+
const { ease: _old, ...rest } = key;
|
|
148
|
+
return ease === void 0 || ease === "linear" ? rest : { ...rest, ease };
|
|
149
|
+
};
|
|
150
|
+
function sortVideoKeys(keys) {
|
|
151
|
+
const sorted = [...keys].sort((a, b) => a.t - b.t);
|
|
152
|
+
for (let i = 1; i < sorted.length; i++) if (Math.abs(sorted[i].t - sorted[i - 1].t) < 1e-6) throw new Error(`Two keys are at ${sorted[i].t} ms; give each key its own time.`);
|
|
153
|
+
return sorted;
|
|
154
|
+
}
|
|
155
|
+
function setVideoKey(prop, key, fallback) {
|
|
156
|
+
const keys = isVideoKeyframes(prop) ? prop.keys.filter((k) => Math.abs(k.t - key.t) >= 1e-6) : [];
|
|
157
|
+
if (!isVideoKeyframes(prop)) {
|
|
158
|
+
const v = _nullishCoalesce(prop, () => ( fallback));
|
|
159
|
+
if (key.t !== 0) keys.push({ t: 0, v });
|
|
160
|
+
}
|
|
161
|
+
return { keys: sortVideoKeys([...keys, key]) };
|
|
162
|
+
}
|
|
163
|
+
function removeVideoKey(prop, t) {
|
|
164
|
+
const keys = prop.keys.filter((k) => Math.abs(k.t - t) >= 1e-6);
|
|
165
|
+
return keys.length ? { keys } : prop.keys[0].v;
|
|
166
|
+
}
|
|
167
|
+
var fmt = (v) => {
|
|
168
|
+
const r = Math.round(v * 1e4) / 1e4;
|
|
169
|
+
return Object.is(r, -0) ? "0" : String(r);
|
|
170
|
+
};
|
|
171
|
+
var signed = (v) => fmt(v).startsWith("-") ? fmt(v) : `+${fmt(v)}`;
|
|
172
|
+
function videoKeyframeBreakpoints(prop, step) {
|
|
173
|
+
const { keys } = prop;
|
|
174
|
+
const points = [];
|
|
175
|
+
const holds = /* @__PURE__ */ new Set();
|
|
176
|
+
for (let i = 0; i < keys.length; i++) {
|
|
177
|
+
const a = keys[i];
|
|
178
|
+
points.push([a.t, a.v]);
|
|
179
|
+
const b = keys[i + 1];
|
|
180
|
+
if (!b) break;
|
|
181
|
+
if (a.ease === "hold") {
|
|
182
|
+
holds.add(points.length - 1);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!videoEaseBezier(a.ease)) continue;
|
|
186
|
+
const n2 = Math.max(1, Math.ceil((b.t - a.t) / step));
|
|
187
|
+
for (let k = 1; k < n2; k++) {
|
|
188
|
+
const t = a.t + (b.t - a.t) * k / n2;
|
|
189
|
+
points.push([t, videoValueAt(prop, t)]);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { points, holds };
|
|
193
|
+
}
|
|
194
|
+
function videoKeyframeFfmpegExpr(prop, time, fallback, step) {
|
|
195
|
+
if (!isVideoKeyframes(prop)) return fmt(_nullishCoalesce(prop, () => ( fallback)));
|
|
196
|
+
const { points, holds } = videoKeyframeBreakpoints(prop, step);
|
|
197
|
+
if (points.length === 1 || points.every(([, v]) => v === points[0][1])) return fmt(points[0][1]);
|
|
198
|
+
let expr = fmt(points[points.length - 1][1]);
|
|
199
|
+
for (let i = points.length - 2; i >= 0; i--) {
|
|
200
|
+
const [t0, v0] = points[i];
|
|
201
|
+
const [t1, v1] = points[i + 1];
|
|
202
|
+
const seg = holds.has(i) || v0 === v1 ? fmt(v0) : `${fmt(v0)}${signed((v1 - v0) / (t1 - t0))}*(${time}${signed(-t0)})`;
|
|
203
|
+
expr = `if(lt(${time},${fmt(t1)}),${seg},${expr})`;
|
|
204
|
+
}
|
|
205
|
+
return `if(lt(${time},${fmt(points[0][0])}),${fmt(points[0][1])},${expr})`;
|
|
206
|
+
}
|
|
207
|
+
|
|
20
208
|
// src/video-editor/parse.ts
|
|
21
209
|
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
22
210
|
var fail = (path, what) => {
|
|
@@ -46,11 +234,44 @@ var oneOf = (v, options, path, optional = true) => {
|
|
|
46
234
|
return v;
|
|
47
235
|
};
|
|
48
236
|
var compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
237
|
+
function parseVideoEase(v, path) {
|
|
238
|
+
if (v === void 0) return void 0;
|
|
239
|
+
if (Array.isArray(v)) {
|
|
240
|
+
if (v.length !== 4 || !v.every((n2) => typeof n2 === "number" && Number.isFinite(n2))) fail(path, "must be four numbers [x1, y1, x2, y2] (CSS cubic-bezier)");
|
|
241
|
+
if (v[0] < 0 || v[0] > 1 || v[2] < 0 || v[2] > 1) fail(path, "must have x1 and x2 between 0 and 1");
|
|
242
|
+
return [v[0], v[1], v[2], v[3]];
|
|
243
|
+
}
|
|
244
|
+
return oneOf(v, VIDEO_EASE_NAMES, path);
|
|
245
|
+
}
|
|
246
|
+
function parseVideoKeyframes(v, path, { min, max } = {}) {
|
|
247
|
+
if (!isObject(v)) return fail(path, "must be a number or keyframes { keys: [{ t, v, ease }] }");
|
|
248
|
+
for (const key of Object.keys(v)) if (key !== "keys") fail(`${path}.${key}`, "is not a keyframes field (keys)");
|
|
249
|
+
if (!Array.isArray(v.keys) || !v.keys.length) fail(`${path}.keys`, "must be an array of at least one key { t, v, ease }");
|
|
250
|
+
const keys = v.keys.map((k, i) => {
|
|
251
|
+
const at = `${path}.keys[${i}]`;
|
|
252
|
+
if (!isObject(k)) return fail(at, "must be an object { t, v, ease }");
|
|
253
|
+
for (const key of Object.keys(k)) if (!["t", "v", "ease"].includes(key)) fail(`${at}.${key}`, "is not a key field (t, v, ease)");
|
|
254
|
+
return compact({ t: num(k.t, `${at}.t`), v: num(k.v, `${at}.v`, { min, max }), ease: parseVideoEase(k.ease, `${at}.ease`) });
|
|
255
|
+
});
|
|
256
|
+
for (let i = 1; i < keys.length; i++) if (!(keys[i].t > keys[i - 1].t)) fail(`${path}.keys[${i}].t`, `must be later than the key before (${keys[i - 1].t} ms); keys go in time order, one per time`);
|
|
257
|
+
return { keys };
|
|
258
|
+
}
|
|
259
|
+
function parseVideoAnimatable(v, path, range = {}) {
|
|
260
|
+
if (v === void 0 && range.optional) return void 0;
|
|
261
|
+
const { min, max } = range;
|
|
262
|
+
if (isObject(v)) return parseVideoKeyframes(v, path, { min, max });
|
|
263
|
+
return num(v, path, { min, max });
|
|
264
|
+
}
|
|
49
265
|
function parseVideoTransform(v, path) {
|
|
50
266
|
if (v === void 0) return void 0;
|
|
51
267
|
if (!isObject(v)) return fail(path, "must be an object { x, y, scale, rotation }");
|
|
52
268
|
for (const key of Object.keys(v)) if (!["x", "y", "scale", "rotation"].includes(key)) fail(`${path}.${key}`, "is not a transform field (x, y, scale, rotation)");
|
|
53
|
-
return compact({
|
|
269
|
+
return compact({
|
|
270
|
+
x: parseVideoAnimatable(v.x, `${path}.x`, { optional: true }),
|
|
271
|
+
y: parseVideoAnimatable(v.y, `${path}.y`, { optional: true }),
|
|
272
|
+
scale: parseVideoAnimatable(v.scale, `${path}.scale`, { optional: true, min: 0 }),
|
|
273
|
+
rotation: parseVideoAnimatable(v.rotation, `${path}.rotation`, { optional: true })
|
|
274
|
+
});
|
|
54
275
|
}
|
|
55
276
|
function parseVideoTransition(v, path) {
|
|
56
277
|
if (v === void 0) return void 0;
|
|
@@ -87,7 +308,7 @@ function parseVideoClip(v, path = "clip") {
|
|
|
87
308
|
name: str(v.name, `${path}.name`, true),
|
|
88
309
|
start: num(v.start, `${path}.start`, { min: 0 }),
|
|
89
310
|
duration: num(v.duration, `${path}.duration`, { positive: true }),
|
|
90
|
-
opacity:
|
|
311
|
+
opacity: parseVideoAnimatable(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),
|
|
91
312
|
transform: parseVideoTransform(v.transform, `${path}.transform`),
|
|
92
313
|
fit: oneOf(v.fit, VIDEO_FITS, `${path}.fit`),
|
|
93
314
|
fadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),
|
|
@@ -101,7 +322,7 @@ function parseVideoClip(v, path = "clip") {
|
|
|
101
322
|
asset: str(v.asset, `${path}.asset`),
|
|
102
323
|
in: num(v.in, `${path}.in`, { optional: true, min: 0 }),
|
|
103
324
|
speed: num(v.speed, `${path}.speed`, { optional: true, min: 0.05, max: 20 }),
|
|
104
|
-
volume:
|
|
325
|
+
volume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
|
|
105
326
|
muted: bool(v.muted, `${path}.muted`)
|
|
106
327
|
});
|
|
107
328
|
if (type === "text")
|
|
@@ -311,6 +532,14 @@ function videoClipsAt(project, time) {
|
|
|
311
532
|
}
|
|
312
533
|
|
|
313
534
|
// src/video-editor/check.ts
|
|
535
|
+
function videoClipKeyframes(clip) {
|
|
536
|
+
const out = [
|
|
537
|
+
{ property: "opacity", keys: videoKeysOf(clip.opacity) },
|
|
538
|
+
{ property: "volume", keys: clip.type === "media" ? videoKeysOf(clip.volume) : [] },
|
|
539
|
+
...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p, keys: videoKeysOf(_optionalChain([clip, 'access', _3 => _3.transform, 'optionalAccess', _4 => _4[p]])) }))
|
|
540
|
+
];
|
|
541
|
+
return out.filter((o) => o.keys.length);
|
|
542
|
+
}
|
|
314
543
|
var VIDEO_ISSUE_CODES = [
|
|
315
544
|
"missing-asset",
|
|
316
545
|
"wrong-track",
|
|
@@ -320,6 +549,7 @@ var VIDEO_ISSUE_CODES = [
|
|
|
320
549
|
"transition-too-long",
|
|
321
550
|
"transition-handles",
|
|
322
551
|
"fades-too-long",
|
|
552
|
+
"keyframes-outside-clip",
|
|
323
553
|
"unknown-duration",
|
|
324
554
|
"unused-asset",
|
|
325
555
|
"marker-past-end",
|
|
@@ -355,6 +585,16 @@ function checkVideoProject(project) {
|
|
|
355
585
|
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.` });
|
|
356
586
|
if ((_nullishCoalesce(clip.fadeIn, () => ( 0))) + (_nullishCoalesce(clip.fadeOut, () => ( 0))) > clip.duration + VIDEO_TIME_EPSILON)
|
|
357
587
|
issues.push({ code: "fades-too-long", severity: "warning", ...at, message: `Clip "${clip.id}"'s fades (${_nullishCoalesce(clip.fadeIn, () => ( 0))} + ${_nullishCoalesce(clip.fadeOut, () => ( 0))} ms) are longer than the clip (${clip.duration} ms); shorten them.` });
|
|
588
|
+
for (const { property, keys } of videoClipKeyframes(clip)) {
|
|
589
|
+
const outside = keys.filter((k) => k.t < -VIDEO_TIME_EPSILON || k.t > clip.duration + VIDEO_TIME_EPSILON);
|
|
590
|
+
if (outside.length)
|
|
591
|
+
issues.push({
|
|
592
|
+
code: "keyframes-outside-clip",
|
|
593
|
+
severity: "warning",
|
|
594
|
+
...at,
|
|
595
|
+
message: `Clip "${clip.id}" has ${property} key${outside.length > 1 ? "s" : ""} at ${outside.map((k) => `${Math.round(k.t)} ms`).join(", ")}, outside the clip (0\u2013${Math.round(clip.duration)} ms in clip time), so ${outside.length > 1 ? "they never play" : "it never plays"} directly; move ${outside.length > 1 ? "them" : "it"} inside or remove ${outside.length > 1 ? "them" : "it"} (set_video_keyframes).`
|
|
596
|
+
});
|
|
597
|
+
}
|
|
358
598
|
if (clip.transition) {
|
|
359
599
|
const d = clip.transition.duration;
|
|
360
600
|
const before = videoClipBefore(track, clip);
|
|
@@ -408,6 +648,7 @@ var VIDEO_EDIT_OPS = [
|
|
|
408
648
|
"delete_clips",
|
|
409
649
|
"update_clip",
|
|
410
650
|
"set_transition",
|
|
651
|
+
"set_keyframes",
|
|
411
652
|
"close_gaps",
|
|
412
653
|
"add_marker",
|
|
413
654
|
"update_marker",
|
|
@@ -426,6 +667,21 @@ function freshId(prefix, taken) {
|
|
|
426
667
|
for (let n2 = 1; ; n2++) if (!used.has(`${prefix}${n2}`)) return `${prefix}${n2}`;
|
|
427
668
|
}
|
|
428
669
|
var slug = (s) => s.replace(/^.*[\\/]/, "").replace(/\.[a-z0-9]+(\?.*)?$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
670
|
+
function clipAnimatables(clip) {
|
|
671
|
+
const c = clip;
|
|
672
|
+
const out = [
|
|
673
|
+
{ get: () => c.opacity, set: (v) => c.opacity = v },
|
|
674
|
+
{ get: () => c.volume, set: (v) => c.volume = v }
|
|
675
|
+
];
|
|
676
|
+
for (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => _optionalChain([c, 'access', _5 => _5.transform, 'optionalAccess', _6 => _6[p]]), set: (v) => c.transform && (c.transform[p] = v) });
|
|
677
|
+
return out;
|
|
678
|
+
}
|
|
679
|
+
function retimeClipKeys(clip, fn) {
|
|
680
|
+
for (const a of clipAnimatables(clip)) {
|
|
681
|
+
const v = a.get();
|
|
682
|
+
if (isVideoKeyframes(v)) a.set(fn(v));
|
|
683
|
+
}
|
|
684
|
+
}
|
|
429
685
|
var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
|
|
430
686
|
var Editor = (_class = class {
|
|
431
687
|
constructor(p) {;_class.prototype.__init.call(this);
|
|
@@ -464,7 +720,7 @@ var Editor = (_class = class {
|
|
|
464
720
|
kindFits(track, clip) {
|
|
465
721
|
const asset = clip.type === "media" ? this.asset(clip.asset) : null;
|
|
466
722
|
if (track.kind === "audio" && (clip.type !== "media" || asset.kind === "image")) refuse(`Track "${track.id}" is an audio track; ${clip.type === "media" ? "images" : `${clip.type} clips`} go on a video track.`);
|
|
467
|
-
if (track.kind === "video" && _optionalChain([asset, 'optionalAccess',
|
|
723
|
+
if (track.kind === "video" && _optionalChain([asset, 'optionalAccess', _7 => _7.kind]) === "audio") refuse(`Track "${track.id}" is a video track; audio asset "${asset.id}" goes on an audio track.`);
|
|
468
724
|
}
|
|
469
725
|
/** Moves every clip on `track` starting at or after `from` by `delta`. */
|
|
470
726
|
shift(track, from, delta, except = /* @__PURE__ */ new Set()) {
|
|
@@ -596,6 +852,7 @@ var Editor = (_class = class {
|
|
|
596
852
|
clip.in = Math.max(0, sourceIn);
|
|
597
853
|
if (clip.in === 0) delete clip.in;
|
|
598
854
|
}
|
|
855
|
+
retimeClipKeys(clip, (v) => retimeVideoAnimatable(v, -delta));
|
|
599
856
|
if (e.ripple) {
|
|
600
857
|
clip.duration -= delta;
|
|
601
858
|
this.shift(track, end, -delta, /* @__PURE__ */ new Set([clip.id]));
|
|
@@ -611,7 +868,7 @@ var Editor = (_class = class {
|
|
|
611
868
|
if (clip.type === "media") {
|
|
612
869
|
const asset = assets.get(clip.asset);
|
|
613
870
|
const out = (_nullishCoalesce(clip.in, () => ( 0))) + duration * speed;
|
|
614
|
-
if (_optionalChain([asset, 'optionalAccess',
|
|
871
|
+
if (_optionalChain([asset, 'optionalAccess', _8 => _8.duration]) !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
|
|
615
872
|
refuse(`"${clip.id}" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (_nullishCoalesce(clip.in, () => ( 0)))) / speed)}, where "${asset.id}" runs out.`);
|
|
616
873
|
}
|
|
617
874
|
if (e.ripple) this.shift(track, end, e.to - end, /* @__PURE__ */ new Set([clip.id]));
|
|
@@ -633,6 +890,12 @@ var Editor = (_class = class {
|
|
|
633
890
|
const right = { ...structuredClone(clip), id, start: e.at, duration: end - e.at };
|
|
634
891
|
delete right.transition;
|
|
635
892
|
delete right.fadeIn;
|
|
893
|
+
const leftValues = clipAnimatables(clip);
|
|
894
|
+
clipAnimatables(right).forEach((r, i) => {
|
|
895
|
+
const [l, rr] = splitVideoAnimatable(leftValues[i].get(), left);
|
|
896
|
+
if (isVideoKeyframes(l)) leftValues[i].set(l);
|
|
897
|
+
if (isVideoKeyframes(rr)) r.set(rr);
|
|
898
|
+
});
|
|
636
899
|
if (clip.type === "media") {
|
|
637
900
|
right.in = (_nullishCoalesce(clip.in, () => ( 0))) + left * videoClipSpeed(clip);
|
|
638
901
|
}
|
|
@@ -659,12 +922,17 @@ var Editor = (_class = class {
|
|
|
659
922
|
for (const k of ["id", "type"]) if (k in set) refuse(`A clip's ${k} cannot be changed; delete it and add a new one.`);
|
|
660
923
|
for (const k of _nullishCoalesce(e.remove, () => ( []))) if (!OPTIONAL_CLIP_FIELDS.includes(k)) refuse(`"${k}" cannot be removed; removable fields are ${OPTIONAL_CLIP_FIELDS.join(", ")}.`);
|
|
661
924
|
if ("speed" in set && (typeof set.speed !== "number" || !(set.speed > 0))) refuse('"speed" must be a number greater than 0 (1 = normal speed).');
|
|
925
|
+
let stretch = 1;
|
|
662
926
|
if (at.clip.type === "media" && !("duration" in set)) {
|
|
663
927
|
const was = videoClipSpeed(at.clip);
|
|
664
|
-
const now = "speed" in set ? set.speed : _optionalChain([e, 'access',
|
|
665
|
-
if (now !== was)
|
|
928
|
+
const now = "speed" in set ? set.speed : _optionalChain([e, 'access', _9 => _9.remove, 'optionalAccess', _10 => _10.includes, 'call', _11 => _11("speed")]) ? 1 : was;
|
|
929
|
+
if (now !== was) {
|
|
930
|
+
set.duration = at.clip.duration * was / now;
|
|
931
|
+
stretch = was / now;
|
|
932
|
+
}
|
|
666
933
|
}
|
|
667
|
-
const next = { ...at.clip, ...set };
|
|
934
|
+
const next = { ...structuredClone(at.clip), ...set };
|
|
935
|
+
if (stretch !== 1) retimeClipKeys(next, (v) => retimeVideoAnimatable(v, 0, stretch));
|
|
668
936
|
for (const k of _nullishCoalesce(e.remove, () => ( []))) delete next[k];
|
|
669
937
|
const clip = parseVideoClip(next, "set");
|
|
670
938
|
if (clip.type === "media" && clip.asset !== at.clip.asset) this.kindFits(at.track, clip);
|
|
@@ -680,6 +948,37 @@ var Editor = (_class = class {
|
|
|
680
948
|
else clip.transition = parseVideoTransition(e.transition, "transition");
|
|
681
949
|
return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
|
|
682
950
|
}
|
|
951
|
+
case "set_keyframes": {
|
|
952
|
+
const { track, clip } = this.clip(e.clip);
|
|
953
|
+
this.unlocked(track);
|
|
954
|
+
if (!VIDEO_KEYFRAME_PROPERTIES.includes(e.property)) refuse(`"${e.property}" cannot be keyframed; use one of ${VIDEO_KEYFRAME_PROPERTIES.join(", ")}.`);
|
|
955
|
+
if (e.property === "volume" && clip.type !== "media") refuse(`Clip "${clip.id}" is a ${clip.type} clip and has no volume; volume keys go on media clips.`);
|
|
956
|
+
const limits = VIDEO_KEYFRAME_LIMITS[e.property];
|
|
957
|
+
const transform = VIDEO_TRANSFORM_PROPERTIES.includes(e.property);
|
|
958
|
+
const c = clip;
|
|
959
|
+
const current = transform ? _optionalChain([c, 'access', _12 => _12.transform, 'optionalAccess', _13 => _13[e.property]]) : c[e.property];
|
|
960
|
+
let value;
|
|
961
|
+
if (e.keys === null) {
|
|
962
|
+
const v = videoValueAt(current, 0, limits.default);
|
|
963
|
+
value = isVideoKeyframes(current) ? v === limits.default ? void 0 : v : current;
|
|
964
|
+
} else {
|
|
965
|
+
if (!Array.isArray(e.keys) || !e.keys.length) refuse('"keys" must list at least one key { t, v }; pass null to remove the keyframes.');
|
|
966
|
+
let sorted = [];
|
|
967
|
+
try {
|
|
968
|
+
sorted = sortVideoKeys(e.keys);
|
|
969
|
+
} catch (error) {
|
|
970
|
+
refuse(error.message);
|
|
971
|
+
}
|
|
972
|
+
value = parseVideoAnimatable({ keys: sorted }, e.property, limits);
|
|
973
|
+
}
|
|
974
|
+
if (transform) {
|
|
975
|
+
c.transform = { ...c.transform, [e.property]: value };
|
|
976
|
+
if (value === void 0) delete c.transform[e.property];
|
|
977
|
+
if (!Object.keys(c.transform).length) delete c.transform;
|
|
978
|
+
} else if (value === void 0) delete c[e.property];
|
|
979
|
+
else c[e.property] = value;
|
|
980
|
+
return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
|
|
981
|
+
}
|
|
683
982
|
case "close_gaps": {
|
|
684
983
|
const track = this.track(e.track);
|
|
685
984
|
this.unlocked(track);
|
|
@@ -739,7 +1038,7 @@ function applyVideoEdits(project, edits) {
|
|
|
739
1038
|
editor.run(edit);
|
|
740
1039
|
} catch (error) {
|
|
741
1040
|
const message = error.message;
|
|
742
|
-
return { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess',
|
|
1041
|
+
return { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess', _14 => _14.op])}): ` : ""}${message}${/Nothing was changed\.$/.test(message) ? "" : " Nothing was changed."}` };
|
|
743
1042
|
}
|
|
744
1043
|
}
|
|
745
1044
|
let next;
|
|
@@ -757,7 +1056,10 @@ function applyVideoEdits(project, edits) {
|
|
|
757
1056
|
}
|
|
758
1057
|
|
|
759
1058
|
// src/video-editor/evaluate.ts
|
|
760
|
-
function
|
|
1059
|
+
function videoTransformAt(transform, clipTime = 0) {
|
|
1060
|
+
return { x: videoValueAt(_optionalChain([transform, 'optionalAccess', _15 => _15.x]), clipTime, 0), y: videoValueAt(_optionalChain([transform, 'optionalAccess', _16 => _16.y]), clipTime, 0), scale: videoValueAt(_optionalChain([transform, 'optionalAccess', _17 => _17.scale]), clipTime, 1), rotation: videoValueAt(_optionalChain([transform, 'optionalAccess', _18 => _18.rotation]), clipTime, 0) };
|
|
1061
|
+
}
|
|
1062
|
+
function placeVideoRect(frame, size, fit = "contain", transform = {}, clipTime = 0) {
|
|
761
1063
|
let w = size.width;
|
|
762
1064
|
let h = size.height;
|
|
763
1065
|
if (fit === "fill") {
|
|
@@ -768,18 +1070,18 @@ function placeVideoRect(frame, size, fit = "contain", transform = {}) {
|
|
|
768
1070
|
w *= k;
|
|
769
1071
|
h *= k;
|
|
770
1072
|
}
|
|
771
|
-
const
|
|
772
|
-
w *= scale;
|
|
773
|
-
h *= scale;
|
|
774
|
-
const cx = frame.width / 2 +
|
|
775
|
-
const cy = frame.height / 2 +
|
|
1073
|
+
const t = videoTransformAt(transform, clipTime);
|
|
1074
|
+
w *= t.scale;
|
|
1075
|
+
h *= t.scale;
|
|
1076
|
+
const cx = frame.width / 2 + t.x;
|
|
1077
|
+
const cy = frame.height / 2 + t.y;
|
|
776
1078
|
return { x: cx - w / 2, y: cy - h / 2, width: w, height: h };
|
|
777
1079
|
}
|
|
778
1080
|
function videoTextStyle(clip) {
|
|
779
1081
|
return { text: clip.text, color: _nullishCoalesce(clip.color, () => ( "#ffffff")), fontSize: _nullishCoalesce(clip.fontSize, () => ( 64)), fontFamily: _nullishCoalesce(clip.fontFamily, () => ( "sans-serif")), fontWeight: _nullishCoalesce(clip.fontWeight, () => ( 700)), ...clip.background ? { background: clip.background } : {} };
|
|
780
1082
|
}
|
|
781
|
-
function videoTitleFrame(project, clip) {
|
|
782
|
-
const layer = { clip: clip.id, track: "", type: "text", opacity: 1, rect: placeVideoRect(project, project, "fill", clip.transform), rotation:
|
|
1083
|
+
function videoTitleFrame(project, clip, clipTime = 0) {
|
|
1084
|
+
const layer = { clip: clip.id, track: "", type: "text", opacity: 1, rect: placeVideoRect(project, project, "fill", clip.transform, clipTime), rotation: videoTransformAt(clip.transform, clipTime).rotation, text: videoTextStyle(clip) };
|
|
783
1085
|
return { time: clip.start, width: project.width, height: project.height, background: "rgba(0,0,0,0)", layers: [layer], audio: [] };
|
|
784
1086
|
}
|
|
785
1087
|
var clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
@@ -795,7 +1097,7 @@ function activeOnTrack(track, time) {
|
|
|
795
1097
|
const before = clip.transition ? videoClipBefore(track, clip) : null;
|
|
796
1098
|
const lead = before ? clip.transition.duration / 2 : 0;
|
|
797
1099
|
const next = videoClipAfter(track, clip);
|
|
798
|
-
const tail = _optionalChain([next, 'optionalAccess',
|
|
1100
|
+
const tail = _optionalChain([next, 'optionalAccess', _19 => _19.transition]) ? next.transition.duration / 2 : 0;
|
|
799
1101
|
if (time < clip.start - lead || time >= videoClipEnd(clip) + tail) continue;
|
|
800
1102
|
const a = { clip };
|
|
801
1103
|
if (lead && time < clip.start + lead) a.incoming = { progress: clamp01((time - (clip.start - lead)) / (2 * lead)), type: clip.transition.type, transition: clip.transition };
|
|
@@ -812,21 +1114,22 @@ function videoFrameAt(project, time) {
|
|
|
812
1114
|
for (let ti = project.tracks.length - 1; ti >= 0; ti--) {
|
|
813
1115
|
const track = project.tracks[ti];
|
|
814
1116
|
for (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {
|
|
815
|
-
if (_optionalChain([incoming, 'optionalAccess',
|
|
816
|
-
if (_optionalChain([outgoing, 'optionalAccess',
|
|
1117
|
+
if (_optionalChain([incoming, 'optionalAccess', _20 => _20.type]) === "dip" && incoming.progress < 0.5) continue;
|
|
1118
|
+
if (_optionalChain([outgoing, 'optionalAccess', _21 => _21.type]) === "dip" && outgoing.progress >= 0.5) continue;
|
|
817
1119
|
const fade = fadeGain(clip, Math.min(Math.max(time, clip.start), videoClipEnd(clip)));
|
|
818
1120
|
const level = (t, into) => t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress;
|
|
819
1121
|
const mix = (incoming ? level(incoming, true) : 1) * (outgoing ? level(outgoing, false) : 1);
|
|
820
1122
|
const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
|
|
821
1123
|
const sourceTime = sourceTimeAt(clip, time);
|
|
1124
|
+
const clipTime = time - clip.start;
|
|
822
1125
|
const audible = asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
|
|
823
1126
|
if (audible && clip.type === "media" && !clip.muted && !track.muted) {
|
|
824
|
-
const gain = (
|
|
1127
|
+
const gain = videoValueAt(clip.volume, clipTime, 1) * (_nullishCoalesce(track.volume, () => ( 1))) * fade * mix;
|
|
825
1128
|
audio.push({ clip: clip.id, track: track.id, asset: asset.id, src: asset.src, sourceTime, gain, speed: videoClipSpeed(clip) });
|
|
826
1129
|
}
|
|
827
1130
|
if (track.kind !== "video" || track.hidden) continue;
|
|
828
1131
|
if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
|
|
829
|
-
let opacity = (
|
|
1132
|
+
let opacity = videoValueAt(clip.opacity, clipTime, 1) * fade;
|
|
830
1133
|
let wipe;
|
|
831
1134
|
let dip = null;
|
|
832
1135
|
if (incoming) {
|
|
@@ -834,11 +1137,11 @@ function videoFrameAt(project, time) {
|
|
|
834
1137
|
else if (incoming.type === "wipe") wipe = { direction: _nullishCoalesce(incoming.transition.direction, () => ( "right")), progress: incoming.progress };
|
|
835
1138
|
else dip = { color: _nullishCoalesce(incoming.transition.color, () => ( "#000000")), opacity: (1 - incoming.progress) * 2 };
|
|
836
1139
|
}
|
|
837
|
-
if (_optionalChain([outgoing, 'optionalAccess',
|
|
1140
|
+
if (_optionalChain([outgoing, 'optionalAccess', _22 => _22.type]) === "dip") dip = { color: _nullishCoalesce(outgoing.next.transition.color, () => ( "#000000")), opacity: outgoing.progress * 2 };
|
|
838
1141
|
const size = clip.type === "media" ? { width: _nullishCoalesce(asset.width, () => ( project.width)), height: _nullishCoalesce(asset.height, () => ( project.height)) } : { width: project.width, height: project.height };
|
|
839
1142
|
const fit = _nullishCoalesce(clip.fit, () => ( (clip.type === "media" ? "contain" : "fill")));
|
|
840
|
-
const rect = clip.type === "text" ?
|
|
841
|
-
const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation:
|
|
1143
|
+
const rect = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, clip.transform, clipTime);
|
|
1144
|
+
const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(_optionalChain([clip, 'access', _23 => _23.transform, 'optionalAccess', _24 => _24.rotation]), clipTime, 0) };
|
|
842
1145
|
if (wipe) layer.wipe = wipe;
|
|
843
1146
|
if (clip.type === "media") Object.assign(layer, { asset: asset.id, assetKind: asset.kind, src: asset.src, sourceTime });
|
|
844
1147
|
if (clip.type === "text") layer.text = videoTextStyle(clip);
|
|
@@ -983,7 +1286,7 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
|
|
|
983
1286
|
}
|
|
984
1287
|
|
|
985
1288
|
// src/video-editor/export.ts
|
|
986
|
-
var reelName = (asset) => (_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess',
|
|
1289
|
+
var reelName = (asset) => (_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _25 => _25.name]), () => ( _optionalChain([asset, 'optionalAccess', _26 => _26.id]))), () => ( "AX"))).replace(/\.[a-z0-9]+$/i, "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 8) || "AX";
|
|
987
1290
|
function toVideoEdl(project, options = {}) {
|
|
988
1291
|
const track = options.track !== void 0 ? project.tracks.find((t) => t.id === options.track) : [...project.tracks].reverse().find((t) => t.kind === "video");
|
|
989
1292
|
if (!track) throw new Error(options.track !== void 0 ? `There is no track "${options.track}".` : "The project has no video track.");
|
|
@@ -1016,7 +1319,7 @@ function toVideoEdl(project, options = {}) {
|
|
|
1016
1319
|
lines.push(row(n2, reel, `${t.type === "wipe" ? "W001" : "D"} ${frames}`, sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
|
|
1017
1320
|
} else lines.push(row(n2, reel, "C", sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
|
|
1018
1321
|
if (clip.type === "media" && videoClipSpeed(clip) !== 1) lines.push(`M2 ${reel.padEnd(8)} ${(videoClipSpeed(clip) * fps).toFixed(1).padStart(6)} ${tc(sourceAt(clip, recIn))}`);
|
|
1019
|
-
if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess',
|
|
1322
|
+
if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _27 => _27.name]), () => ( _optionalChain([asset, 'optionalAccess', _28 => _28.src]))), () => ( clip.asset))}`);
|
|
1020
1323
|
if (clip.type === "text") lines.push(`* TITLE: ${clip.text.replace(/\n/g, " ")}`);
|
|
1021
1324
|
if (clip.type === "color") lines.push(`* COLOR: ${clip.color}`);
|
|
1022
1325
|
lines.push("");
|
|
@@ -1060,7 +1363,13 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1060
1363
|
const audio = [];
|
|
1061
1364
|
let cur = "base";
|
|
1062
1365
|
let usesDrawtext = false;
|
|
1366
|
+
const animated = /* @__PURE__ */ new Set();
|
|
1063
1367
|
let k = 0;
|
|
1368
|
+
const step = 2e3 / fps;
|
|
1369
|
+
const expr = (prop, clipTime, fallback, what) => {
|
|
1370
|
+
if (isVideoKeyframes(prop)) animated.add(what);
|
|
1371
|
+
return videoKeyframeFfmpegExpr(prop, clipTime, fallback, step);
|
|
1372
|
+
};
|
|
1064
1373
|
const input = (clip, asset, lead, tail) => {
|
|
1065
1374
|
const speed = videoClipSpeed(clip);
|
|
1066
1375
|
const src = options.resolve ? options.resolve(asset) : asset.src;
|
|
@@ -1085,13 +1394,14 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1085
1394
|
const audible = asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
|
|
1086
1395
|
if (audible && clip.type === "media" && !clip.muted && !track.muted) {
|
|
1087
1396
|
const ai = _nullishCoalesce(index, () => ( input(clip, asset, lead, tail)));
|
|
1088
|
-
const
|
|
1089
|
-
const
|
|
1397
|
+
const trackGain = _nullishCoalesce(track.volume, () => ( 1));
|
|
1398
|
+
const volume = isVideoKeyframes(clip.volume) ? `volume='${trackGain}*(${expr(clip.volume, `(t*1000${lead ? `-${Math.round(lead)}` : ""})`, 1, "volume")})':eval=frame` : `volume=${(_nullishCoalesce(clip.volume, () => ( 1))) * trackGain}`;
|
|
1399
|
+
const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), volume];
|
|
1090
1400
|
const before2 = clip.transition ? videoClipBefore(track, clip) : null;
|
|
1091
1401
|
if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}`);
|
|
1092
1402
|
if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}`);
|
|
1093
1403
|
if (before2 && clip.transition.type === "dip") chain2.push(`afade=t=in:st=0:d=${sec(clip.transition.duration / 2)}`);
|
|
1094
|
-
const after = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && _optionalChain([c, 'access',
|
|
1404
|
+
const after = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && _optionalChain([c, 'access', _29 => _29.transition, 'optionalAccess', _30 => _30.type]) === "dip");
|
|
1095
1405
|
if (after) chain2.push(`afade=t=out:st=${sec(span - after.transition.duration / 2)}:d=${sec(after.transition.duration / 2)}`);
|
|
1096
1406
|
if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}`);
|
|
1097
1407
|
if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}`);
|
|
@@ -1102,33 +1412,43 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1102
1412
|
if (track.kind !== "video" || track.hidden) continue;
|
|
1103
1413
|
const enable = `enable='between(t,${sec(t0)},${sec(t0 + span)})'`;
|
|
1104
1414
|
const next = `v${k++}`;
|
|
1415
|
+
const onTimeline = `(t*1000-${Math.round(clip.start)})`;
|
|
1416
|
+
const onChain = (v) => `(${v}*1000${lead ? `-${Math.round(lead)}` : ""})`;
|
|
1417
|
+
const tf = clip.transform;
|
|
1418
|
+
const moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _31 => _31[p]])));
|
|
1105
1419
|
if (clip.type === "text" && options.titleImage) {
|
|
1106
|
-
inputs.push(["-loop", "1", "-framerate", String(fps), "-t", sec(span), "-i", options.titleImage(clip, { width: project.width, height: project.height })]);
|
|
1420
|
+
inputs.push(["-loop", "1", "-framerate", String(fps), "-t", sec(span), "-i", options.titleImage(moving ? { ...clip, transform: void 0 } : clip, { width: project.width, height: project.height })]);
|
|
1107
1421
|
index = inputs.length - 1;
|
|
1108
1422
|
} else if (clip.type === "text") {
|
|
1109
1423
|
usesDrawtext = true;
|
|
1110
|
-
const op = _nullishCoalesce(clip.opacity, () => ( 1));
|
|
1111
|
-
const alpha = [
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1424
|
+
const op = isVideoKeyframes(clip.opacity) ? `clip(${expr(clip.opacity, onTimeline, 1, "opacity")},0,1)` : String(_nullishCoalesce(clip.opacity, () => ( 1)));
|
|
1425
|
+
const alpha = [op, clip.fadeIn ? `min(1,(t-${sec(clip.start)})/${sec(clip.fadeIn)})` : "", clip.fadeOut ? `min(1,(${sec(end)}-t)/${sec(clip.fadeOut)})` : ""].filter(Boolean).join("*");
|
|
1426
|
+
const coord = (axis) => {
|
|
1427
|
+
const v = _optionalChain([tf, 'optionalAccess', _32 => _32[axis]]);
|
|
1428
|
+
const base = `(${axis === "x" ? "w-text_w" : "h-text_h"})/2`;
|
|
1429
|
+
return isVideoKeyframes(v) ? `${axis}='${base}+(${expr(v, onTimeline, 0, "position")})'` : `${axis}=${base}+${_nullishCoalesce(v, () => ( 0))}`;
|
|
1430
|
+
};
|
|
1431
|
+
const fontSize = _nullishCoalesce(clip.fontSize, () => ( 64));
|
|
1114
1432
|
const opts = [
|
|
1115
1433
|
`text=${ffmpegFilterValue(clip.text)}`,
|
|
1116
1434
|
options.fontFile ? `fontfile=${ffmpegFilterValue(options.fontFile)}` : `font=${ffmpegFilterValue(_nullishCoalesce(clip.fontFamily, () => ( "sans-serif")))}`,
|
|
1117
|
-
`fontsize
|
|
1435
|
+
isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _33 => _33.scale])) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, "scale")})'` : `fontsize=${Math.round(fontSize * (_nullishCoalesce(_optionalChain([tf, 'optionalAccess', _34 => _34.scale]), () => ( 1))))}`,
|
|
1118
1436
|
`fontcolor=${color(_nullishCoalesce(clip.color, () => ( "#ffffff")))}`,
|
|
1119
|
-
|
|
1120
|
-
|
|
1437
|
+
coord("x"),
|
|
1438
|
+
coord("y"),
|
|
1121
1439
|
`alpha='${alpha}'`,
|
|
1122
1440
|
...clip.background ? ["box=1", `boxcolor=${color(clip.background)}`, `boxborderw=${Math.round((_nullishCoalesce(clip.fontSize, () => ( 64))) / 2)}`] : [],
|
|
1123
1441
|
`enable='between(t,${sec(clip.start)},${sec(end)})'`
|
|
1124
1442
|
];
|
|
1125
|
-
if (_optionalChain([
|
|
1443
|
+
if (_optionalChain([tf, 'optionalAccess', _35 => _35.rotation])) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
|
|
1126
1444
|
graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
|
|
1127
1445
|
cur = next;
|
|
1128
1446
|
continue;
|
|
1129
1447
|
}
|
|
1130
1448
|
const size = asset ? { width: _nullishCoalesce(asset.width, () => ( project.width)), height: _nullishCoalesce(asset.height, () => ( project.height)) } : { width: project.width, height: project.height };
|
|
1131
|
-
const
|
|
1449
|
+
const placed = clip.type === "text" && !moving ? void 0 : tf;
|
|
1450
|
+
const still = Object.fromEntries(Object.entries(_nullishCoalesce(placed, () => ( {}))).filter(([, v]) => !isVideoKeyframes(v)));
|
|
1451
|
+
const rect = placeVideoRect(project, size, clip.type === "text" ? "fill" : _nullishCoalesce(clip.fit, () => ( (clip.type === "media" ? "contain" : "fill"))), still);
|
|
1132
1452
|
const chain = [];
|
|
1133
1453
|
let source;
|
|
1134
1454
|
if (clip.type === "color") source = `color=c=${color(clip.color)}:s=${even(rect.width)}x${even(rect.height)}:r=${fps}:d=${sec(span)}`;
|
|
@@ -1136,8 +1456,13 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1136
1456
|
source = `[${index}:v]`;
|
|
1137
1457
|
chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
|
|
1138
1458
|
}
|
|
1459
|
+
if (isVideoKeyframes(_optionalChain([placed, 'optionalAccess', _36 => _36.scale]))) {
|
|
1460
|
+
const s = expr(placed.scale, onChain("t"), 1, "scale");
|
|
1461
|
+
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`);
|
|
1462
|
+
}
|
|
1139
1463
|
chain.push("format=yuva420p");
|
|
1140
|
-
if (clip.opacity
|
|
1464
|
+
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)'`);
|
|
1465
|
+
else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
|
|
1141
1466
|
if (clip.fadeIn) chain.push(`fade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}:alpha=1`);
|
|
1142
1467
|
if (clip.fadeOut) chain.push(`fade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}:alpha=1`);
|
|
1143
1468
|
if (lead && clip.transition.type === "crossfade") chain.push(`fade=t=in:st=0:d=${sec(2 * lead)}:alpha=1`);
|
|
@@ -1148,12 +1473,18 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1148
1473
|
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}))`;
|
|
1149
1474
|
chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
|
|
1150
1475
|
}
|
|
1151
|
-
|
|
1476
|
+
const rotation = _optionalChain([placed, 'optionalAccess', _37 => _37.rotation]);
|
|
1477
|
+
if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=none:ow='hypot(iw,ih)':oh=ow`);
|
|
1478
|
+
else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=none:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
|
|
1152
1479
|
chain.push(`setpts=PTS+${sec(t0)}/TB`);
|
|
1153
1480
|
const cx = rect.x + rect.width / 2;
|
|
1154
1481
|
const cy = rect.y + rect.height / 2;
|
|
1482
|
+
const at = (axis, c) => {
|
|
1483
|
+
const v = _optionalChain([placed, 'optionalAccess', _38 => _38[axis]]);
|
|
1484
|
+
return isVideoKeyframes(v) ? `${axis}='${Math.round(axis === "x" ? project.width / 2 : project.height / 2)}+(${expr(v, onTimeline, 0, "position")})-${axis === "x" ? "w" : "h"}/2'` : `${axis}='${Math.round(c)}-${axis === "x" ? "w" : "h"}/2'`;
|
|
1485
|
+
};
|
|
1155
1486
|
graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
|
|
1156
|
-
graph.push(`[${cur}][l${next}]overlay
|
|
1487
|
+
graph.push(`[${cur}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable}[${next}]`);
|
|
1157
1488
|
cur = next;
|
|
1158
1489
|
const before = clip.transition ? videoClipBefore(track, clip) : null;
|
|
1159
1490
|
if (before && clip.transition.type === "dip") {
|
|
@@ -1167,8 +1498,10 @@ function toVideoFfmpeg(project, options = {}) {
|
|
|
1167
1498
|
}
|
|
1168
1499
|
graph.push(`[${cur}]format=yuv420p[vout]`);
|
|
1169
1500
|
if (audio.length) graph.push(`${audio.join("")}amix=inputs=${audio.length}:normalize=0:duration=longest,apad,atrim=0:${sec(D)}[aout]`);
|
|
1170
|
-
if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && _optionalChain([assets, 'access',
|
|
1501
|
+
if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && _optionalChain([assets, 'access', _39 => _39.get, 'call', _40 => _40(c.asset), 'optionalAccess', _41 => _41.kind]) === "video" && _optionalChain([assets, 'access', _42 => _42.get, 'call', _43 => _43(c.asset), 'optionalAccess', _44 => _44.hasAudio]) === void 0)))
|
|
1171
1502
|
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.');
|
|
1503
|
+
if (animated.size)
|
|
1504
|
+
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)" : ""}.`);
|
|
1172
1505
|
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.");
|
|
1173
1506
|
const filterComplex = graph.join(";");
|
|
1174
1507
|
const args = [
|
|
@@ -1199,7 +1532,7 @@ var VideoPreviewEngine = (_class2 = class {
|
|
|
1199
1532
|
this.source = source;
|
|
1200
1533
|
this.options = options;
|
|
1201
1534
|
this.canvas = canvas;
|
|
1202
|
-
this.ctx = options.context !== void 0 ? options.context : _nullishCoalesce(_optionalChain([canvas, 'access',
|
|
1535
|
+
this.ctx = options.context !== void 0 ? options.context : _nullishCoalesce(_optionalChain([canvas, 'access', _45 => _45.getContext, 'optionalCall', _46 => _46("2d")]), () => ( null));
|
|
1203
1536
|
this.unsubscribe = source.subscribe(() => this.update());
|
|
1204
1537
|
this.update();
|
|
1205
1538
|
}
|
|
@@ -1247,8 +1580,8 @@ var VideoPreviewEngine = (_class2 = class {
|
|
|
1247
1580
|
entry.media.pause();
|
|
1248
1581
|
entry.media.removeEventListener("seeked", entry.onReady);
|
|
1249
1582
|
entry.media.removeEventListener("loadeddata", entry.onReady);
|
|
1250
|
-
_optionalChain([entry, 'access',
|
|
1251
|
-
_optionalChain([entry, 'access',
|
|
1583
|
+
_optionalChain([entry, 'access', _47 => _47.media, 'access', _48 => _48.removeAttribute, 'optionalCall', _49 => _49("src")]);
|
|
1584
|
+
_optionalChain([entry, 'access', _50 => _50.media, 'access', _51 => _51.load, 'optionalCall', _52 => _52()]);
|
|
1252
1585
|
this.media.delete(entry.clip);
|
|
1253
1586
|
}
|
|
1254
1587
|
/** Brings the media elements in step with the editor and draws the frame. */
|
|
@@ -1281,7 +1614,7 @@ var VideoPreviewEngine = (_class2 = class {
|
|
|
1281
1614
|
for (const layer of frame.layers) {
|
|
1282
1615
|
if (layer.type !== "media" || layer.assetKind !== "video" || !layer.src) continue;
|
|
1283
1616
|
const sound = sounding.get(layer.clip);
|
|
1284
|
-
sync(layer.clip, layer.src, "video", _nullishCoalesce(layer.sourceTime, () => ( 0)), _nullishCoalesce(_optionalChain([sound, 'optionalAccess',
|
|
1617
|
+
sync(layer.clip, layer.src, "video", _nullishCoalesce(layer.sourceTime, () => ( 0)), _nullishCoalesce(_optionalChain([sound, 'optionalAccess', _53 => _53.speed]), () => ( 1)), sound ? sound.gain : null);
|
|
1285
1618
|
sounding.delete(layer.clip);
|
|
1286
1619
|
}
|
|
1287
1620
|
for (const a of sounding.values()) sync(a.clip, a.src, "audio", a.sourceTime, a.speed, a.gain);
|
|
@@ -1314,7 +1647,7 @@ var VideoPreviewEngine = (_class2 = class {
|
|
|
1314
1647
|
if (this.canvas.width !== w) this.canvas.width = w;
|
|
1315
1648
|
if (this.canvas.height !== h) this.canvas.height = h;
|
|
1316
1649
|
drawVideoFrame(this.ctx, frame, this.sourceFor, resolution);
|
|
1317
|
-
_optionalChain([this, 'access',
|
|
1650
|
+
_optionalChain([this, 'access', _54 => _54.options, 'access', _55 => _55.onFrame, 'optionalCall', _56 => _56(frame)]);
|
|
1318
1651
|
}
|
|
1319
1652
|
/** Stops following the editor and releases every media element. */
|
|
1320
1653
|
destroy() {
|
|
@@ -1421,7 +1754,7 @@ var VideoEditorController = (_class3 = class {
|
|
|
1421
1754
|
this.applying = false;
|
|
1422
1755
|
}
|
|
1423
1756
|
this.notify();
|
|
1424
|
-
_optionalChain([this, 'access',
|
|
1757
|
+
_optionalChain([this, 'access', _57 => _57.onChange, 'optionalCall', _58 => _58(project)]);
|
|
1425
1758
|
}
|
|
1426
1759
|
commit(label, next) {
|
|
1427
1760
|
const before = this.project;
|
|
@@ -1485,7 +1818,7 @@ var VideoEditorController = (_class3 = class {
|
|
|
1485
1818
|
}
|
|
1486
1819
|
/** Selects clips (unknown ids are dropped). `add` toggles them into the current selection. */
|
|
1487
1820
|
select(clips, options = {}) {
|
|
1488
|
-
const known = _nullishCoalesce(_optionalChain([clips, 'optionalAccess',
|
|
1821
|
+
const known = _nullishCoalesce(_optionalChain([clips, 'optionalAccess', _59 => _59.filter, 'call', _60 => _60((id) => findVideoClip(this.project, id))]), () => ( []));
|
|
1489
1822
|
let next;
|
|
1490
1823
|
if (options.add) {
|
|
1491
1824
|
next = [...this.selection];
|
|
@@ -1604,7 +1937,7 @@ var TRANSITION = object(
|
|
|
1604
1937
|
);
|
|
1605
1938
|
var CLIP_COMMON = {
|
|
1606
1939
|
name: { type: "string", description: "A label for the clip." },
|
|
1607
|
-
opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1)." },
|
|
1940
|
+
opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1). A number replaces any keyframes; set_video_keyframes animates it." },
|
|
1608
1941
|
transform: TRANSFORM,
|
|
1609
1942
|
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)." },
|
|
1610
1943
|
fadeIn: MS("Fade up from nothing (picture and sound) over this long"),
|
|
@@ -1613,7 +1946,7 @@ var CLIP_COMMON = {
|
|
|
1613
1946
|
var MEDIA_FIELDS = {
|
|
1614
1947
|
in: MS("Where in the source it starts (default 0)"),
|
|
1615
1948
|
speed: { type: "number", minimum: 0.05, maximum: 20, description: "Playback speed (default 1). Changing it keeps the stretch of source, so the clip gets shorter or longer." },
|
|
1616
|
-
volume: { type: "number", minimum: 0, maximum: 4, description: "Gain (1 = unchanged, default 1)." },
|
|
1949
|
+
volume: { type: "number", minimum: 0, maximum: 4, description: "Gain (1 = unchanged, default 1). A number replaces any keyframes; set_video_keyframes animates it." },
|
|
1617
1950
|
muted: { type: "boolean", description: "Silence this clip (default false)." }
|
|
1618
1951
|
};
|
|
1619
1952
|
var TEXT_FIELDS = {
|
|
@@ -1660,6 +1993,32 @@ var ASSET_FIELDS = {
|
|
|
1660
1993
|
hasAudio: { type: "boolean", description: "For video: whether it has sound (default true)." },
|
|
1661
1994
|
poster: { type: "string", description: "For video: a still (URL) to show in rendered frames." }
|
|
1662
1995
|
};
|
|
1996
|
+
var KEY_PROPERTY = {
|
|
1997
|
+
type: "string",
|
|
1998
|
+
enum: [...VIDEO_KEYFRAME_PROPERTIES],
|
|
1999
|
+
description: "Which property: opacity (0\u20131), volume (media only, 0\u20134 gain), x / y (pixels from the frame's centre, right / down), scale (1 = fitted size) or rotation (degrees clockwise)."
|
|
2000
|
+
};
|
|
2001
|
+
var KEYS = {
|
|
2002
|
+
type: "array",
|
|
2003
|
+
minItems: 1,
|
|
2004
|
+
maxItems: 500,
|
|
2005
|
+
description: "The keys, replacing any the property had (any order, one per time). Before the first key the property holds the first value, after the last the last value.",
|
|
2006
|
+
items: object(
|
|
2007
|
+
{
|
|
2008
|
+
t: { type: "number", description: "Time in ms from the clip's start (clip time, not timeline time: 0 is the clip's first frame, its duration the end). Keys stay with the clip when it moves, is trimmed or split." },
|
|
2009
|
+
v: { type: "number", description: "The value at that time, in the property's units." },
|
|
2010
|
+
ease: {
|
|
2011
|
+
description: "How the value moves from this key to the next (default linear): linear, hold (stays, then jumps at the next key), ease-in, ease-out, ease-in-out, or CSS cubic-bezier points [x1, y1, x2, y2] with x1 and x2 in 0\u20131.",
|
|
2012
|
+
anyOf: [
|
|
2013
|
+
{ type: "string", enum: [...VIDEO_EASE_NAMES] },
|
|
2014
|
+
{ type: "array", items: { type: "number" }, minItems: 4, maxItems: 4 }
|
|
2015
|
+
]
|
|
2016
|
+
}
|
|
2017
|
+
},
|
|
2018
|
+
["t", "v"],
|
|
2019
|
+
"One keyframe."
|
|
2020
|
+
)
|
|
2021
|
+
};
|
|
1663
2022
|
var issueSummary = (issues) => ({
|
|
1664
2023
|
errors: issues.filter((i) => i.severity === "error").length,
|
|
1665
2024
|
warnings: issues.filter((i) => i.severity === "warning").length,
|
|
@@ -1711,6 +2070,11 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
1711
2070
|
}
|
|
1712
2071
|
return null;
|
|
1713
2072
|
};
|
|
2073
|
+
const keysOut = (p, clip, property) => {
|
|
2074
|
+
const c = clipOut(p, clip);
|
|
2075
|
+
const value = VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([c, 'optionalAccess', _61 => _61.transform, 'optionalAccess', _62 => _62[property]]) : _optionalChain([c, 'optionalAccess', _63 => _63[property]]);
|
|
2076
|
+
return typeof value === "object" ? { keys: value.keys } : { keys: [], value: _nullishCoalesce(value, () => ( null)) };
|
|
2077
|
+
};
|
|
1714
2078
|
const getProject = {
|
|
1715
2079
|
name: "get_video_project",
|
|
1716
2080
|
title: "Read the video project",
|
|
@@ -1748,7 +2112,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
1748
2112
|
const checkProject = {
|
|
1749
2113
|
name: "check_video_project",
|
|
1750
2114
|
title: "Check the video project",
|
|
1751
|
-
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, assets with no duration, unused assets.",
|
|
2115
|
+
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.",
|
|
1752
2116
|
inputSchema: object({}),
|
|
1753
2117
|
annotations: { title: "Check the video project", ...READ },
|
|
1754
2118
|
handler: () => {
|
|
@@ -1973,6 +2337,22 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
1973
2337
|
annotations: { title: "Set a transition", ...WRITE_IDEMPOTENT },
|
|
1974
2338
|
handler: ({ clip, transition }) => apply({ op: "set_transition", clip, transition }, transition ? "Set transition" : "Remove transition", (r) => ({ clip: clipOut(r.project, clip) }))
|
|
1975
2339
|
};
|
|
2340
|
+
const setKeyframes = {
|
|
2341
|
+
name: "set_video_keyframes",
|
|
2342
|
+
title: "Animate a clip property",
|
|
2343
|
+
description: "Animates one property of a clip with keyframes (one undo step), replacing any keys it had: opacity, volume, or the transform's x, y, scale and rotation. Times are clip time (ms from the clip's start), so a key at 0 is the clip's first frame; get_video_project gives each clip's duration. Plays in the preview, renders and the ffmpeg export. Returns the property's keys, sorted.",
|
|
2344
|
+
inputSchema: object({ clip: CLIP_ID, property: KEY_PROPERTY, keys: KEYS }, ["clip", "property", "keys"]),
|
|
2345
|
+
annotations: { title: "Animate a clip property", ...WRITE_IDEMPOTENT },
|
|
2346
|
+
handler: ({ clip, property, keys }) => apply({ op: "set_keyframes", clip, property, keys }, `Keyframe ${property}`, (r) => ({ clip, property, ...keysOut(r.project, clip, property) }))
|
|
2347
|
+
};
|
|
2348
|
+
const removeKeyframes = {
|
|
2349
|
+
name: "remove_video_keyframes",
|
|
2350
|
+
title: "Stop animating a clip property",
|
|
2351
|
+
description: "Removes all of a property's keyframes (one undo step), leaving it at the value it had at the clip's start. Returns the remaining keys (none) and that value (null for the default).",
|
|
2352
|
+
inputSchema: object({ clip: CLIP_ID, property: KEY_PROPERTY }, ["clip", "property"]),
|
|
2353
|
+
annotations: { title: "Stop animating a clip property", ...WRITE_IDEMPOTENT },
|
|
2354
|
+
handler: ({ clip, property }) => apply({ op: "set_keyframes", clip, property, keys: null }, `Remove ${property} keyframes`, (r) => ({ clip, property, ...keysOut(r.project, clip, property) }))
|
|
2355
|
+
};
|
|
1976
2356
|
const closeGaps = {
|
|
1977
2357
|
name: "close_video_gaps",
|
|
1978
2358
|
title: "Close gaps",
|
|
@@ -1999,7 +2379,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
1999
2379
|
};
|
|
2000
2380
|
const EDIT_OP = {
|
|
2001
2381
|
type: "object",
|
|
2002
|
-
description: `One edit: { "op": one of ${VIDEO_EDIT_OPS.filter((o) => o !== "replace_project").join(", ")}, \u2026its fields }. Fields match the single tools: add_clip { track, clip, ripple }, move_clip { clip, start, track }, trim_clip { clip, edge, to, ripple }, split_clip { clip, at, id }, delete_clips { clips, ripple }, update_clip { clip, set, remove }, set_transition { clip, transition }, close_gaps { track, from }, add_asset { asset }, update_asset { asset, set, remove }, remove_asset { asset }, add_track { track: { kind, id, \u2026 }, at }, update_track { track, set, remove }, remove_track { track }, move_track { track, to }, add_marker { marker: { time, label } }, update_marker { marker, set }, remove_marker { marker }, set_settings { name, width, height, fps, background }.`,
|
|
2382
|
+
description: `One edit: { "op": one of ${VIDEO_EDIT_OPS.filter((o) => o !== "replace_project").join(", ")}, \u2026its fields }. Fields match the single tools: add_clip { track, clip, ripple }, move_clip { clip, start, track }, trim_clip { clip, edge, to, ripple }, split_clip { clip, at, id }, delete_clips { clips, ripple }, update_clip { clip, set, remove }, set_transition { clip, transition }, set_keyframes { clip, property, keys (null removes) }, close_gaps { track, from }, add_asset { asset }, update_asset { asset, set, remove }, remove_asset { asset }, add_track { track: { kind, id, \u2026 }, at }, update_track { track, set, remove }, remove_track { track }, move_track { track, to }, add_marker { marker: { time, label } }, update_marker { marker, set }, remove_marker { marker }, set_settings { name, width, height, fps, background }.`,
|
|
2003
2383
|
properties: { op: { type: "string", enum: VIDEO_EDIT_OPS.filter((o) => o !== "replace_project"), description: "Which edit." } },
|
|
2004
2384
|
required: ["op"]
|
|
2005
2385
|
};
|
|
@@ -2040,7 +2420,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
2040
2420
|
}
|
|
2041
2421
|
};
|
|
2042
2422
|
const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportEdl, seek];
|
|
2043
|
-
const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
|
|
2423
|
+
const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setKeyframes, removeKeyframes, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
|
|
2044
2424
|
return {
|
|
2045
2425
|
id: _nullishCoalesce(options.id, () => ( "video")),
|
|
2046
2426
|
namespace: options.namespace,
|
|
@@ -2082,7 +2462,7 @@ function videoClipLabel(clip, asset) {
|
|
|
2082
2462
|
if (clip.name) return clip.name;
|
|
2083
2463
|
if (clip.type === "text") return clip.text.split("\n")[0];
|
|
2084
2464
|
if (clip.type === "color") return clip.color;
|
|
2085
|
-
const base = _nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess',
|
|
2465
|
+
const base = _nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _64 => _64.name]), () => ( _optionalChain([asset, 'optionalAccess', _65 => _65.src, 'access', _66 => _66.replace, 'call', _67 => _67(/^.*[\\/]/, "")]))), () => ( clip.asset));
|
|
2086
2466
|
const speed = videoClipSpeed(clip);
|
|
2087
2467
|
return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
|
|
2088
2468
|
}
|
|
@@ -2121,7 +2501,7 @@ function videoAssetDropEdit(project, assetId, options = {}) {
|
|
|
2121
2501
|
const asset = videoAssetMap(project).get(assetId);
|
|
2122
2502
|
if (!asset) return null;
|
|
2123
2503
|
const kind = asset.kind === "audio" ? "audio" : "video";
|
|
2124
|
-
const track = _nullishCoalesce(options.track, () => ( _optionalChain([project, 'access',
|
|
2504
|
+
const track = _nullishCoalesce(options.track, () => ( _optionalChain([project, 'access', _68 => _68.tracks, 'access', _69 => _69.find, 'call', _70 => _70((t) => t.kind === kind && !t.locked), 'optionalAccess', _71 => _71.id])));
|
|
2125
2505
|
if (!track) return null;
|
|
2126
2506
|
return { op: "add_clip", track, clip: { type: "media", asset: asset.id, ...options.start !== void 0 ? { start: options.start } : {} }, ...options.ripple ? { ripple: true } : {} };
|
|
2127
2507
|
}
|
|
@@ -2162,6 +2542,96 @@ function probeVideoMedia(src, kind, timeout = 15e3) {
|
|
|
2162
2542
|
el.src = src;
|
|
2163
2543
|
});
|
|
2164
2544
|
}
|
|
2545
|
+
var KEY_LABELS = {
|
|
2546
|
+
opacity: { label: "Opacity", step: 0.05 },
|
|
2547
|
+
volume: { label: "Volume", step: 0.05 },
|
|
2548
|
+
x: { label: "X", step: 1 },
|
|
2549
|
+
y: { label: "Y", step: 1 },
|
|
2550
|
+
scale: { label: "Scale", step: 0.05 },
|
|
2551
|
+
rotation: { label: "Rotation", step: 1 }
|
|
2552
|
+
};
|
|
2553
|
+
var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([clip, 'access', _72 => _72.transform, 'optionalAccess', _73 => _73[property]]) : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
|
|
2554
|
+
var playheadInClip = (clip, time) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));
|
|
2555
|
+
function videoKeyframeRows(project, clipId, time) {
|
|
2556
|
+
const at = findVideoClip(project, clipId);
|
|
2557
|
+
if (!at) return [];
|
|
2558
|
+
const { clip } = at;
|
|
2559
|
+
const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
|
|
2560
|
+
const visual = !(_optionalChain([asset, 'optionalAccess', _74 => _74.kind]) === "audio" || at.track.kind === "audio");
|
|
2561
|
+
const audible = clip.type === "media" && (_optionalChain([asset, 'optionalAccess', _75 => _75.kind]) === "audio" || _optionalChain([asset, 'optionalAccess', _76 => _76.kind]) === "video" && asset.hasAudio !== false);
|
|
2562
|
+
const properties = [...visual ? ["opacity"] : [], ...audible ? ["volume"] : [], ...visual ? VIDEO_TRANSFORM_PROPERTIES : []];
|
|
2563
|
+
const clipTime = playheadInClip(clip, time);
|
|
2564
|
+
return properties.map((property) => {
|
|
2565
|
+
const prop = animatableOf(clip, property);
|
|
2566
|
+
const keys = isVideoKeyframes(prop) ? prop.keys : [];
|
|
2567
|
+
const limits = VIDEO_KEYFRAME_LIMITS[property];
|
|
2568
|
+
return {
|
|
2569
|
+
property,
|
|
2570
|
+
...KEY_LABELS[property],
|
|
2571
|
+
value: Math.round(videoValueAt(prop, clipTime, limits.default) * 1e3) / 1e3,
|
|
2572
|
+
keyed: keys.length > 0,
|
|
2573
|
+
keys: keys.length,
|
|
2574
|
+
onKey: keys.some((k) => Math.abs(k.t - clipTime) < 0.5),
|
|
2575
|
+
clipTime,
|
|
2576
|
+
...limits.min !== void 0 ? { min: limits.min } : {},
|
|
2577
|
+
...limits.max !== void 0 ? { max: limits.max } : {}
|
|
2578
|
+
};
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
function plainValueEdit(clip, property, value) {
|
|
2582
|
+
if (!VIDEO_TRANSFORM_PROPERTIES.includes(property))
|
|
2583
|
+
return value === void 0 ? { op: "update_clip", clip: clip.id, remove: [property] } : { op: "update_clip", clip: clip.id, set: { [property]: value } };
|
|
2584
|
+
const transform = { ...clip.transform, [property]: value };
|
|
2585
|
+
if (value === void 0) delete transform[property];
|
|
2586
|
+
return Object.keys(transform).length ? { op: "update_clip", clip: clip.id, set: { transform } } : { op: "update_clip", clip: clip.id, remove: ["transform"] };
|
|
2587
|
+
}
|
|
2588
|
+
function videoKeyframeValueEdit(project, clipId, property, time, value) {
|
|
2589
|
+
const at = findVideoClip(project, clipId);
|
|
2590
|
+
if (!at) return null;
|
|
2591
|
+
const prop = animatableOf(at.clip, property);
|
|
2592
|
+
if (!isVideoKeyframes(prop)) return plainValueEdit(at.clip, property, value);
|
|
2593
|
+
if (value === void 0) return null;
|
|
2594
|
+
return { op: "set_keyframes", clip: clipId, property, keys: setVideoKey(prop, { t: playheadInClip(at.clip, time), v: value }, VIDEO_KEYFRAME_LIMITS[property].default).keys };
|
|
2595
|
+
}
|
|
2596
|
+
function videoKeyframeToggleEdit(project, clipId, property, time) {
|
|
2597
|
+
const at = findVideoClip(project, clipId);
|
|
2598
|
+
if (!at) return null;
|
|
2599
|
+
const prop = animatableOf(at.clip, property);
|
|
2600
|
+
const t = playheadInClip(at.clip, time);
|
|
2601
|
+
const fallback = VIDEO_KEYFRAME_LIMITS[property].default;
|
|
2602
|
+
if (isVideoKeyframes(prop)) {
|
|
2603
|
+
const key = prop.keys.find((k) => Math.abs(k.t - t) < 0.5);
|
|
2604
|
+
if (key) {
|
|
2605
|
+
const rest = removeVideoKey(prop, key.t);
|
|
2606
|
+
return isVideoKeyframes(rest) ? { op: "set_keyframes", clip: clipId, property, keys: rest.keys } : plainValueEdit(at.clip, property, rest === fallback ? void 0 : rest);
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
const v = videoValueAt(prop, t, fallback);
|
|
2610
|
+
return { op: "set_keyframes", clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
|
|
2614
|
+
|
|
2615
|
+
|
|
2616
|
+
|
|
2617
|
+
|
|
2618
|
+
|
|
2619
|
+
|
|
2620
|
+
|
|
2621
|
+
|
|
2622
|
+
|
|
2623
|
+
|
|
2624
|
+
|
|
2625
|
+
|
|
2626
|
+
|
|
2627
|
+
|
|
2628
|
+
|
|
2629
|
+
|
|
2630
|
+
|
|
2631
|
+
|
|
2632
|
+
|
|
2633
|
+
|
|
2634
|
+
|
|
2165
2635
|
|
|
2166
2636
|
|
|
2167
2637
|
|
|
@@ -2230,5 +2700,5 @@ function probeVideoMedia(src, kind, timeout = 15e3) {
|
|
|
2230
2700
|
|
|
2231
2701
|
|
|
2232
2702
|
|
|
2233
|
-
exports.VIDEO_CLIP_TYPES = VIDEO_CLIP_TYPES; exports.VIDEO_DEFAULT_STILL_DURATION = VIDEO_DEFAULT_STILL_DURATION; exports.VIDEO_DEFAULT_TITLE_DURATION = VIDEO_DEFAULT_TITLE_DURATION; exports.VIDEO_EDIT_OPS = VIDEO_EDIT_OPS; exports.VIDEO_FITS = VIDEO_FITS; exports.VIDEO_ISSUE_CODES = VIDEO_ISSUE_CODES; exports.VIDEO_TIME_EPSILON = VIDEO_TIME_EPSILON; exports.VIDEO_TRANSITION_TYPES = VIDEO_TRANSITION_TYPES; exports.VIDEO_WIPE_DIRECTIONS = VIDEO_WIPE_DIRECTIONS; exports.VideoEditorController = VideoEditorController; exports.VideoPreviewEngine = VideoPreviewEngine; exports.allVideoClipIds = allVideoClipIds; exports.applyVideoEdits = applyVideoEdits; exports.checkVideoProject = checkVideoProject; exports.createVideoEditorToolProvider = createVideoEditorToolProvider; exports.createVideoProject = createVideoProject; exports.describeVideoProject = describeVideoProject; exports.drawVideoFrame = drawVideoFrame; exports.ffmpegFilterValue = ffmpegFilterValue; exports.findVideoClip = findVideoClip; exports.findVideoGaps = findVideoGaps; exports.formatVideoSeconds = formatVideoSeconds; exports.formatVideoTimecode = formatVideoTimecode; exports.nextFreeVideoSlot = nextFreeVideoSlot; exports.parseVideoAsset = parseVideoAsset; exports.parseVideoClip = parseVideoClip; exports.parseVideoMarker = parseVideoMarker; exports.parseVideoProject = parseVideoProject; exports.parseVideoTrack = parseVideoTrack; exports.parseVideoTransform = parseVideoTransform; exports.parseVideoTransition = parseVideoTransition; exports.placeVideoRect = placeVideoRect; exports.planVideoClipDrag = planVideoClipDrag; exports.probeVideoMedia = probeVideoMedia; exports.snapToVideoFrame = snapToVideoFrame; exports.snapVideoTime = snapVideoTime; exports.sortedVideoClips = sortedVideoClips; exports.toVideoEdl = toVideoEdl; exports.toVideoFfmpeg = toVideoFfmpeg; exports.videoAssetDropEdit = videoAssetDropEdit; exports.videoAssetKindOf = videoAssetKindOf; exports.videoAssetMap = videoAssetMap; exports.videoClipAfter = videoClipAfter; exports.videoClipBefore = videoClipBefore; exports.videoClipEnd = videoClipEnd; exports.videoClipHandles = videoClipHandles; exports.videoClipLabel = videoClipLabel; exports.videoClipOverhang = videoClipOverhang; exports.videoClipSourceRange = videoClipSourceRange; exports.videoClipSpeed = videoClipSpeed; exports.videoClipTint = videoClipTint; exports.videoClipsAt = videoClipsAt; exports.videoContactSheetSvg = videoContactSheetSvg; exports.videoDropTracks = videoDropTracks; exports.videoFrameAt = videoFrameAt; exports.videoFrameDuration = videoFrameDuration; exports.videoFrameToSvg = videoFrameToSvg; exports.videoFrames = videoFrames; exports.videoIssueKey = videoIssueKey; exports.videoOverlap = videoOverlap; exports.videoProjectDuration = videoProjectDuration; exports.videoProjectSvg = videoProjectSvg; exports.videoRulerTicks = videoRulerTicks; exports.videoSnapPoints = videoSnapPoints; exports.videoTextStyle = videoTextStyle; exports.videoTitleFrame = videoTitleFrame; exports.videoWipeRect = videoWipeRect;
|
|
2703
|
+
exports.VIDEO_CLIP_TYPES = VIDEO_CLIP_TYPES; exports.VIDEO_DEFAULT_STILL_DURATION = VIDEO_DEFAULT_STILL_DURATION; exports.VIDEO_DEFAULT_TITLE_DURATION = VIDEO_DEFAULT_TITLE_DURATION; exports.VIDEO_EASE_NAMES = VIDEO_EASE_NAMES; exports.VIDEO_EDIT_OPS = VIDEO_EDIT_OPS; exports.VIDEO_FITS = VIDEO_FITS; exports.VIDEO_ISSUE_CODES = VIDEO_ISSUE_CODES; exports.VIDEO_KEYFRAME_LIMITS = VIDEO_KEYFRAME_LIMITS; exports.VIDEO_KEYFRAME_PROPERTIES = VIDEO_KEYFRAME_PROPERTIES; exports.VIDEO_TIME_EPSILON = VIDEO_TIME_EPSILON; exports.VIDEO_TRANSFORM_PROPERTIES = VIDEO_TRANSFORM_PROPERTIES; exports.VIDEO_TRANSITION_TYPES = VIDEO_TRANSITION_TYPES; exports.VIDEO_WIPE_DIRECTIONS = VIDEO_WIPE_DIRECTIONS; exports.VideoEditorController = VideoEditorController; exports.VideoPreviewEngine = VideoPreviewEngine; exports.allVideoClipIds = allVideoClipIds; exports.applyVideoEdits = applyVideoEdits; exports.checkVideoProject = checkVideoProject; exports.createVideoEditorToolProvider = createVideoEditorToolProvider; exports.createVideoProject = createVideoProject; exports.describeVideoProject = describeVideoProject; exports.drawVideoFrame = drawVideoFrame; exports.ffmpegFilterValue = ffmpegFilterValue; exports.findVideoClip = findVideoClip; exports.findVideoGaps = findVideoGaps; exports.formatVideoSeconds = formatVideoSeconds; exports.formatVideoTimecode = formatVideoTimecode; exports.isVideoKeyframes = isVideoKeyframes; exports.nextFreeVideoSlot = nextFreeVideoSlot; exports.parseVideoAnimatable = parseVideoAnimatable; exports.parseVideoAsset = parseVideoAsset; exports.parseVideoClip = parseVideoClip; exports.parseVideoKeyframes = parseVideoKeyframes; exports.parseVideoMarker = parseVideoMarker; exports.parseVideoProject = parseVideoProject; exports.parseVideoTrack = parseVideoTrack; exports.parseVideoTransform = parseVideoTransform; exports.parseVideoTransition = parseVideoTransition; exports.placeVideoRect = placeVideoRect; exports.planVideoClipDrag = planVideoClipDrag; exports.probeVideoMedia = probeVideoMedia; exports.removeVideoKey = removeVideoKey; exports.retimeVideoAnimatable = retimeVideoAnimatable; exports.setVideoKey = setVideoKey; exports.snapToVideoFrame = snapToVideoFrame; exports.snapVideoTime = snapVideoTime; exports.sortVideoKeys = sortVideoKeys; exports.sortedVideoClips = sortedVideoClips; exports.splitVideoAnimatable = splitVideoAnimatable; exports.toVideoEdl = toVideoEdl; exports.toVideoFfmpeg = toVideoFfmpeg; exports.videoAssetDropEdit = videoAssetDropEdit; exports.videoAssetKindOf = videoAssetKindOf; exports.videoAssetMap = videoAssetMap; exports.videoClipAfter = videoClipAfter; exports.videoClipBefore = videoClipBefore; exports.videoClipEnd = videoClipEnd; exports.videoClipHandles = videoClipHandles; exports.videoClipKeyframes = videoClipKeyframes; exports.videoClipLabel = videoClipLabel; exports.videoClipOverhang = videoClipOverhang; exports.videoClipSourceRange = videoClipSourceRange; exports.videoClipSpeed = videoClipSpeed; exports.videoClipTint = videoClipTint; exports.videoClipsAt = videoClipsAt; exports.videoContactSheetSvg = videoContactSheetSvg; exports.videoDropTracks = videoDropTracks; exports.videoEaseBezier = videoEaseBezier; exports.videoEaseProgress = videoEaseProgress; exports.videoFrameAt = videoFrameAt; exports.videoFrameDuration = videoFrameDuration; exports.videoFrameToSvg = videoFrameToSvg; exports.videoFrames = videoFrames; exports.videoIssueKey = videoIssueKey; exports.videoKeyframeBreakpoints = videoKeyframeBreakpoints; exports.videoKeyframeFfmpegExpr = videoKeyframeFfmpegExpr; exports.videoKeyframeRows = videoKeyframeRows; exports.videoKeyframeToggleEdit = videoKeyframeToggleEdit; exports.videoKeyframeValueEdit = videoKeyframeValueEdit; exports.videoKeysOf = videoKeysOf; exports.videoOverlap = videoOverlap; exports.videoProjectDuration = videoProjectDuration; exports.videoProjectSvg = videoProjectSvg; exports.videoRulerTicks = videoRulerTicks; exports.videoSnapPoints = videoSnapPoints; exports.videoTextStyle = videoTextStyle; exports.videoTitleFrame = videoTitleFrame; exports.videoTransformAt = videoTransformAt; exports.videoValueAt = videoValueAt; exports.videoWipeRect = videoWipeRect;
|
|
2234
2704
|
//# sourceMappingURL=index.cjs.map
|