@pixerate/editor 0.75.0 → 0.77.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/agent/index.cjs +28 -28
- package/dist/agent/index.js +3 -3
- package/dist/animation-editor/index.cjs +148 -218
- package/dist/animation-editor/index.cjs.map +1 -1
- package/dist/animation-editor/index.js +8 -78
- package/dist/animation-editor/index.js.map +1 -1
- package/dist/{chunk-OK7T2LHK.js → chunk-GNO5PEMH.js} +2 -2
- package/dist/{chunk-EIXXBKUB.js → chunk-IMSX4IVG.js} +7 -7
- package/dist/chunk-INZJALPY.js +87 -0
- package/dist/chunk-INZJALPY.js.map +1 -0
- package/dist/{chunk-PL3MPFPA.cjs → chunk-MXF4DP2C.cjs} +77 -77
- package/dist/{chunk-PL3MPFPA.cjs.map → chunk-MXF4DP2C.cjs.map} +1 -1
- package/dist/{chunk-T4XWVPJK.cjs → chunk-RKJDBXTY.cjs} +3 -3
- package/dist/{chunk-T4XWVPJK.cjs.map → chunk-RKJDBXTY.cjs.map} +1 -1
- package/dist/{chunk-HNDSPZ5T.cjs → chunk-ROXFP2LQ.cjs} +4 -4
- package/dist/{chunk-HNDSPZ5T.cjs.map → chunk-ROXFP2LQ.cjs.map} +1 -1
- package/dist/{chunk-WON34HKM.js → chunk-SVW2UNUC.js} +2 -2
- package/dist/{chunk-66IMF6OD.cjs → chunk-TYOAJDFD.cjs} +3 -3
- package/dist/{chunk-66IMF6OD.cjs.map → chunk-TYOAJDFD.cjs.map} +1 -1
- package/dist/chunk-WAJQKWQU.cjs +87 -0
- package/dist/chunk-WAJQKWQU.cjs.map +1 -0
- package/dist/{chunk-UKI64KUD.js → chunk-ZQNDSUZO.js} +6 -6
- package/dist/extensions/index.cjs +7 -7
- package/dist/extensions/index.cjs.map +1 -1
- package/dist/extensions/index.js +6 -6
- package/dist/index.cjs +35 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +17 -17
- package/dist/serializers/index.cjs +4 -4
- package/dist/serializers/index.cjs.map +1 -1
- package/dist/serializers/index.js +3 -3
- package/dist/video-editor/index.cjs +2234 -0
- package/dist/video-editor/index.cjs.map +1 -0
- package/dist/video-editor/index.d.cts +1003 -0
- package/dist/video-editor/index.d.ts +1003 -0
- package/dist/video-editor/index.js +2234 -0
- package/dist/video-editor/index.js.map +1 -0
- package/package.json +6 -1
- /package/dist/{chunk-OK7T2LHK.js.map → chunk-GNO5PEMH.js.map} +0 -0
- /package/dist/{chunk-EIXXBKUB.js.map → chunk-IMSX4IVG.js.map} +0 -0
- /package/dist/{chunk-WON34HKM.js.map → chunk-SVW2UNUC.js.map} +0 -0
- /package/dist/{chunk-UKI64KUD.js.map → chunk-ZQNDSUZO.js.map} +0 -0
|
@@ -0,0 +1,2234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
namedWithSuggestion
|
|
3
|
+
} from "../chunk-INZJALPY.js";
|
|
4
|
+
import {
|
|
5
|
+
HistoryManager
|
|
6
|
+
} from "../chunk-QVZZGT4X.js";
|
|
7
|
+
import {
|
|
8
|
+
AnimationPlayer
|
|
9
|
+
} from "../chunk-JK6M6V7G.js";
|
|
10
|
+
import "../chunk-ZRJMJIQU.js";
|
|
11
|
+
import "../chunk-TGD52FEZ.js";
|
|
12
|
+
import "../chunk-AGR4P4UZ.js";
|
|
13
|
+
|
|
14
|
+
// src/video-editor/types.ts
|
|
15
|
+
var VIDEO_FITS = ["contain", "cover", "fill", "none"];
|
|
16
|
+
var VIDEO_TRANSITION_TYPES = ["crossfade", "dip", "wipe"];
|
|
17
|
+
var VIDEO_WIPE_DIRECTIONS = ["left", "right", "up", "down"];
|
|
18
|
+
var VIDEO_CLIP_TYPES = ["media", "text", "color"];
|
|
19
|
+
|
|
20
|
+
// src/video-editor/parse.ts
|
|
21
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
22
|
+
var fail = (path, what) => {
|
|
23
|
+
throw new Error(`${path} ${what}.`);
|
|
24
|
+
};
|
|
25
|
+
var str = (v, path, optional = false) => {
|
|
26
|
+
if (v === void 0 && optional) return void 0;
|
|
27
|
+
if (typeof v !== "string" || !optional && !v.length) fail(path, "must be a non-empty string");
|
|
28
|
+
return v;
|
|
29
|
+
};
|
|
30
|
+
var num = (v, path, { min, positive, optional, max } = {}) => {
|
|
31
|
+
if (v === void 0 && optional) return void 0;
|
|
32
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return fail(path, "must be a number");
|
|
33
|
+
if (positive && !(v > 0)) fail(path, "must be greater than 0");
|
|
34
|
+
if (min !== void 0 && v < min) fail(path, `must be at least ${min}`);
|
|
35
|
+
if (max !== void 0 && v > max) fail(path, `must be at most ${max}`);
|
|
36
|
+
return v;
|
|
37
|
+
};
|
|
38
|
+
var bool = (v, path) => {
|
|
39
|
+
if (v === void 0) return void 0;
|
|
40
|
+
if (typeof v !== "boolean") fail(path, "must be true or false");
|
|
41
|
+
return v;
|
|
42
|
+
};
|
|
43
|
+
var oneOf = (v, options, path, optional = true) => {
|
|
44
|
+
if (v === void 0 && optional) return void 0;
|
|
45
|
+
if (!options.includes(v)) fail(path, `must be one of ${options.map((o) => `"${o}"`).join(", ")}`);
|
|
46
|
+
return v;
|
|
47
|
+
};
|
|
48
|
+
var compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
49
|
+
function parseVideoTransform(v, path) {
|
|
50
|
+
if (v === void 0) return void 0;
|
|
51
|
+
if (!isObject(v)) return fail(path, "must be an object { x, y, scale, rotation }");
|
|
52
|
+
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({ x: num(v.x, `${path}.x`, { optional: true }), y: num(v.y, `${path}.y`, { optional: true }), scale: num(v.scale, `${path}.scale`, { optional: true, min: 0 }), rotation: num(v.rotation, `${path}.rotation`, { optional: true }) });
|
|
54
|
+
}
|
|
55
|
+
function parseVideoTransition(v, path) {
|
|
56
|
+
if (v === void 0) return void 0;
|
|
57
|
+
if (!isObject(v)) return fail(path, "must be an object { type, duration }");
|
|
58
|
+
return compact({
|
|
59
|
+
type: oneOf(v.type, VIDEO_TRANSITION_TYPES, `${path}.type`, false),
|
|
60
|
+
duration: num(v.duration, `${path}.duration`, { positive: true }),
|
|
61
|
+
color: str(v.color, `${path}.color`, true),
|
|
62
|
+
direction: oneOf(v.direction, VIDEO_WIPE_DIRECTIONS, `${path}.direction`)
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function parseVideoAsset(v, path = "asset") {
|
|
66
|
+
if (!isObject(v)) return fail(path, "must be an object");
|
|
67
|
+
const kind = oneOf(v.kind, ["video", "audio", "image"], `${path}.kind`, false);
|
|
68
|
+
const asset = compact({
|
|
69
|
+
id: str(v.id, `${path}.id`),
|
|
70
|
+
kind,
|
|
71
|
+
src: str(v.src, `${path}.src`),
|
|
72
|
+
name: str(v.name, `${path}.name`, true),
|
|
73
|
+
duration: num(v.duration, `${path}.duration`, { optional: true, positive: true }),
|
|
74
|
+
width: num(v.width, `${path}.width`, { optional: true, positive: true }),
|
|
75
|
+
height: num(v.height, `${path}.height`, { optional: true, positive: true }),
|
|
76
|
+
hasAudio: bool(v.hasAudio, `${path}.hasAudio`),
|
|
77
|
+
poster: str(v.poster, `${path}.poster`, true)
|
|
78
|
+
});
|
|
79
|
+
if (kind === "image" && asset.duration !== void 0) fail(`${path}.duration`, "is only for video and audio assets");
|
|
80
|
+
return asset;
|
|
81
|
+
}
|
|
82
|
+
function parseVideoClip(v, path = "clip") {
|
|
83
|
+
if (!isObject(v)) return fail(path, "must be an object");
|
|
84
|
+
const type = oneOf(v.type ?? "media", VIDEO_CLIP_TYPES, `${path}.type`, false);
|
|
85
|
+
const base = {
|
|
86
|
+
id: str(v.id, `${path}.id`),
|
|
87
|
+
name: str(v.name, `${path}.name`, true),
|
|
88
|
+
start: num(v.start, `${path}.start`, { min: 0 }),
|
|
89
|
+
duration: num(v.duration, `${path}.duration`, { positive: true }),
|
|
90
|
+
opacity: num(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),
|
|
91
|
+
transform: parseVideoTransform(v.transform, `${path}.transform`),
|
|
92
|
+
fit: oneOf(v.fit, VIDEO_FITS, `${path}.fit`),
|
|
93
|
+
fadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),
|
|
94
|
+
fadeOut: num(v.fadeOut, `${path}.fadeOut`, { optional: true, min: 0 }),
|
|
95
|
+
transition: parseVideoTransition(v.transition, `${path}.transition`)
|
|
96
|
+
};
|
|
97
|
+
if (type === "media")
|
|
98
|
+
return compact({
|
|
99
|
+
type,
|
|
100
|
+
...base,
|
|
101
|
+
asset: str(v.asset, `${path}.asset`),
|
|
102
|
+
in: num(v.in, `${path}.in`, { optional: true, min: 0 }),
|
|
103
|
+
speed: num(v.speed, `${path}.speed`, { optional: true, min: 0.05, max: 20 }),
|
|
104
|
+
volume: num(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
|
|
105
|
+
muted: bool(v.muted, `${path}.muted`)
|
|
106
|
+
});
|
|
107
|
+
if (type === "text")
|
|
108
|
+
return compact({
|
|
109
|
+
type,
|
|
110
|
+
...base,
|
|
111
|
+
text: str(v.text, `${path}.text`),
|
|
112
|
+
color: str(v.color, `${path}.color`, true),
|
|
113
|
+
fontSize: num(v.fontSize, `${path}.fontSize`, { optional: true, positive: true }),
|
|
114
|
+
fontFamily: str(v.fontFamily, `${path}.fontFamily`, true),
|
|
115
|
+
fontWeight: num(v.fontWeight, `${path}.fontWeight`, { optional: true, min: 100, max: 1e3 }),
|
|
116
|
+
background: str(v.background, `${path}.background`, true)
|
|
117
|
+
});
|
|
118
|
+
return compact({ type, ...base, color: str(v.color, `${path}.color`) });
|
|
119
|
+
}
|
|
120
|
+
function parseVideoTrack(v, path = "track") {
|
|
121
|
+
if (!isObject(v)) return fail(path, "must be an object");
|
|
122
|
+
if (v.clips !== void 0 && !Array.isArray(v.clips)) fail(`${path}.clips`, "must be an array");
|
|
123
|
+
return compact({
|
|
124
|
+
id: str(v.id, `${path}.id`),
|
|
125
|
+
kind: oneOf(v.kind, ["video", "audio"], `${path}.kind`, false),
|
|
126
|
+
name: str(v.name, `${path}.name`, true),
|
|
127
|
+
muted: bool(v.muted, `${path}.muted`),
|
|
128
|
+
hidden: bool(v.hidden, `${path}.hidden`),
|
|
129
|
+
locked: bool(v.locked, `${path}.locked`),
|
|
130
|
+
volume: num(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
|
|
131
|
+
clips: (v.clips ?? []).map((c, i) => parseVideoClip(c, `${path}.clips[${i}]`))
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function parseVideoMarker(v, path = "marker") {
|
|
135
|
+
if (!isObject(v)) return fail(path, "must be an object");
|
|
136
|
+
return compact({ id: str(v.id, `${path}.id`), time: num(v.time, `${path}.time`, { min: 0 }), label: str(v.label, `${path}.label`, true), color: str(v.color, `${path}.color`, true) });
|
|
137
|
+
}
|
|
138
|
+
function parseVideoProject(input) {
|
|
139
|
+
let v = input;
|
|
140
|
+
if (typeof v === "string") {
|
|
141
|
+
try {
|
|
142
|
+
v = JSON.parse(v);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new Error(`The project is not valid JSON: ${error.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!isObject(v)) return fail("The project", "must be an object");
|
|
148
|
+
if (v.version !== void 0 && v.version !== 1) fail("version", "must be 1");
|
|
149
|
+
for (const key of ["assets", "tracks", "markers"]) if (v[key] !== void 0 && !Array.isArray(v[key])) fail(key, "must be an array");
|
|
150
|
+
const project = compact({
|
|
151
|
+
version: 1,
|
|
152
|
+
name: str(v.name, "name", true),
|
|
153
|
+
width: num(v.width, "width", { positive: true }),
|
|
154
|
+
height: num(v.height, "height", { positive: true }),
|
|
155
|
+
fps: num(v.fps ?? 30, "fps", { positive: true, max: 240 }),
|
|
156
|
+
background: str(v.background, "background", true),
|
|
157
|
+
assets: (v.assets ?? []).map((a, i) => parseVideoAsset(a, `assets[${i}]`)),
|
|
158
|
+
tracks: (v.tracks ?? []).map((t, i) => parseVideoTrack(t, `tracks[${i}]`)),
|
|
159
|
+
markers: v.markers === void 0 ? void 0 : v.markers.map((m, i) => parseVideoMarker(m, `markers[${i}]`))
|
|
160
|
+
});
|
|
161
|
+
const unique = (ids, what) => {
|
|
162
|
+
const seen = /* @__PURE__ */ new Set();
|
|
163
|
+
for (const id of ids) {
|
|
164
|
+
if (seen.has(id)) throw new Error(`Two ${what} share the id "${id}"; ids must be unique.`);
|
|
165
|
+
seen.add(id);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
unique(project.assets.map((a) => a.id), "assets");
|
|
169
|
+
unique(project.tracks.map((t) => t.id), "tracks");
|
|
170
|
+
unique(project.tracks.flatMap((t) => t.clips.map((c) => c.id)), "clips");
|
|
171
|
+
unique((project.markers ?? []).map((m) => m.id), "markers");
|
|
172
|
+
return project;
|
|
173
|
+
}
|
|
174
|
+
function createVideoProject(options = {}) {
|
|
175
|
+
return compact({
|
|
176
|
+
version: 1,
|
|
177
|
+
name: options.name,
|
|
178
|
+
width: options.width ?? 1920,
|
|
179
|
+
height: options.height ?? 1080,
|
|
180
|
+
fps: options.fps ?? 30,
|
|
181
|
+
background: options.background,
|
|
182
|
+
assets: [],
|
|
183
|
+
tracks: [
|
|
184
|
+
{ id: "v1", kind: "video", name: "Video 1", clips: [] },
|
|
185
|
+
{ id: "a1", kind: "audio", name: "Audio 1", clips: [] }
|
|
186
|
+
]
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/video-editor/timeline.ts
|
|
191
|
+
var VIDEO_TIME_EPSILON = 0.5;
|
|
192
|
+
var videoClipEnd = (clip) => clip.start + clip.duration;
|
|
193
|
+
var videoClipSpeed = (clip) => clip.type === "media" ? clip.speed ?? 1 : 1;
|
|
194
|
+
function videoClipSourceRange(clip) {
|
|
195
|
+
if (clip.type !== "media") return null;
|
|
196
|
+
const start = clip.in ?? 0;
|
|
197
|
+
return { in: start, out: start + clip.duration * videoClipSpeed(clip) };
|
|
198
|
+
}
|
|
199
|
+
var videoFrameDuration = (fps) => 1e3 / fps;
|
|
200
|
+
function snapToVideoFrame(time, fps) {
|
|
201
|
+
const frame = Math.round(time * fps / 1e3);
|
|
202
|
+
return Math.round(frame * 1e3 / fps * 1e3) / 1e3;
|
|
203
|
+
}
|
|
204
|
+
var videoFrames = (time, fps) => Math.round(time * fps / 1e3);
|
|
205
|
+
function formatVideoTimecode(time, fps) {
|
|
206
|
+
const base = Math.round(fps);
|
|
207
|
+
const total = Math.max(0, videoFrames(time, fps));
|
|
208
|
+
const ff = total % base;
|
|
209
|
+
const secs = Math.floor(total / base);
|
|
210
|
+
const pad = (n2) => String(n2).padStart(2, "0");
|
|
211
|
+
return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(ff)}`;
|
|
212
|
+
}
|
|
213
|
+
var formatVideoSeconds = (time) => `${(time / 1e3).toFixed(2)} s`;
|
|
214
|
+
function videoAssetMap(project) {
|
|
215
|
+
return new Map(project.assets.map((a) => [a.id, a]));
|
|
216
|
+
}
|
|
217
|
+
function sortedVideoClips(track) {
|
|
218
|
+
return [...track.clips].sort((a, b) => a.start - b.start || a.duration - b.duration);
|
|
219
|
+
}
|
|
220
|
+
function findVideoClip(project, id) {
|
|
221
|
+
for (let trackIndex = 0; trackIndex < project.tracks.length; trackIndex++) {
|
|
222
|
+
const track = project.tracks[trackIndex];
|
|
223
|
+
const index = track.clips.findIndex((c) => c.id === id);
|
|
224
|
+
if (index >= 0) return { track, trackIndex, clip: track.clips[index], index };
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
function allVideoClipIds(project) {
|
|
229
|
+
return project.tracks.flatMap((t) => t.clips.map((c) => c.id));
|
|
230
|
+
}
|
|
231
|
+
function videoProjectDuration(project) {
|
|
232
|
+
let end = 0;
|
|
233
|
+
for (const track of project.tracks) for (const clip of track.clips) end = Math.max(end, videoClipEnd(clip));
|
|
234
|
+
return end;
|
|
235
|
+
}
|
|
236
|
+
function videoClipBefore(track, clip) {
|
|
237
|
+
let best = null;
|
|
238
|
+
for (const c of track.clips) if (c !== clip && Math.abs(videoClipEnd(c) - clip.start) <= VIDEO_TIME_EPSILON) best = c;
|
|
239
|
+
return best;
|
|
240
|
+
}
|
|
241
|
+
function videoClipAfter(track, clip) {
|
|
242
|
+
for (const c of track.clips) if (c !== clip && Math.abs(c.start - videoClipEnd(clip)) <= VIDEO_TIME_EPSILON) return c;
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
function videoClipHandles(clip, assets) {
|
|
246
|
+
if (clip.type !== "media") return { before: Infinity, after: Infinity };
|
|
247
|
+
const asset = assets.get(clip.asset);
|
|
248
|
+
const speed = videoClipSpeed(clip);
|
|
249
|
+
const range = videoClipSourceRange(clip);
|
|
250
|
+
const before = range.in / speed;
|
|
251
|
+
if (!asset || asset.kind === "image" || asset.duration === void 0) return { before: asset?.kind === "image" ? Infinity : before, after: Infinity };
|
|
252
|
+
return { before, after: Math.max(0, (asset.duration - range.out) / speed) };
|
|
253
|
+
}
|
|
254
|
+
function videoClipOverhang(track, clip) {
|
|
255
|
+
const lead = clip.transition && clip.transition.type !== "dip" && videoClipBefore(track, clip) ? clip.transition.duration / 2 : 0;
|
|
256
|
+
const next = videoClipAfter(track, clip);
|
|
257
|
+
const tail = next?.transition && next.transition.type !== "dip" ? next.transition.duration / 2 : 0;
|
|
258
|
+
return { lead, tail };
|
|
259
|
+
}
|
|
260
|
+
function videoSnapPoints(project, options = {}) {
|
|
261
|
+
const exclude = new Set(options.exclude ?? []);
|
|
262
|
+
const points = /* @__PURE__ */ new Set([0]);
|
|
263
|
+
for (const track of project.tracks)
|
|
264
|
+
for (const clip of track.clips) {
|
|
265
|
+
if (exclude.has(clip.id)) continue;
|
|
266
|
+
points.add(clip.start);
|
|
267
|
+
points.add(videoClipEnd(clip));
|
|
268
|
+
}
|
|
269
|
+
for (const marker of project.markers ?? []) points.add(marker.time);
|
|
270
|
+
if (options.playhead !== void 0) points.add(options.playhead);
|
|
271
|
+
return [...points].sort((a, b) => a - b);
|
|
272
|
+
}
|
|
273
|
+
function snapVideoTime(time, points, threshold) {
|
|
274
|
+
let best = null;
|
|
275
|
+
for (const p of points) if (Math.abs(p - time) <= threshold && (best === null || Math.abs(p - time) < Math.abs(best - time))) best = p;
|
|
276
|
+
return best === null ? { time, snapped: null } : { time: best, snapped: best };
|
|
277
|
+
}
|
|
278
|
+
function findVideoGaps(project, trackId) {
|
|
279
|
+
const gaps = [];
|
|
280
|
+
for (const track of project.tracks) {
|
|
281
|
+
if (trackId !== void 0 && track.id !== trackId) continue;
|
|
282
|
+
let at = 0;
|
|
283
|
+
for (const clip of sortedVideoClips(track)) {
|
|
284
|
+
if (clip.start - at > VIDEO_TIME_EPSILON) gaps.push({ track: track.id, start: at, end: clip.start });
|
|
285
|
+
at = Math.max(at, videoClipEnd(clip));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return gaps;
|
|
289
|
+
}
|
|
290
|
+
function videoOverlap(track, start, end, ignore = /* @__PURE__ */ new Set()) {
|
|
291
|
+
for (const c of sortedVideoClips(track)) {
|
|
292
|
+
if (ignore.has(c.id)) continue;
|
|
293
|
+
if (c.start < end - VIDEO_TIME_EPSILON && videoClipEnd(c) > start + VIDEO_TIME_EPSILON) return c;
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
function nextFreeVideoSlot(track, from, duration, ignore = /* @__PURE__ */ new Set()) {
|
|
298
|
+
let at = from;
|
|
299
|
+
for (const c of sortedVideoClips(track)) {
|
|
300
|
+
if (ignore.has(c.id)) continue;
|
|
301
|
+
if (videoClipEnd(c) <= at + VIDEO_TIME_EPSILON) continue;
|
|
302
|
+
if (c.start >= at + duration - VIDEO_TIME_EPSILON) break;
|
|
303
|
+
at = videoClipEnd(c);
|
|
304
|
+
}
|
|
305
|
+
return at;
|
|
306
|
+
}
|
|
307
|
+
function videoClipsAt(project, time) {
|
|
308
|
+
const out = [];
|
|
309
|
+
for (const track of project.tracks) for (const clip of track.clips) if (time >= clip.start && time < videoClipEnd(clip)) out.push({ track: track.id, clip });
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/video-editor/check.ts
|
|
314
|
+
var VIDEO_ISSUE_CODES = [
|
|
315
|
+
"missing-asset",
|
|
316
|
+
"wrong-track",
|
|
317
|
+
"overlap",
|
|
318
|
+
"source-overrun",
|
|
319
|
+
"transition-without-cut",
|
|
320
|
+
"transition-too-long",
|
|
321
|
+
"transition-handles",
|
|
322
|
+
"fades-too-long",
|
|
323
|
+
"unknown-duration",
|
|
324
|
+
"unused-asset",
|
|
325
|
+
"marker-past-end",
|
|
326
|
+
"empty"
|
|
327
|
+
];
|
|
328
|
+
var videoIssueKey = (i) => [i.code, i.track ?? "", i.clip ?? "", i.asset ?? "", i.marker ?? ""].join("|");
|
|
329
|
+
function checkVideoProject(project) {
|
|
330
|
+
const issues = [];
|
|
331
|
+
const assets = videoAssetMap(project);
|
|
332
|
+
const used = /* @__PURE__ */ new Set();
|
|
333
|
+
for (const asset of project.assets)
|
|
334
|
+
if ((asset.kind === "video" || asset.kind === "audio") && asset.duration === void 0)
|
|
335
|
+
issues.push({ code: "unknown-duration", severity: "warning", asset: asset.id, message: `Asset "${asset.id}" has no duration, so clips of it cannot be checked against its length; set "duration" in ms.` });
|
|
336
|
+
for (const track of project.tracks) {
|
|
337
|
+
const clips = sortedVideoClips(track);
|
|
338
|
+
for (let i = 0; i < clips.length; i++) {
|
|
339
|
+
const clip = clips[i];
|
|
340
|
+
const at = { track: track.id, clip: clip.id };
|
|
341
|
+
if (clip.type === "media") {
|
|
342
|
+
used.add(clip.asset);
|
|
343
|
+
const asset = assets.get(clip.asset);
|
|
344
|
+
if (!asset) issues.push({ code: "missing-asset", severity: "error", ...at, asset: clip.asset, message: `Clip "${clip.id}" plays asset "${clip.asset}", which is not in the project; add the asset or delete the clip.` });
|
|
345
|
+
else {
|
|
346
|
+
if (track.kind === "audio" && asset.kind === "image") issues.push({ code: "wrong-track", severity: "error", ...at, message: `Clip "${clip.id}" is an image on audio track "${track.id}"; move it to a video track.` });
|
|
347
|
+
if (track.kind === "video" && asset.kind === "audio") issues.push({ code: "wrong-track", severity: "error", ...at, message: `Clip "${clip.id}" is audio on video track "${track.id}"; move it to an audio track.` });
|
|
348
|
+
const range = videoClipSourceRange(clip);
|
|
349
|
+
if (asset.duration !== void 0 && range.out > asset.duration + VIDEO_TIME_EPSILON)
|
|
350
|
+
issues.push({ code: "source-overrun", severity: "error", ...at, message: `Clip "${clip.id}" plays to ${formatVideoSeconds(range.out)} of "${asset.id}", which is only ${formatVideoSeconds(asset.duration)} long; shorten the clip or move its in point.` });
|
|
351
|
+
}
|
|
352
|
+
} else if (track.kind === "audio") issues.push({ code: "wrong-track", severity: "error", ...at, message: `Clip "${clip.id}" is a ${clip.type} clip on audio track "${track.id}"; move it to a video track.` });
|
|
353
|
+
const next = clips[i + 1];
|
|
354
|
+
if (next && next.start < videoClipEnd(clip) - VIDEO_TIME_EPSILON)
|
|
355
|
+
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
|
+
if ((clip.fadeIn ?? 0) + (clip.fadeOut ?? 0) > clip.duration + VIDEO_TIME_EPSILON)
|
|
357
|
+
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.` });
|
|
358
|
+
if (clip.transition) {
|
|
359
|
+
const d = clip.transition.duration;
|
|
360
|
+
const before = videoClipBefore(track, clip);
|
|
361
|
+
if (!before) {
|
|
362
|
+
issues.push({ code: "transition-without-cut", severity: "warning", ...at, message: `Clip "${clip.id}" has a transition but no clip ends where it starts on track "${track.id}", so it plays as nothing; butt a clip up against it or remove the transition.` });
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (d > clip.duration + VIDEO_TIME_EPSILON || d > before.duration + VIDEO_TIME_EPSILON) {
|
|
366
|
+
issues.push({ code: "transition-too-long", severity: "error", ...at, message: `The ${d} ms transition into "${clip.id}" is longer than "${clip.id}" (${clip.duration} ms) or "${before.id}" (${before.duration} ms); make it at most ${Math.min(clip.duration, before.duration)} ms.` });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (clip.transition.type === "dip") continue;
|
|
370
|
+
const outgoing = videoClipHandles(before, assets).after;
|
|
371
|
+
const incoming = videoClipHandles(clip, assets).before;
|
|
372
|
+
const half = d / 2;
|
|
373
|
+
if (outgoing < half - VIDEO_TIME_EPSILON || incoming < half - VIDEO_TIME_EPSILON) {
|
|
374
|
+
const fits = 2 * Math.min(outgoing, incoming);
|
|
375
|
+
issues.push({
|
|
376
|
+
code: "transition-handles",
|
|
377
|
+
severity: "error",
|
|
378
|
+
...at,
|
|
379
|
+
message: `The ${d} ms transition into "${clip.id}" needs ${half} ms of media past the cut on both sides, but "${before.id}" has ${Math.floor(outgoing)} ms after its out point and "${clip.id}" ${Math.floor(incoming)} ms before its in point; make it at most ${Math.floor(fits)} ms, or trim the clips to leave handles.`
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
for (const asset of project.assets) if (!used.has(asset.id)) issues.push({ code: "unused-asset", severity: "note", asset: asset.id, message: `Asset "${asset.id}" is not used by any clip.` });
|
|
386
|
+
const end = videoProjectDuration(project);
|
|
387
|
+
for (const marker of project.markers ?? [])
|
|
388
|
+
if (marker.time > end + VIDEO_TIME_EPSILON) issues.push({ code: "marker-past-end", severity: "note", marker: marker.id, message: `Marker "${marker.id}" at ${formatVideoSeconds(marker.time)} is past the end (${formatVideoSeconds(end)}).` });
|
|
389
|
+
if (!project.tracks.some((t) => t.clips.length)) issues.push({ code: "empty", severity: "note", message: "The project has no clips yet; add assets and then clips." });
|
|
390
|
+
const rank = { error: 0, warning: 1, note: 2 };
|
|
391
|
+
return issues.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/video-editor/edit.ts
|
|
395
|
+
var VIDEO_EDIT_OPS = [
|
|
396
|
+
"set_settings",
|
|
397
|
+
"add_asset",
|
|
398
|
+
"update_asset",
|
|
399
|
+
"remove_asset",
|
|
400
|
+
"add_track",
|
|
401
|
+
"update_track",
|
|
402
|
+
"remove_track",
|
|
403
|
+
"move_track",
|
|
404
|
+
"add_clip",
|
|
405
|
+
"move_clip",
|
|
406
|
+
"trim_clip",
|
|
407
|
+
"split_clip",
|
|
408
|
+
"delete_clips",
|
|
409
|
+
"update_clip",
|
|
410
|
+
"set_transition",
|
|
411
|
+
"close_gaps",
|
|
412
|
+
"add_marker",
|
|
413
|
+
"update_marker",
|
|
414
|
+
"remove_marker",
|
|
415
|
+
"replace_project"
|
|
416
|
+
];
|
|
417
|
+
var VIDEO_DEFAULT_STILL_DURATION = 5e3;
|
|
418
|
+
var VIDEO_DEFAULT_TITLE_DURATION = 3e3;
|
|
419
|
+
var EditError = class extends Error {
|
|
420
|
+
};
|
|
421
|
+
var refuse = (message) => {
|
|
422
|
+
throw new EditError(message);
|
|
423
|
+
};
|
|
424
|
+
function freshId(prefix, taken) {
|
|
425
|
+
const used = new Set(taken);
|
|
426
|
+
for (let n2 = 1; ; n2++) if (!used.has(`${prefix}${n2}`)) return `${prefix}${n2}`;
|
|
427
|
+
}
|
|
428
|
+
var slug = (s) => s.replace(/^.*[\\/]/, "").replace(/\.[a-z0-9]+(\?.*)?$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
429
|
+
var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
|
|
430
|
+
var Editor = class {
|
|
431
|
+
constructor(p) {
|
|
432
|
+
this.p = p;
|
|
433
|
+
}
|
|
434
|
+
p;
|
|
435
|
+
outcomes = [];
|
|
436
|
+
track(id) {
|
|
437
|
+
const t = this.p.tracks.find((x) => x.id === id);
|
|
438
|
+
if (!t) refuse(`There is no track ${namedWithSuggestion(id, this.p.tracks.map((x) => x.id))}; the tracks are ${this.p.tracks.map((x) => `"${x.id}" (${x.kind})`).join(", ") || "none"}.`);
|
|
439
|
+
return t;
|
|
440
|
+
}
|
|
441
|
+
clip(id) {
|
|
442
|
+
const at = findVideoClip(this.p, id);
|
|
443
|
+
if (!at) {
|
|
444
|
+
const ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));
|
|
445
|
+
refuse(`There is no clip ${namedWithSuggestion(id, ids)}${ids.length ? "" : "; the project has no clips yet"}.`);
|
|
446
|
+
}
|
|
447
|
+
return at;
|
|
448
|
+
}
|
|
449
|
+
unlocked(track) {
|
|
450
|
+
if (track.locked) refuse(`Track "${track.id}" is locked; unlock it (update_track with locked false) first.`);
|
|
451
|
+
}
|
|
452
|
+
asset(id) {
|
|
453
|
+
const a = this.p.assets.find((x) => x.id === id);
|
|
454
|
+
if (!a) refuse(`There is no asset ${namedWithSuggestion(id, this.p.assets.map((x) => x.id))}; add it first (the assets are ${this.p.assets.map((x) => `"${x.id}"`).join(", ") || "none yet"}).`);
|
|
455
|
+
return a;
|
|
456
|
+
}
|
|
457
|
+
fits(track, start, duration, ignore, what) {
|
|
458
|
+
const hit = videoOverlap(track, start, start + duration, ignore);
|
|
459
|
+
if (hit) {
|
|
460
|
+
const free = nextFreeVideoSlot(track, start, duration, ignore);
|
|
461
|
+
refuse(`${what} at ${formatVideoSeconds(start)}\u2013${formatVideoSeconds(start + duration)} would overlap clip "${hit.id}" (${formatVideoSeconds(hit.start)}\u2013${formatVideoSeconds(videoClipEnd(hit))}) on track "${track.id}"; the next free place is ${formatVideoSeconds(free)}, or pass ripple to push later clips along.`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
kindFits(track, clip) {
|
|
465
|
+
const asset = clip.type === "media" ? this.asset(clip.asset) : null;
|
|
466
|
+
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" && asset?.kind === "audio") refuse(`Track "${track.id}" is a video track; audio asset "${asset.id}" goes on an audio track.`);
|
|
468
|
+
}
|
|
469
|
+
/** Moves every clip on `track` starting at or after `from` by `delta`. */
|
|
470
|
+
shift(track, from, delta, except = /* @__PURE__ */ new Set()) {
|
|
471
|
+
if (!delta) return;
|
|
472
|
+
for (const c of track.clips) if (!except.has(c.id) && c.start >= from - VIDEO_TIME_EPSILON) c.start = Math.max(0, c.start + delta);
|
|
473
|
+
}
|
|
474
|
+
run(e) {
|
|
475
|
+
switch (e.op) {
|
|
476
|
+
case "set_settings": {
|
|
477
|
+
const { op: _op, ...settings } = e;
|
|
478
|
+
for (const [k, v] of Object.entries(settings)) {
|
|
479
|
+
if (!["name", "width", "height", "fps", "background"].includes(k)) refuse(`"${k}" is not a setting; use name, width, height, fps or background.`);
|
|
480
|
+
if (v !== void 0) this.p[k] = v;
|
|
481
|
+
}
|
|
482
|
+
return this.outcomes.push({ op: e.op });
|
|
483
|
+
}
|
|
484
|
+
case "add_asset": {
|
|
485
|
+
const ids = this.p.assets.map((a) => a.id);
|
|
486
|
+
if (e.asset.id !== void 0 && ids.includes(e.asset.id)) refuse(`There is already an asset "${e.asset.id}"; pick another id or use update_asset.`);
|
|
487
|
+
const base = slug(e.asset.name ?? e.asset.src ?? "") || e.asset.kind || "asset";
|
|
488
|
+
const unique = e.asset.id ?? (ids.includes(base) ? freshId(`${base}-`, ids) : base);
|
|
489
|
+
this.p.assets.push(parseVideoAsset({ ...e.asset, id: unique }));
|
|
490
|
+
return this.outcomes.push({ op: e.op, asset: unique });
|
|
491
|
+
}
|
|
492
|
+
case "update_asset": {
|
|
493
|
+
const asset = this.asset(e.asset);
|
|
494
|
+
if (e.set && "id" in e.set) refuse("An asset's id cannot be changed.");
|
|
495
|
+
const next = { ...asset, ...e.set };
|
|
496
|
+
for (const k of e.remove ?? []) delete next[k];
|
|
497
|
+
this.p.assets[this.p.assets.indexOf(asset)] = parseVideoAsset(next);
|
|
498
|
+
return this.outcomes.push({ op: e.op, asset: asset.id });
|
|
499
|
+
}
|
|
500
|
+
case "remove_asset": {
|
|
501
|
+
const asset = this.asset(e.asset);
|
|
502
|
+
const users = this.p.tracks.flatMap((t) => t.clips.filter((c) => c.type === "media" && c.asset === asset.id).map((c) => c.id));
|
|
503
|
+
if (users.length) refuse(`Asset "${asset.id}" is used by clip${users.length > 1 ? "s" : ""} ${users.map((u) => `"${u}"`).join(", ")}; delete ${users.length > 1 ? "them" : "it"} first.`);
|
|
504
|
+
this.p.assets = this.p.assets.filter((a) => a !== asset);
|
|
505
|
+
return this.outcomes.push({ op: e.op, asset: asset.id });
|
|
506
|
+
}
|
|
507
|
+
case "add_track": {
|
|
508
|
+
const ids = this.p.tracks.map((t) => t.id);
|
|
509
|
+
if (e.track.id && ids.includes(e.track.id)) refuse(`There is already a track "${e.track.id}".`);
|
|
510
|
+
const id = e.track.id ?? freshId(e.track.kind === "video" ? "v" : "a", ids);
|
|
511
|
+
const n2 = this.p.tracks.filter((t) => t.kind === e.track.kind).length + 1;
|
|
512
|
+
const track = { name: `${e.track.kind === "video" ? "Video" : "Audio"} ${n2}`, ...e.track, id, clips: [] };
|
|
513
|
+
const at = e.at ?? (e.track.kind === "video" ? 0 : this.p.tracks.length);
|
|
514
|
+
if (at > this.p.tracks.length) refuse(`"at" is ${at} but there are only ${this.p.tracks.length} tracks; use 0\u2013${this.p.tracks.length}.`);
|
|
515
|
+
this.p.tracks.splice(at, 0, track);
|
|
516
|
+
return this.outcomes.push({ op: e.op, track: id });
|
|
517
|
+
}
|
|
518
|
+
case "update_track": {
|
|
519
|
+
const track = this.track(e.track);
|
|
520
|
+
for (const k of Object.keys(e.set ?? {})) if (!["name", "muted", "hidden", "locked", "volume"].includes(k)) refuse(`"${k}" cannot be set on a track; use name, muted, hidden, locked or volume.`);
|
|
521
|
+
Object.assign(track, e.set);
|
|
522
|
+
for (const k of e.remove ?? []) delete track[k];
|
|
523
|
+
return this.outcomes.push({ op: e.op, track: track.id });
|
|
524
|
+
}
|
|
525
|
+
case "remove_track": {
|
|
526
|
+
const track = this.track(e.track);
|
|
527
|
+
this.unlocked(track);
|
|
528
|
+
this.p.tracks = this.p.tracks.filter((t) => t !== track);
|
|
529
|
+
return this.outcomes.push({ op: e.op, track: track.id, clips: track.clips.map((c) => c.id) });
|
|
530
|
+
}
|
|
531
|
+
case "move_track": {
|
|
532
|
+
const track = this.track(e.track);
|
|
533
|
+
if (e.to < 0 || e.to >= this.p.tracks.length) refuse(`"to" must be 0\u2013${this.p.tracks.length - 1}.`);
|
|
534
|
+
this.p.tracks.splice(this.p.tracks.indexOf(track), 1);
|
|
535
|
+
this.p.tracks.splice(e.to, 0, track);
|
|
536
|
+
return this.outcomes.push({ op: e.op, track: track.id });
|
|
537
|
+
}
|
|
538
|
+
case "add_clip": {
|
|
539
|
+
const track = this.track(e.track);
|
|
540
|
+
this.unlocked(track);
|
|
541
|
+
const draft = { ...e.clip, type: e.clip.type ?? "media" };
|
|
542
|
+
this.kindFits(track, draft);
|
|
543
|
+
const ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));
|
|
544
|
+
if (draft.id !== void 0 && ids.includes(draft.id)) refuse(`There is already a clip "${draft.id}".`);
|
|
545
|
+
draft.id ??= freshId("c", ids);
|
|
546
|
+
if (draft.duration === void 0) {
|
|
547
|
+
if (draft.type === "media") {
|
|
548
|
+
const asset = this.asset(draft.asset);
|
|
549
|
+
if (asset.kind === "image") draft.duration = VIDEO_DEFAULT_STILL_DURATION;
|
|
550
|
+
else if (asset.duration === void 0) refuse(`Asset "${asset.id}" has no duration, so give the clip a "duration".`);
|
|
551
|
+
else draft.duration = (asset.duration - (draft.in ?? 0)) / (draft.speed ?? 1);
|
|
552
|
+
if (!(draft.duration > 0)) refuse(`"in" (${draft.in} ms) is at or past the end of "${asset.id}" (${asset.duration} ms).`);
|
|
553
|
+
} else draft.duration = VIDEO_DEFAULT_TITLE_DURATION;
|
|
554
|
+
}
|
|
555
|
+
const end = sortedVideoClips(track).reduce((m, c) => Math.max(m, videoClipEnd(c)), 0);
|
|
556
|
+
draft.start ??= end;
|
|
557
|
+
const clip = parseVideoClip(draft, "clip");
|
|
558
|
+
if (e.ripple) {
|
|
559
|
+
const inside = track.clips.find((c) => c.start < clip.start - VIDEO_TIME_EPSILON && videoClipEnd(c) > clip.start + VIDEO_TIME_EPSILON);
|
|
560
|
+
if (inside) refuse(`${formatVideoSeconds(clip.start)} is inside clip "${inside.id}"; split it there first (split_clip), or insert at its start or end.`);
|
|
561
|
+
this.shift(track, clip.start, clip.duration);
|
|
562
|
+
} else this.fits(track, clip.start, clip.duration, /* @__PURE__ */ new Set(), `Clip "${clip.id}"`);
|
|
563
|
+
track.clips.push(clip);
|
|
564
|
+
track.clips.sort((a, b) => a.start - b.start);
|
|
565
|
+
return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
|
|
566
|
+
}
|
|
567
|
+
case "move_clip": {
|
|
568
|
+
const from = this.clip(e.clip);
|
|
569
|
+
this.unlocked(from.track);
|
|
570
|
+
const to = e.track !== void 0 ? this.track(e.track) : from.track;
|
|
571
|
+
this.unlocked(to);
|
|
572
|
+
if (to !== from.track) this.kindFits(to, from.clip);
|
|
573
|
+
const start = e.start ?? from.clip.start;
|
|
574
|
+
this.fits(to, start, from.clip.duration, /* @__PURE__ */ new Set([from.clip.id]), `Clip "${from.clip.id}"`);
|
|
575
|
+
from.clip.start = start;
|
|
576
|
+
if (to !== from.track) {
|
|
577
|
+
from.track.clips.splice(from.index, 1);
|
|
578
|
+
to.clips.push(from.clip);
|
|
579
|
+
}
|
|
580
|
+
to.clips.sort((a, b) => a.start - b.start);
|
|
581
|
+
return this.outcomes.push({ op: e.op, clip: from.clip.id, track: to.id });
|
|
582
|
+
}
|
|
583
|
+
case "trim_clip": {
|
|
584
|
+
const { track, clip } = this.clip(e.clip);
|
|
585
|
+
this.unlocked(track);
|
|
586
|
+
const speed = videoClipSpeed(clip);
|
|
587
|
+
const end = videoClipEnd(clip);
|
|
588
|
+
const minimum = 1e3 / this.p.fps;
|
|
589
|
+
const assets = videoAssetMap(this.p);
|
|
590
|
+
if (e.edge === "start") {
|
|
591
|
+
const delta = e.to - clip.start;
|
|
592
|
+
if (clip.duration - delta < minimum - VIDEO_TIME_EPSILON) refuse(`Trimming the start to ${formatVideoSeconds(e.to)} would leave "${clip.id}" shorter than a frame; it ends at ${formatVideoSeconds(end)}.`);
|
|
593
|
+
if (clip.type === "media") {
|
|
594
|
+
const sourceIn = (clip.in ?? 0) + delta * speed;
|
|
595
|
+
if (sourceIn < -VIDEO_TIME_EPSILON) refuse(`"${clip.id}" starts ${Math.round((clip.in ?? 0) / speed)} ms into its source, so its start can move at most that far earlier (to ${formatVideoSeconds(clip.start - (clip.in ?? 0) / speed)}).`);
|
|
596
|
+
clip.in = Math.max(0, sourceIn);
|
|
597
|
+
if (clip.in === 0) delete clip.in;
|
|
598
|
+
}
|
|
599
|
+
if (e.ripple) {
|
|
600
|
+
clip.duration -= delta;
|
|
601
|
+
this.shift(track, end, -delta, /* @__PURE__ */ new Set([clip.id]));
|
|
602
|
+
} else {
|
|
603
|
+
if (e.to < 0) refuse("A clip cannot start before 0.");
|
|
604
|
+
if (delta < 0) this.fits(track, e.to, -delta, /* @__PURE__ */ new Set([clip.id]), `Extending "${clip.id}"`);
|
|
605
|
+
clip.start = e.to;
|
|
606
|
+
clip.duration -= delta;
|
|
607
|
+
}
|
|
608
|
+
} else {
|
|
609
|
+
const duration = e.to - clip.start;
|
|
610
|
+
if (duration < minimum - VIDEO_TIME_EPSILON) refuse(`Trimming the end to ${formatVideoSeconds(e.to)} would leave "${clip.id}" shorter than a frame; it starts at ${formatVideoSeconds(clip.start)}.`);
|
|
611
|
+
if (clip.type === "media") {
|
|
612
|
+
const asset = assets.get(clip.asset);
|
|
613
|
+
const out = (clip.in ?? 0) + duration * speed;
|
|
614
|
+
if (asset?.duration !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
|
|
615
|
+
refuse(`"${clip.id}" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (clip.in ?? 0)) / speed)}, where "${asset.id}" runs out.`);
|
|
616
|
+
}
|
|
617
|
+
if (e.ripple) this.shift(track, end, e.to - end, /* @__PURE__ */ new Set([clip.id]));
|
|
618
|
+
else if (e.to > end) this.fits(track, end, e.to - end, /* @__PURE__ */ new Set([clip.id]), `Extending "${clip.id}"`);
|
|
619
|
+
clip.duration = duration;
|
|
620
|
+
}
|
|
621
|
+
return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
|
|
622
|
+
}
|
|
623
|
+
case "split_clip": {
|
|
624
|
+
const { track, clip } = this.clip(e.clip);
|
|
625
|
+
this.unlocked(track);
|
|
626
|
+
const end = videoClipEnd(clip);
|
|
627
|
+
const minimum = 1e3 / this.p.fps;
|
|
628
|
+
if (e.at < clip.start + minimum - VIDEO_TIME_EPSILON || e.at > end - minimum + VIDEO_TIME_EPSILON) refuse(`${formatVideoSeconds(e.at)} is not inside "${clip.id}" (${formatVideoSeconds(clip.start)}\u2013${formatVideoSeconds(end)}), at least a frame from either end.`);
|
|
629
|
+
const ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));
|
|
630
|
+
if (e.id !== void 0 && ids.includes(e.id)) refuse(`There is already a clip "${e.id}".`);
|
|
631
|
+
const id = e.id ?? freshId(`${clip.id}-`, ids);
|
|
632
|
+
const left = e.at - clip.start;
|
|
633
|
+
const right = { ...structuredClone(clip), id, start: e.at, duration: end - e.at };
|
|
634
|
+
delete right.transition;
|
|
635
|
+
delete right.fadeIn;
|
|
636
|
+
if (clip.type === "media") {
|
|
637
|
+
right.in = (clip.in ?? 0) + left * videoClipSpeed(clip);
|
|
638
|
+
}
|
|
639
|
+
clip.duration = left;
|
|
640
|
+
delete clip.fadeOut;
|
|
641
|
+
track.clips.push(right);
|
|
642
|
+
track.clips.sort((a, b) => a.start - b.start);
|
|
643
|
+
return this.outcomes.push({ op: e.op, clips: [clip.id, id], clip: id, track: track.id });
|
|
644
|
+
}
|
|
645
|
+
case "delete_clips": {
|
|
646
|
+
const found = [...new Set(e.clips)].map((id) => this.clip(id));
|
|
647
|
+
for (const f of found) this.unlocked(f.track);
|
|
648
|
+
for (const track of new Set(found.map((f) => f.track))) {
|
|
649
|
+
const gone = found.filter((f) => f.track === track).map((f) => f.clip);
|
|
650
|
+
track.clips = track.clips.filter((c) => !gone.includes(c));
|
|
651
|
+
if (e.ripple) for (const c of [...gone].sort((a, b) => b.start - a.start)) this.shift(track, videoClipEnd(c), -c.duration);
|
|
652
|
+
}
|
|
653
|
+
return this.outcomes.push({ op: e.op, clips: found.map((f) => f.clip.id) });
|
|
654
|
+
}
|
|
655
|
+
case "update_clip": {
|
|
656
|
+
const at = this.clip(e.clip);
|
|
657
|
+
this.unlocked(at.track);
|
|
658
|
+
const set = { ...e.set ?? {} };
|
|
659
|
+
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
|
+
for (const k of e.remove ?? []) if (!OPTIONAL_CLIP_FIELDS.includes(k)) refuse(`"${k}" cannot be removed; removable fields are ${OPTIONAL_CLIP_FIELDS.join(", ")}.`);
|
|
661
|
+
if ("speed" in set && (typeof set.speed !== "number" || !(set.speed > 0))) refuse('"speed" must be a number greater than 0 (1 = normal speed).');
|
|
662
|
+
if (at.clip.type === "media" && !("duration" in set)) {
|
|
663
|
+
const was = videoClipSpeed(at.clip);
|
|
664
|
+
const now = "speed" in set ? set.speed : e.remove?.includes("speed") ? 1 : was;
|
|
665
|
+
if (now !== was) set.duration = at.clip.duration * was / now;
|
|
666
|
+
}
|
|
667
|
+
const next = { ...at.clip, ...set };
|
|
668
|
+
for (const k of e.remove ?? []) delete next[k];
|
|
669
|
+
const clip = parseVideoClip(next, "set");
|
|
670
|
+
if (clip.type === "media" && clip.asset !== at.clip.asset) this.kindFits(at.track, clip);
|
|
671
|
+
if (clip.start !== at.clip.start || clip.duration !== at.clip.duration) this.fits(at.track, clip.start, clip.duration, /* @__PURE__ */ new Set([clip.id]), `Clip "${clip.id}"`);
|
|
672
|
+
at.track.clips[at.index] = clip;
|
|
673
|
+
at.track.clips.sort((a, b) => a.start - b.start);
|
|
674
|
+
return this.outcomes.push({ op: e.op, clip: clip.id, track: at.track.id });
|
|
675
|
+
}
|
|
676
|
+
case "set_transition": {
|
|
677
|
+
const { track, clip } = this.clip(e.clip);
|
|
678
|
+
this.unlocked(track);
|
|
679
|
+
if (e.transition === null) delete clip.transition;
|
|
680
|
+
else clip.transition = parseVideoTransition(e.transition, "transition");
|
|
681
|
+
return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
|
|
682
|
+
}
|
|
683
|
+
case "close_gaps": {
|
|
684
|
+
const track = this.track(e.track);
|
|
685
|
+
this.unlocked(track);
|
|
686
|
+
let at = 0;
|
|
687
|
+
const moved = [];
|
|
688
|
+
for (const clip of sortedVideoClips(track)) {
|
|
689
|
+
if (clip.start >= (e.from ?? 0) - VIDEO_TIME_EPSILON && clip.start > at + VIDEO_TIME_EPSILON) {
|
|
690
|
+
clip.start = Math.max(at, e.from ?? 0);
|
|
691
|
+
moved.push(clip.id);
|
|
692
|
+
}
|
|
693
|
+
at = Math.max(at, videoClipEnd(clip));
|
|
694
|
+
}
|
|
695
|
+
return this.outcomes.push({ op: e.op, track: track.id, clips: moved });
|
|
696
|
+
}
|
|
697
|
+
case "add_marker": {
|
|
698
|
+
const markers = this.p.markers ??= [];
|
|
699
|
+
if (e.marker.id !== void 0 && markers.some((m) => m.id === e.marker.id)) refuse(`There is already a marker "${e.marker.id}".`);
|
|
700
|
+
const marker = parseVideoMarker({ ...e.marker, id: e.marker.id ?? freshId("m", markers.map((m) => m.id)) });
|
|
701
|
+
markers.push(marker);
|
|
702
|
+
markers.sort((a, b) => a.time - b.time);
|
|
703
|
+
return this.outcomes.push({ op: e.op, marker: marker.id });
|
|
704
|
+
}
|
|
705
|
+
case "update_marker": {
|
|
706
|
+
const markers = this.p.markers ?? [];
|
|
707
|
+
const i = markers.findIndex((m) => m.id === e.marker);
|
|
708
|
+
if (i < 0) refuse(`There is no marker ${namedWithSuggestion(e.marker, markers.map((m) => m.id))}.`);
|
|
709
|
+
if (e.set && "id" in e.set) refuse("A marker's id cannot be changed.");
|
|
710
|
+
const next = { ...markers[i], ...e.set };
|
|
711
|
+
for (const k of e.remove ?? []) delete next[k];
|
|
712
|
+
markers[i] = parseVideoMarker(next);
|
|
713
|
+
markers.sort((a, b) => a.time - b.time);
|
|
714
|
+
return this.outcomes.push({ op: e.op, marker: e.marker });
|
|
715
|
+
}
|
|
716
|
+
case "remove_marker": {
|
|
717
|
+
const markers = this.p.markers ?? [];
|
|
718
|
+
if (!markers.some((m) => m.id === e.marker)) refuse(`There is no marker ${namedWithSuggestion(e.marker, markers.map((m) => m.id))}.`);
|
|
719
|
+
this.p.markers = markers.filter((m) => m.id !== e.marker);
|
|
720
|
+
if (!this.p.markers.length) delete this.p.markers;
|
|
721
|
+
return this.outcomes.push({ op: e.op, marker: e.marker });
|
|
722
|
+
}
|
|
723
|
+
case "replace_project": {
|
|
724
|
+
this.p = parseVideoProject(e.project);
|
|
725
|
+
return this.outcomes.push({ op: e.op });
|
|
726
|
+
}
|
|
727
|
+
default:
|
|
728
|
+
refuse(`Unknown op ${namedWithSuggestion(String(e.op), VIDEO_EDIT_OPS)}; the ops are ${VIDEO_EDIT_OPS.join(", ")}.`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
function applyVideoEdits(project, edits) {
|
|
733
|
+
const list = Array.isArray(edits) ? edits : [edits];
|
|
734
|
+
if (!list.length) return { ok: true, project, issues: checkVideoProject(project), outcomes: [] };
|
|
735
|
+
const editor = new Editor(structuredClone(project));
|
|
736
|
+
for (let i = 0; i < list.length; i++) {
|
|
737
|
+
const edit = list[i];
|
|
738
|
+
try {
|
|
739
|
+
editor.run(edit);
|
|
740
|
+
} catch (error) {
|
|
741
|
+
const message = error.message;
|
|
742
|
+
return { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${edit?.op}): ` : ""}${message}${/Nothing was changed\.$/.test(message) ? "" : " Nothing was changed."}` };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
let next;
|
|
746
|
+
try {
|
|
747
|
+
next = parseVideoProject(editor.p);
|
|
748
|
+
} catch (error) {
|
|
749
|
+
return { ok: false, error: `${error.message} Nothing was changed.` };
|
|
750
|
+
}
|
|
751
|
+
const before = new Set(checkVideoProject(project).filter((i) => i.severity === "error").map(videoIssueKey));
|
|
752
|
+
const issues = checkVideoProject(next);
|
|
753
|
+
const added = issues.find((i) => i.severity === "error" && !before.has(videoIssueKey(i)));
|
|
754
|
+
if (added) return { ok: false, error: `That would leave an error: ${added.message} Nothing was changed.` };
|
|
755
|
+
if (JSON.stringify(next) === JSON.stringify(project)) return { ok: true, project, issues, outcomes: editor.outcomes };
|
|
756
|
+
return { ok: true, project: next, issues, outcomes: editor.outcomes };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// src/video-editor/evaluate.ts
|
|
760
|
+
function placeVideoRect(frame, size, fit = "contain", transform = {}) {
|
|
761
|
+
let w = size.width;
|
|
762
|
+
let h = size.height;
|
|
763
|
+
if (fit === "fill") {
|
|
764
|
+
w = frame.width;
|
|
765
|
+
h = frame.height;
|
|
766
|
+
} else if (fit !== "none") {
|
|
767
|
+
const k = fit === "cover" ? Math.max(frame.width / w, frame.height / h) : Math.min(frame.width / w, frame.height / h);
|
|
768
|
+
w *= k;
|
|
769
|
+
h *= k;
|
|
770
|
+
}
|
|
771
|
+
const scale = transform?.scale ?? 1;
|
|
772
|
+
w *= scale;
|
|
773
|
+
h *= scale;
|
|
774
|
+
const cx = frame.width / 2 + (transform?.x ?? 0);
|
|
775
|
+
const cy = frame.height / 2 + (transform?.y ?? 0);
|
|
776
|
+
return { x: cx - w / 2, y: cy - h / 2, width: w, height: h };
|
|
777
|
+
}
|
|
778
|
+
function videoTextStyle(clip) {
|
|
779
|
+
return { text: clip.text, color: clip.color ?? "#ffffff", fontSize: clip.fontSize ?? 64, fontFamily: clip.fontFamily ?? "sans-serif", fontWeight: clip.fontWeight ?? 700, ...clip.background ? { background: clip.background } : {} };
|
|
780
|
+
}
|
|
781
|
+
function videoTitleFrame(project, clip) {
|
|
782
|
+
const layer = { clip: clip.id, track: "", type: "text", opacity: 1, rect: placeVideoRect(project, project, "fill", clip.transform), rotation: clip.transform?.rotation ?? 0, text: videoTextStyle(clip) };
|
|
783
|
+
return { time: clip.start, width: project.width, height: project.height, background: "rgba(0,0,0,0)", layers: [layer], audio: [] };
|
|
784
|
+
}
|
|
785
|
+
var clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
786
|
+
function fadeGain(clip, time) {
|
|
787
|
+
let g = 1;
|
|
788
|
+
if (clip.fadeIn) g *= clamp01((time - clip.start) / clip.fadeIn);
|
|
789
|
+
if (clip.fadeOut) g *= clamp01((videoClipEnd(clip) - time) / clip.fadeOut);
|
|
790
|
+
return g;
|
|
791
|
+
}
|
|
792
|
+
function activeOnTrack(track, time) {
|
|
793
|
+
const out = [];
|
|
794
|
+
for (const clip of sortedVideoClips(track)) {
|
|
795
|
+
const before = clip.transition ? videoClipBefore(track, clip) : null;
|
|
796
|
+
const lead = before ? clip.transition.duration / 2 : 0;
|
|
797
|
+
const next = videoClipAfter(track, clip);
|
|
798
|
+
const tail = next?.transition ? next.transition.duration / 2 : 0;
|
|
799
|
+
if (time < clip.start - lead || time >= videoClipEnd(clip) + tail) continue;
|
|
800
|
+
const a = { clip };
|
|
801
|
+
if (lead && time < clip.start + lead) a.incoming = { progress: clamp01((time - (clip.start - lead)) / (2 * lead)), type: clip.transition.type, transition: clip.transition };
|
|
802
|
+
if (tail && time >= videoClipEnd(clip) - tail) a.outgoing = { progress: clamp01((time - (videoClipEnd(clip) - tail)) / (2 * tail)), type: next.transition.type, next };
|
|
803
|
+
out.push(a);
|
|
804
|
+
}
|
|
805
|
+
return out;
|
|
806
|
+
}
|
|
807
|
+
var sourceTimeAt = (clip, time) => clip.type === "media" ? (clip.in ?? 0) + (time - clip.start) * videoClipSpeed(clip) : 0;
|
|
808
|
+
function videoFrameAt(project, time) {
|
|
809
|
+
const assets = videoAssetMap(project);
|
|
810
|
+
const layers = [];
|
|
811
|
+
const audio = [];
|
|
812
|
+
for (let ti = project.tracks.length - 1; ti >= 0; ti--) {
|
|
813
|
+
const track = project.tracks[ti];
|
|
814
|
+
for (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {
|
|
815
|
+
if (incoming?.type === "dip" && incoming.progress < 0.5) continue;
|
|
816
|
+
if (outgoing?.type === "dip" && outgoing.progress >= 0.5) continue;
|
|
817
|
+
const fade = fadeGain(clip, Math.min(Math.max(time, clip.start), videoClipEnd(clip)));
|
|
818
|
+
const level = (t, into) => t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress;
|
|
819
|
+
const mix = (incoming ? level(incoming, true) : 1) * (outgoing ? level(outgoing, false) : 1);
|
|
820
|
+
const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
|
|
821
|
+
const sourceTime = sourceTimeAt(clip, time);
|
|
822
|
+
const audible = asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
|
|
823
|
+
if (audible && clip.type === "media" && !clip.muted && !track.muted) {
|
|
824
|
+
const gain = (clip.volume ?? 1) * (track.volume ?? 1) * fade * mix;
|
|
825
|
+
audio.push({ clip: clip.id, track: track.id, asset: asset.id, src: asset.src, sourceTime, gain, speed: videoClipSpeed(clip) });
|
|
826
|
+
}
|
|
827
|
+
if (track.kind !== "video" || track.hidden) continue;
|
|
828
|
+
if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
|
|
829
|
+
let opacity = (clip.opacity ?? 1) * fade;
|
|
830
|
+
let wipe;
|
|
831
|
+
let dip = null;
|
|
832
|
+
if (incoming) {
|
|
833
|
+
if (incoming.type === "crossfade") opacity *= incoming.progress;
|
|
834
|
+
else if (incoming.type === "wipe") wipe = { direction: incoming.transition.direction ?? "right", progress: incoming.progress };
|
|
835
|
+
else dip = { color: incoming.transition.color ?? "#000000", opacity: (1 - incoming.progress) * 2 };
|
|
836
|
+
}
|
|
837
|
+
if (outgoing?.type === "dip") dip = { color: outgoing.next.transition.color ?? "#000000", opacity: outgoing.progress * 2 };
|
|
838
|
+
const size = clip.type === "media" ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
|
|
839
|
+
const fit = clip.fit ?? (clip.type === "media" ? "contain" : "fill");
|
|
840
|
+
const rect = clip.type === "text" ? placeVideoRect(project, size, "fill", clip.transform) : placeVideoRect(project, size, fit, clip.transform);
|
|
841
|
+
const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: clip.transform?.rotation ?? 0 };
|
|
842
|
+
if (wipe) layer.wipe = wipe;
|
|
843
|
+
if (clip.type === "media") Object.assign(layer, { asset: asset.id, assetKind: asset.kind, src: asset.src, sourceTime });
|
|
844
|
+
if (clip.type === "text") layer.text = videoTextStyle(clip);
|
|
845
|
+
if (clip.type === "color") layer.color = clip.color;
|
|
846
|
+
if (layer.opacity > 0) layers.push(layer);
|
|
847
|
+
if (dip && dip.opacity > 0) layers.push({ clip: `transition:${incoming ? clip.id : outgoing.next.id}`, track: track.id, type: "color", color: dip.color, opacity: clamp01(dip.opacity), rect: { x: 0, y: 0, width: project.width, height: project.height }, rotation: 0 });
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return { time, width: project.width, height: project.height, background: project.background ?? "#000000", layers, audio };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// src/video-editor/render.ts
|
|
854
|
+
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
855
|
+
var n = (v) => String(Math.round(v * 100) / 100);
|
|
856
|
+
function videoWipeRect(rect, wipe) {
|
|
857
|
+
const { direction, progress } = wipe;
|
|
858
|
+
const w = rect.width * progress;
|
|
859
|
+
const h = rect.height * progress;
|
|
860
|
+
if (direction === "right") return { ...rect, width: w };
|
|
861
|
+
if (direction === "left") return { ...rect, x: rect.x + rect.width - w, width: w };
|
|
862
|
+
if (direction === "down") return { ...rect, height: h };
|
|
863
|
+
return { ...rect, y: rect.y + rect.height - h, height: h };
|
|
864
|
+
}
|
|
865
|
+
function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
866
|
+
const defs = [];
|
|
867
|
+
const body = [];
|
|
868
|
+
const posters = new Map(assets.map((a) => [a.id, a.poster]));
|
|
869
|
+
frame.layers.forEach((layer, i) => {
|
|
870
|
+
const { rect } = layer;
|
|
871
|
+
const attrs = [];
|
|
872
|
+
if (layer.opacity < 1) attrs.push(`opacity="${n(layer.opacity)}"`);
|
|
873
|
+
if (layer.rotation) attrs.push(`transform="rotate(${n(layer.rotation)} ${n(rect.x + rect.width / 2)} ${n(rect.y + rect.height / 2)})"`);
|
|
874
|
+
if (layer.wipe) {
|
|
875
|
+
const r = videoWipeRect(rect, layer.wipe);
|
|
876
|
+
defs.push(`<clipPath id="w${i}"><rect x="${n(r.x)}" y="${n(r.y)}" width="${n(r.width)}" height="${n(r.height)}"/></clipPath>`);
|
|
877
|
+
attrs.push(`clip-path="url(#w${i})"`);
|
|
878
|
+
}
|
|
879
|
+
const box = `x="${n(rect.x)}" y="${n(rect.y)}" width="${n(rect.width)}" height="${n(rect.height)}"`;
|
|
880
|
+
let inner = "";
|
|
881
|
+
if (layer.type === "color") inner = `<rect ${box} fill="${esc(layer.color ?? "#000000")}"/>`;
|
|
882
|
+
else if (layer.type === "text" && layer.text) {
|
|
883
|
+
const t = layer.text;
|
|
884
|
+
const lines = t.text.split("\n");
|
|
885
|
+
const lh = t.fontSize * 1.2;
|
|
886
|
+
const cx = rect.x + rect.width / 2;
|
|
887
|
+
const cy = rect.y + rect.height / 2;
|
|
888
|
+
const top = cy - lh * (lines.length - 1) / 2;
|
|
889
|
+
if (t.background) {
|
|
890
|
+
const w = Math.max(...lines.map((l) => l.length)) * t.fontSize * 0.6 + t.fontSize;
|
|
891
|
+
const h = lh * lines.length + t.fontSize * 0.4;
|
|
892
|
+
inner += `<rect x="${n(cx - w / 2)}" y="${n(cy - h / 2)}" width="${n(w)}" height="${n(h)}" fill="${esc(t.background)}"/>`;
|
|
893
|
+
}
|
|
894
|
+
inner += `<text x="${n(cx)}" text-anchor="middle" dominant-baseline="central" font-family="${esc(t.fontFamily)}" font-size="${n(t.fontSize)}" font-weight="${t.fontWeight}" fill="${esc(t.color)}">${lines.map((l, k) => `<tspan x="${n(cx)}" y="${n(top + k * lh)}">${esc(l)}</tspan>`).join("")}</text>`;
|
|
895
|
+
} else {
|
|
896
|
+
const href = options.imageFor ? options.imageFor(layer, frame) : layer.assetKind === "image" ? layer.src ?? null : posters.get(layer.asset ?? "") ?? null;
|
|
897
|
+
if (href) inner = `<image href="${esc(href)}" ${box} preserveAspectRatio="none"/>`;
|
|
898
|
+
else {
|
|
899
|
+
const label = `${layer.asset ?? layer.clip} \xB7 ${formatVideoTimecode(layer.sourceTime ?? 0, options.fps ?? 30)}`;
|
|
900
|
+
const size = Math.max(10, Math.min(rect.height / 8, rect.width / Math.max(8, label.length * 0.6)));
|
|
901
|
+
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>`;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
body.push(attrs.length ? `<g ${attrs.join(" ")}>${inner}</g>` : inner);
|
|
905
|
+
});
|
|
906
|
+
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>`;
|
|
907
|
+
}
|
|
908
|
+
function videoProjectSvg(project, time, options = {}) {
|
|
909
|
+
return videoFrameToSvg(videoFrameAt(project, time), { fps: project.fps, ...options }, project.assets);
|
|
910
|
+
}
|
|
911
|
+
function videoContactSheetSvg(project, times, options = {}) {
|
|
912
|
+
const columns = options.columns ?? Math.ceil(Math.sqrt(times.length));
|
|
913
|
+
const w = options.width ?? 320;
|
|
914
|
+
const h = w * project.height / project.width;
|
|
915
|
+
const label = 18;
|
|
916
|
+
const gap = 8;
|
|
917
|
+
const rows = Math.ceil(times.length / columns);
|
|
918
|
+
const W = columns * w + (columns + 1) * gap;
|
|
919
|
+
const H = rows * (h + label) + (rows + 1) * gap;
|
|
920
|
+
const cells = times.map((t, i) => {
|
|
921
|
+
const x = gap + i % columns * (w + gap);
|
|
922
|
+
const y = gap + Math.floor(i / columns) * (h + label + gap);
|
|
923
|
+
const svg = videoProjectSvg(project, t, options).replace(/^<svg xmlns="http:\/\/www.w3.org\/2000\/svg"/, `<svg x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}"`).replace(/ width="[\d.]+" height="[\d.]+">/, ">");
|
|
924
|
+
return `${svg}<text x="${n(x)}" y="${n(y + h + 14)}" font-family="sans-serif" font-size="12" fill="#0f172a">${(t / 1e3).toFixed(2)} s \xB7 ${formatVideoTimecode(t, project.fps)}</text>`;
|
|
925
|
+
});
|
|
926
|
+
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>`;
|
|
927
|
+
}
|
|
928
|
+
function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
|
|
929
|
+
ctx.save();
|
|
930
|
+
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
931
|
+
ctx.globalAlpha = 1;
|
|
932
|
+
ctx.fillStyle = frame.background;
|
|
933
|
+
ctx.fillRect(0, 0, frame.width, frame.height);
|
|
934
|
+
for (const layer of frame.layers) {
|
|
935
|
+
const { rect } = layer;
|
|
936
|
+
ctx.save();
|
|
937
|
+
ctx.globalAlpha = layer.opacity;
|
|
938
|
+
if (layer.rotation) {
|
|
939
|
+
ctx.translate(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
|
940
|
+
ctx.rotate(layer.rotation * Math.PI / 180);
|
|
941
|
+
ctx.translate(-(rect.x + rect.width / 2), -(rect.y + rect.height / 2));
|
|
942
|
+
}
|
|
943
|
+
if (layer.wipe) {
|
|
944
|
+
const r = videoWipeRect(rect, layer.wipe);
|
|
945
|
+
ctx.beginPath();
|
|
946
|
+
ctx.rect(r.x, r.y, r.width, r.height);
|
|
947
|
+
ctx.clip();
|
|
948
|
+
}
|
|
949
|
+
if (layer.type === "color") {
|
|
950
|
+
ctx.fillStyle = layer.color ?? "#000000";
|
|
951
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
952
|
+
} else if (layer.type === "text" && layer.text) {
|
|
953
|
+
const t = layer.text;
|
|
954
|
+
const lines = t.text.split("\n");
|
|
955
|
+
const lh = t.fontSize * 1.2;
|
|
956
|
+
const cx = rect.x + rect.width / 2;
|
|
957
|
+
const top = rect.y + rect.height / 2 - lh * (lines.length - 1) / 2;
|
|
958
|
+
ctx.font = `${t.fontWeight} ${t.fontSize}px ${t.fontFamily}`;
|
|
959
|
+
ctx.textAlign = "center";
|
|
960
|
+
ctx.textBaseline = "middle";
|
|
961
|
+
if (t.background) {
|
|
962
|
+
const w = Math.max(...lines.map((l) => ctx.measureText(l).width)) + t.fontSize;
|
|
963
|
+
const h = lh * lines.length + t.fontSize * 0.4;
|
|
964
|
+
ctx.fillStyle = t.background;
|
|
965
|
+
ctx.fillRect(cx - w / 2, rect.y + rect.height / 2 - h / 2, w, h);
|
|
966
|
+
}
|
|
967
|
+
ctx.fillStyle = t.color;
|
|
968
|
+
lines.forEach((l, k) => ctx.fillText(l, cx, top + k * lh));
|
|
969
|
+
} else {
|
|
970
|
+
const source = sourceFor(layer);
|
|
971
|
+
if (source) ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);
|
|
972
|
+
else {
|
|
973
|
+
ctx.fillStyle = "#334155";
|
|
974
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
975
|
+
ctx.strokeStyle = "#94a3b8";
|
|
976
|
+
ctx.lineWidth = 2;
|
|
977
|
+
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
ctx.restore();
|
|
981
|
+
}
|
|
982
|
+
ctx.restore();
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// src/video-editor/export.ts
|
|
986
|
+
var reelName = (asset) => (asset?.name ?? asset?.id ?? "AX").replace(/\.[a-z0-9]+$/i, "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 8) || "AX";
|
|
987
|
+
function toVideoEdl(project, options = {}) {
|
|
988
|
+
const track = options.track !== void 0 ? project.tracks.find((t) => t.id === options.track) : [...project.tracks].reverse().find((t) => t.kind === "video");
|
|
989
|
+
if (!track) throw new Error(options.track !== void 0 ? `There is no track "${options.track}".` : "The project has no video track.");
|
|
990
|
+
const fps = project.fps;
|
|
991
|
+
const tc = (ms) => formatVideoTimecode(ms, fps);
|
|
992
|
+
const rec0 = options.recordStart ?? 36e5;
|
|
993
|
+
const assets = videoAssetMap(project);
|
|
994
|
+
const channel = track.kind === "video" ? "V" : "A";
|
|
995
|
+
const lines = [`TITLE: ${(options.title ?? project.name ?? "Untitled").toUpperCase()}`, "FCM: NON-DROP FRAME", ""];
|
|
996
|
+
let event = 0;
|
|
997
|
+
const num2 = () => String(++event).padStart(3, "0");
|
|
998
|
+
const sourceAt = (clip, time) => clip.type === "media" ? (clip.in ?? 0) + (time - clip.start) * videoClipSpeed(clip) : time - clip.start;
|
|
999
|
+
const row = (n2, reel, kind, srcIn, srcOut, recIn, recOut) => `${n2} ${reel.padEnd(8)} ${channel.padEnd(5)} ${kind.padEnd(8)} ${tc(srcIn)} ${tc(srcOut)} ${tc(rec0 + recIn)} ${tc(rec0 + recOut)}`;
|
|
1000
|
+
for (const clip of sortedVideoClips(track)) {
|
|
1001
|
+
const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
|
|
1002
|
+
const reel = clip.type === "media" ? reelName(asset) : "BL";
|
|
1003
|
+
const end = videoClipEnd(clip);
|
|
1004
|
+
const before = clip.transition ? videoClipBefore(track, clip) : null;
|
|
1005
|
+
const half = before && clip.transition.type !== "dip" ? clip.transition.duration / 2 : 0;
|
|
1006
|
+
const next = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition && c.transition.type !== "dip");
|
|
1007
|
+
const recOut = next ? end - next.transition.duration / 2 : end;
|
|
1008
|
+
const recIn = clip.start - half;
|
|
1009
|
+
const n2 = num2();
|
|
1010
|
+
if (before) {
|
|
1011
|
+
const t = clip.transition;
|
|
1012
|
+
const frames = String(Math.round(t.duration * fps / 1e3)).padStart(3, "0");
|
|
1013
|
+
const outReel = before.type === "media" ? reelName(assets.get(before.asset)) : "BL";
|
|
1014
|
+
const at = sourceAt(before, recIn);
|
|
1015
|
+
lines.push(row(n2, outReel, "C", at, at, recIn, recIn));
|
|
1016
|
+
lines.push(row(n2, reel, `${t.type === "wipe" ? "W001" : "D"} ${frames}`, sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
|
|
1017
|
+
} else lines.push(row(n2, reel, "C", sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
|
|
1018
|
+
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: ${asset?.name ?? asset?.src ?? clip.asset}`);
|
|
1020
|
+
if (clip.type === "text") lines.push(`* TITLE: ${clip.text.replace(/\n/g, " ")}`);
|
|
1021
|
+
if (clip.type === "color") lines.push(`* COLOR: ${clip.color}`);
|
|
1022
|
+
lines.push("");
|
|
1023
|
+
}
|
|
1024
|
+
return lines.join("\n");
|
|
1025
|
+
}
|
|
1026
|
+
var sec = (ms) => String(Math.round(ms) / 1e3);
|
|
1027
|
+
var even = (v) => Math.max(2, 2 * Math.round(v / 2));
|
|
1028
|
+
var ffmpegFilterValue = (s) => s.replace(/[\\':%]/g, (c) => `\\${c}`).replace(/[\\'[\],;]/g, (c) => `\\${c}`);
|
|
1029
|
+
var color = (c) => {
|
|
1030
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(c);
|
|
1031
|
+
if (!hex) return c.replace(/[^A-Za-z0-9@.]/g, "");
|
|
1032
|
+
const h = hex[1].length === 3 ? [...hex[1]].map((x) => x + x).join("") : hex[1];
|
|
1033
|
+
return `0x${h.slice(0, 6)}${h.length === 8 ? `@${(parseInt(h.slice(6), 16) / 255).toFixed(3)}` : ""}`;
|
|
1034
|
+
};
|
|
1035
|
+
var shellQuote = (s) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1036
|
+
function atempo(speed) {
|
|
1037
|
+
const out = [];
|
|
1038
|
+
let s = speed;
|
|
1039
|
+
while (s > 2) {
|
|
1040
|
+
out.push("atempo=2");
|
|
1041
|
+
s /= 2;
|
|
1042
|
+
}
|
|
1043
|
+
while (s < 0.5) {
|
|
1044
|
+
out.push("atempo=0.5");
|
|
1045
|
+
s /= 0.5;
|
|
1046
|
+
}
|
|
1047
|
+
if (Math.abs(s - 1) > 1e-6) out.push(`atempo=${Math.round(s * 1e6) / 1e6}`);
|
|
1048
|
+
return out;
|
|
1049
|
+
}
|
|
1050
|
+
function toVideoFfmpeg(project, options = {}) {
|
|
1051
|
+
const notes = [];
|
|
1052
|
+
const assets = videoAssetMap(project);
|
|
1053
|
+
const D = videoProjectDuration(project);
|
|
1054
|
+
if (D <= 0) throw new Error("The project has no clips, so there is nothing to render.");
|
|
1055
|
+
const W = even(project.width);
|
|
1056
|
+
const H = even(project.height);
|
|
1057
|
+
const fps = project.fps;
|
|
1058
|
+
const inputs = [];
|
|
1059
|
+
const graph = [`color=c=${color(project.background ?? "#000000")}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];
|
|
1060
|
+
const audio = [];
|
|
1061
|
+
let cur = "base";
|
|
1062
|
+
let usesDrawtext = false;
|
|
1063
|
+
let k = 0;
|
|
1064
|
+
const input = (clip, asset, lead, tail) => {
|
|
1065
|
+
const speed = videoClipSpeed(clip);
|
|
1066
|
+
const src = options.resolve ? options.resolve(asset) : asset.src;
|
|
1067
|
+
const span = clip.duration + lead + tail;
|
|
1068
|
+
if (asset.kind === "image") inputs.push(["-loop", "1", "-framerate", String(fps), "-t", sec(span), "-i", src]);
|
|
1069
|
+
else inputs.push(["-ss", sec((clip.in ?? 0) - lead * speed), "-t", sec(span * speed), "-i", src]);
|
|
1070
|
+
return inputs.length - 1;
|
|
1071
|
+
};
|
|
1072
|
+
for (const track of [...project.tracks].reverse()) {
|
|
1073
|
+
for (const clip of sortedVideoClips(track)) {
|
|
1074
|
+
const { lead, tail } = videoClipOverhang(track, clip);
|
|
1075
|
+
const t0 = clip.start - lead;
|
|
1076
|
+
const span = clip.duration + lead + tail;
|
|
1077
|
+
const end = videoClipEnd(clip);
|
|
1078
|
+
const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
|
|
1079
|
+
if (clip.type === "media" && !asset) {
|
|
1080
|
+
notes.push(`Clip "${clip.id}" was left out: its asset "${clip.asset}" is missing.`);
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
let index;
|
|
1084
|
+
if (asset && asset.kind !== "audio" && track.kind === "video" && !track.hidden) index = input(clip, asset, lead, tail);
|
|
1085
|
+
const audible = asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
|
|
1086
|
+
if (audible && clip.type === "media" && !clip.muted && !track.muted) {
|
|
1087
|
+
const ai = index ?? input(clip, asset, lead, tail);
|
|
1088
|
+
const gain = (clip.volume ?? 1) * (track.volume ?? 1);
|
|
1089
|
+
const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), `volume=${gain}`];
|
|
1090
|
+
const before2 = clip.transition ? videoClipBefore(track, clip) : null;
|
|
1091
|
+
if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}`);
|
|
1092
|
+
if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}`);
|
|
1093
|
+
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 && c.transition?.type === "dip");
|
|
1095
|
+
if (after) chain2.push(`afade=t=out:st=${sec(span - after.transition.duration / 2)}:d=${sec(after.transition.duration / 2)}`);
|
|
1096
|
+
if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}`);
|
|
1097
|
+
if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}`);
|
|
1098
|
+
chain2.push(`adelay=${Math.round(t0)}:all=1`);
|
|
1099
|
+
graph.push(`[${ai}:a]${chain2.join(",")}[a${audio.length}]`);
|
|
1100
|
+
audio.push(`[a${audio.length}]`);
|
|
1101
|
+
}
|
|
1102
|
+
if (track.kind !== "video" || track.hidden) continue;
|
|
1103
|
+
const enable = `enable='between(t,${sec(t0)},${sec(t0 + span)})'`;
|
|
1104
|
+
const next = `v${k++}`;
|
|
1105
|
+
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 })]);
|
|
1107
|
+
index = inputs.length - 1;
|
|
1108
|
+
} else if (clip.type === "text") {
|
|
1109
|
+
usesDrawtext = true;
|
|
1110
|
+
const op = clip.opacity ?? 1;
|
|
1111
|
+
const alpha = [String(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("*");
|
|
1112
|
+
const x = clip.transform?.x ?? 0;
|
|
1113
|
+
const y = clip.transform?.y ?? 0;
|
|
1114
|
+
const opts = [
|
|
1115
|
+
`text=${ffmpegFilterValue(clip.text)}`,
|
|
1116
|
+
options.fontFile ? `fontfile=${ffmpegFilterValue(options.fontFile)}` : `font=${ffmpegFilterValue(clip.fontFamily ?? "sans-serif")}`,
|
|
1117
|
+
`fontsize=${Math.round((clip.fontSize ?? 64) * (clip.transform?.scale ?? 1))}`,
|
|
1118
|
+
`fontcolor=${color(clip.color ?? "#ffffff")}`,
|
|
1119
|
+
`x=(w-text_w)/2+${x}`,
|
|
1120
|
+
`y=(h-text_h)/2+${y}`,
|
|
1121
|
+
`alpha='${alpha}'`,
|
|
1122
|
+
...clip.background ? ["box=1", `boxcolor=${color(clip.background)}`, `boxborderw=${Math.round((clip.fontSize ?? 64) / 2)}`] : [],
|
|
1123
|
+
`enable='between(t,${sec(clip.start)},${sec(end)})'`
|
|
1124
|
+
];
|
|
1125
|
+
if (clip.transform?.rotation) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
|
|
1126
|
+
graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
|
|
1127
|
+
cur = next;
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
const size = asset ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
|
|
1131
|
+
const rect = clip.type === "text" ? { x: 0, y: 0, width: project.width, height: project.height } : placeVideoRect(project, size, clip.fit ?? (clip.type === "media" ? "contain" : "fill"), clip.transform);
|
|
1132
|
+
const chain = [];
|
|
1133
|
+
let source;
|
|
1134
|
+
if (clip.type === "color") source = `color=c=${color(clip.color)}:s=${even(rect.width)}x${even(rect.height)}:r=${fps}:d=${sec(span)}`;
|
|
1135
|
+
else {
|
|
1136
|
+
source = `[${index}:v]`;
|
|
1137
|
+
chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
|
|
1138
|
+
}
|
|
1139
|
+
chain.push("format=yuva420p");
|
|
1140
|
+
if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
|
|
1141
|
+
if (clip.fadeIn) chain.push(`fade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}:alpha=1`);
|
|
1142
|
+
if (clip.fadeOut) chain.push(`fade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}:alpha=1`);
|
|
1143
|
+
if (lead && clip.transition.type === "crossfade") chain.push(`fade=t=in:st=0:d=${sec(2 * lead)}:alpha=1`);
|
|
1144
|
+
if (lead && clip.transition.type === "wipe") {
|
|
1145
|
+
const d = sec(2 * lead);
|
|
1146
|
+
const p = `min(1,T/${d})`;
|
|
1147
|
+
const dir = clip.transition.direction ?? "right";
|
|
1148
|
+
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
|
+
chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
|
|
1150
|
+
}
|
|
1151
|
+
if (clip.transform?.rotation && clip.type !== "text") chain.push(`rotate=${clip.transform.rotation}*PI/180:c=none:ow=rotw(${clip.transform.rotation}*PI/180):oh=roth(${clip.transform.rotation}*PI/180)`);
|
|
1152
|
+
chain.push(`setpts=PTS+${sec(t0)}/TB`);
|
|
1153
|
+
const cx = rect.x + rect.width / 2;
|
|
1154
|
+
const cy = rect.y + rect.height / 2;
|
|
1155
|
+
graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
|
|
1156
|
+
graph.push(`[${cur}][l${next}]overlay=x='${Math.round(cx)}-w/2':y='${Math.round(cy)}-h/2':eof_action=pass:${enable}[${next}]`);
|
|
1157
|
+
cur = next;
|
|
1158
|
+
const before = clip.transition ? videoClipBefore(track, clip) : null;
|
|
1159
|
+
if (before && clip.transition.type === "dip") {
|
|
1160
|
+
const d = clip.transition.duration;
|
|
1161
|
+
const dip = `d${k}`;
|
|
1162
|
+
graph.push(`color=c=${color(clip.transition.color ?? "#000000")}:s=${W}x${H}:r=${fps}:d=${sec(d)},format=yuva420p,fade=t=in:st=0:d=${sec(d / 2)}:alpha=1,fade=t=out:st=${sec(d / 2)}:d=${sec(d / 2)}:alpha=1,setpts=PTS+${sec(clip.start - d / 2)}/TB[l${dip}]`);
|
|
1163
|
+
graph.push(`[${cur}][l${dip}]overlay=eof_action=pass:enable='between(t,${sec(clip.start - d / 2)},${sec(clip.start + d / 2)})'[${dip}]`);
|
|
1164
|
+
cur = dip;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
graph.push(`[${cur}]format=yuv420p[vout]`);
|
|
1169
|
+
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" && assets.get(c.asset)?.kind === "video" && assets.get(c.asset)?.hasAudio === void 0)))
|
|
1171
|
+
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.');
|
|
1172
|
+
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
|
+
const filterComplex = graph.join(";");
|
|
1174
|
+
const args = [
|
|
1175
|
+
"-y",
|
|
1176
|
+
...inputs.flat(),
|
|
1177
|
+
"-filter_complex",
|
|
1178
|
+
filterComplex,
|
|
1179
|
+
"-map",
|
|
1180
|
+
"[vout]",
|
|
1181
|
+
...audio.length ? ["-map", "[aout]", "-c:a", options.audioCodec ?? "aac"] : ["-an"],
|
|
1182
|
+
"-c:v",
|
|
1183
|
+
options.videoCodec ?? "libx264",
|
|
1184
|
+
...(options.videoCodec ?? "libx264") === "libx264" ? ["-crf", String(options.crf ?? 18), "-preset", "medium"] : [],
|
|
1185
|
+
"-pix_fmt",
|
|
1186
|
+
"yuv420p",
|
|
1187
|
+
"-r",
|
|
1188
|
+
String(fps),
|
|
1189
|
+
"-t",
|
|
1190
|
+
sec(D),
|
|
1191
|
+
options.output ?? "output.mp4"
|
|
1192
|
+
];
|
|
1193
|
+
return { args, command: ["ffmpeg", ...args].map(shellQuote).join(" "), filterComplex, duration: D, notes };
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// src/video-editor/preview.ts
|
|
1197
|
+
var VideoPreviewEngine = class {
|
|
1198
|
+
constructor(source, canvas, options = {}) {
|
|
1199
|
+
this.source = source;
|
|
1200
|
+
this.options = options;
|
|
1201
|
+
this.canvas = canvas;
|
|
1202
|
+
this.ctx = options.context !== void 0 ? options.context : canvas.getContext?.("2d") ?? null;
|
|
1203
|
+
this.unsubscribe = source.subscribe(() => this.update());
|
|
1204
|
+
this.update();
|
|
1205
|
+
}
|
|
1206
|
+
source;
|
|
1207
|
+
options;
|
|
1208
|
+
canvas;
|
|
1209
|
+
ctx;
|
|
1210
|
+
media = /* @__PURE__ */ new Map();
|
|
1211
|
+
images = /* @__PURE__ */ new Map();
|
|
1212
|
+
unsubscribe;
|
|
1213
|
+
scheduled = false;
|
|
1214
|
+
destroyed = false;
|
|
1215
|
+
createMedia(kind) {
|
|
1216
|
+
if (this.options.createMedia) return this.options.createMedia(kind);
|
|
1217
|
+
const el = document.createElement(kind);
|
|
1218
|
+
el.preload = "auto";
|
|
1219
|
+
el.crossOrigin = "anonymous";
|
|
1220
|
+
if (kind === "video") el.playsInline = true;
|
|
1221
|
+
return el;
|
|
1222
|
+
}
|
|
1223
|
+
image(src) {
|
|
1224
|
+
let img = this.images.get(src);
|
|
1225
|
+
if (!img) {
|
|
1226
|
+
img = this.options.createImage ? this.options.createImage() : new Image();
|
|
1227
|
+
img.addEventListener("load", () => this.redraw());
|
|
1228
|
+
img.src = src;
|
|
1229
|
+
this.images.set(src, img);
|
|
1230
|
+
}
|
|
1231
|
+
return img;
|
|
1232
|
+
}
|
|
1233
|
+
entry(clip, src, kind) {
|
|
1234
|
+
const existing = this.media.get(clip);
|
|
1235
|
+
if (existing && existing.src === src) return existing;
|
|
1236
|
+
if (existing) this.release(existing);
|
|
1237
|
+
const media = this.createMedia(kind);
|
|
1238
|
+
const onReady = () => this.redraw();
|
|
1239
|
+
media.addEventListener("seeked", onReady);
|
|
1240
|
+
media.addEventListener("loadeddata", onReady);
|
|
1241
|
+
media.src = src;
|
|
1242
|
+
const entry = { clip, src, media, onReady };
|
|
1243
|
+
this.media.set(clip, entry);
|
|
1244
|
+
return entry;
|
|
1245
|
+
}
|
|
1246
|
+
release(entry) {
|
|
1247
|
+
entry.media.pause();
|
|
1248
|
+
entry.media.removeEventListener("seeked", entry.onReady);
|
|
1249
|
+
entry.media.removeEventListener("loadeddata", entry.onReady);
|
|
1250
|
+
entry.media.removeAttribute?.("src");
|
|
1251
|
+
entry.media.load?.();
|
|
1252
|
+
this.media.delete(entry.clip);
|
|
1253
|
+
}
|
|
1254
|
+
/** Brings the media elements in step with the editor and draws the frame. */
|
|
1255
|
+
update() {
|
|
1256
|
+
if (this.destroyed) return;
|
|
1257
|
+
const { project, time, playing } = this.source.getState();
|
|
1258
|
+
const frame = videoFrameAt(project, time);
|
|
1259
|
+
const tolerance = (this.options.tolerance ?? 200) / 1e3;
|
|
1260
|
+
const active = /* @__PURE__ */ new Set();
|
|
1261
|
+
const sync = (clip, src, kind, sourceTime, speed, gain) => {
|
|
1262
|
+
const { media } = this.entry(clip, src, kind);
|
|
1263
|
+
active.add(clip);
|
|
1264
|
+
const target = sourceTime / 1e3;
|
|
1265
|
+
if (Math.abs(media.playbackRate - speed) > 1e-6) media.playbackRate = speed;
|
|
1266
|
+
if (gain === null) media.muted = true;
|
|
1267
|
+
else {
|
|
1268
|
+
media.muted = gain <= 0;
|
|
1269
|
+
media.volume = Math.min(1, Math.max(0, gain));
|
|
1270
|
+
}
|
|
1271
|
+
if (playing) {
|
|
1272
|
+
if (media.paused || Math.abs(media.currentTime - target) > tolerance) media.currentTime = target;
|
|
1273
|
+
if (media.paused) void Promise.resolve(media.play()).catch(() => {
|
|
1274
|
+
});
|
|
1275
|
+
} else {
|
|
1276
|
+
if (!media.paused) media.pause();
|
|
1277
|
+
if (Math.abs(media.currentTime - target) > 1 / (2 * project.fps)) media.currentTime = target;
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
const sounding = new Map(frame.audio.map((a) => [a.clip, a]));
|
|
1281
|
+
for (const layer of frame.layers) {
|
|
1282
|
+
if (layer.type !== "media" || layer.assetKind !== "video" || !layer.src) continue;
|
|
1283
|
+
const sound = sounding.get(layer.clip);
|
|
1284
|
+
sync(layer.clip, layer.src, "video", layer.sourceTime ?? 0, sound?.speed ?? 1, sound ? sound.gain : null);
|
|
1285
|
+
sounding.delete(layer.clip);
|
|
1286
|
+
}
|
|
1287
|
+
for (const a of sounding.values()) sync(a.clip, a.src, "audio", a.sourceTime, a.speed, a.gain);
|
|
1288
|
+
for (const entry of [...this.media.values()]) if (!active.has(entry.clip)) this.release(entry);
|
|
1289
|
+
this.draw(frame);
|
|
1290
|
+
}
|
|
1291
|
+
redraw() {
|
|
1292
|
+
if (this.scheduled || this.destroyed) return;
|
|
1293
|
+
this.scheduled = true;
|
|
1294
|
+
queueMicrotask(() => {
|
|
1295
|
+
this.scheduled = false;
|
|
1296
|
+
if (!this.destroyed) this.draw(videoFrameAt(this.source.getState().project, this.source.getState().time));
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
sourceFor = (layer) => {
|
|
1300
|
+
if (layer.assetKind === "image" && layer.src) {
|
|
1301
|
+
const img = this.image(layer.src);
|
|
1302
|
+
return img.complete ? img : null;
|
|
1303
|
+
}
|
|
1304
|
+
const entry = this.media.get(layer.clip);
|
|
1305
|
+
return entry && entry.media.readyState >= 2 ? entry.media : null;
|
|
1306
|
+
};
|
|
1307
|
+
draw(frame) {
|
|
1308
|
+
if (!this.ctx) return;
|
|
1309
|
+
const shown = this.canvas.clientWidth || frame.width;
|
|
1310
|
+
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
|
1311
|
+
const resolution = this.options.resolution ?? Math.min(1, shown * dpr / frame.width);
|
|
1312
|
+
const w = Math.max(1, Math.round(frame.width * resolution));
|
|
1313
|
+
const h = Math.max(1, Math.round(frame.height * resolution));
|
|
1314
|
+
if (this.canvas.width !== w) this.canvas.width = w;
|
|
1315
|
+
if (this.canvas.height !== h) this.canvas.height = h;
|
|
1316
|
+
drawVideoFrame(this.ctx, frame, this.sourceFor, resolution);
|
|
1317
|
+
this.options.onFrame?.(frame);
|
|
1318
|
+
}
|
|
1319
|
+
/** Stops following the editor and releases every media element. */
|
|
1320
|
+
destroy() {
|
|
1321
|
+
this.destroyed = true;
|
|
1322
|
+
this.unsubscribe();
|
|
1323
|
+
for (const entry of [...this.media.values()]) this.release(entry);
|
|
1324
|
+
this.images.clear();
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
// src/video-editor/controller.ts
|
|
1329
|
+
var VideoEditorController = class {
|
|
1330
|
+
history;
|
|
1331
|
+
player;
|
|
1332
|
+
project;
|
|
1333
|
+
issues;
|
|
1334
|
+
selection = [];
|
|
1335
|
+
readOnly;
|
|
1336
|
+
error = null;
|
|
1337
|
+
revision = 0;
|
|
1338
|
+
applying = false;
|
|
1339
|
+
onChange;
|
|
1340
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1341
|
+
unsubscribers = [];
|
|
1342
|
+
state;
|
|
1343
|
+
constructor(options = {}) {
|
|
1344
|
+
this.history = options.history ?? new HistoryManager();
|
|
1345
|
+
this.project = options.project ? parseVideoProject(options.project) : createVideoProject();
|
|
1346
|
+
this.issues = checkVideoProject(this.project);
|
|
1347
|
+
this.readOnly = options.readOnly ?? false;
|
|
1348
|
+
this.onChange = options.onChange;
|
|
1349
|
+
this.player = new AnimationPlayer({ clock: options.clock, duration: () => videoProjectDuration(this.project), loop: "none" });
|
|
1350
|
+
this.state = this.computeState();
|
|
1351
|
+
this.connect();
|
|
1352
|
+
}
|
|
1353
|
+
connect() {
|
|
1354
|
+
if (this.unsubscribers.length) return;
|
|
1355
|
+
this.unsubscribers.push(
|
|
1356
|
+
this.player.subscribe(() => this.notify()),
|
|
1357
|
+
this.history.subscribe(() => {
|
|
1358
|
+
if (!this.applying) this.notify();
|
|
1359
|
+
})
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
/** Pauses and stops listening to the player and history. Subscribing again reconnects (React StrictMode). */
|
|
1363
|
+
destroy() {
|
|
1364
|
+
this.player.pause();
|
|
1365
|
+
this.unsubscribers.splice(0).forEach((u) => u());
|
|
1366
|
+
}
|
|
1367
|
+
getState = () => this.state;
|
|
1368
|
+
subscribe = (listener) => {
|
|
1369
|
+
this.connect();
|
|
1370
|
+
this.listeners.add(listener);
|
|
1371
|
+
return () => {
|
|
1372
|
+
this.listeners.delete(listener);
|
|
1373
|
+
};
|
|
1374
|
+
};
|
|
1375
|
+
/** The current project (immutable; edit through the controller). */
|
|
1376
|
+
getProject() {
|
|
1377
|
+
return this.project;
|
|
1378
|
+
}
|
|
1379
|
+
computeState() {
|
|
1380
|
+
const p = this.player.getState();
|
|
1381
|
+
const h = this.history.getState();
|
|
1382
|
+
return {
|
|
1383
|
+
project: this.project,
|
|
1384
|
+
duration: videoProjectDuration(this.project),
|
|
1385
|
+
time: p.time,
|
|
1386
|
+
playing: p.playing,
|
|
1387
|
+
loop: p.loop,
|
|
1388
|
+
selection: this.selection,
|
|
1389
|
+
current: videoClipsAt(this.project, p.time).map((c) => c.clip.id),
|
|
1390
|
+
issues: this.issues,
|
|
1391
|
+
readOnly: this.readOnly,
|
|
1392
|
+
error: this.error,
|
|
1393
|
+
canUndo: h.canUndo,
|
|
1394
|
+
canRedo: h.canRedo,
|
|
1395
|
+
undoLabel: h.undoName,
|
|
1396
|
+
redoLabel: h.redoName,
|
|
1397
|
+
revision: this.revision
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
notify() {
|
|
1401
|
+
this.revision++;
|
|
1402
|
+
this.state = this.computeState();
|
|
1403
|
+
const state = this.state;
|
|
1404
|
+
this.listeners.forEach((listener) => {
|
|
1405
|
+
try {
|
|
1406
|
+
listener(state);
|
|
1407
|
+
} catch (error) {
|
|
1408
|
+
console.error("[VideoEditorController] Error in state listener:", error);
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
setProject(project) {
|
|
1413
|
+
this.applying = true;
|
|
1414
|
+
try {
|
|
1415
|
+
this.project = project;
|
|
1416
|
+
this.issues = checkVideoProject(project);
|
|
1417
|
+
const ids = new Set(project.tracks.flatMap((t) => t.clips.map((c) => c.id)));
|
|
1418
|
+
if (this.selection.some((id) => !ids.has(id))) this.selection = this.selection.filter((id) => ids.has(id));
|
|
1419
|
+
this.player.setDuration(() => videoProjectDuration(this.project));
|
|
1420
|
+
} finally {
|
|
1421
|
+
this.applying = false;
|
|
1422
|
+
}
|
|
1423
|
+
this.notify();
|
|
1424
|
+
this.onChange?.(project);
|
|
1425
|
+
}
|
|
1426
|
+
commit(label, next) {
|
|
1427
|
+
const before = this.project;
|
|
1428
|
+
this.error = null;
|
|
1429
|
+
if (next === before) {
|
|
1430
|
+
this.notify();
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
const command = {
|
|
1434
|
+
name: label,
|
|
1435
|
+
tag: "video-editor",
|
|
1436
|
+
execute: () => this.setProject(next),
|
|
1437
|
+
undo: () => this.setProject(before),
|
|
1438
|
+
redo: () => this.setProject(next)
|
|
1439
|
+
};
|
|
1440
|
+
this.history.execute(command);
|
|
1441
|
+
}
|
|
1442
|
+
refuse(message) {
|
|
1443
|
+
this.error = message;
|
|
1444
|
+
this.notify();
|
|
1445
|
+
return { ok: false, error: message };
|
|
1446
|
+
}
|
|
1447
|
+
/** Why edits are refused now, or `null` when they are allowed. */
|
|
1448
|
+
readOnlyReason() {
|
|
1449
|
+
return this.readOnly ? "The video project is read-only." : null;
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Applies edits as one undo step, all or nothing (see `applyVideoEdits`). With `dryRun` it
|
|
1453
|
+
* reports what would happen and changes nothing.
|
|
1454
|
+
*/
|
|
1455
|
+
apply(edits, label = "Edit video", options = {}) {
|
|
1456
|
+
const reason = this.readOnlyReason();
|
|
1457
|
+
if (reason) return this.refuse(reason);
|
|
1458
|
+
const result = applyVideoEdits(this.project, edits);
|
|
1459
|
+
if (!result.ok) return options.dryRun ? result : this.refuse(result.error);
|
|
1460
|
+
if (!options.dryRun) this.commit(label, result.project);
|
|
1461
|
+
return result;
|
|
1462
|
+
}
|
|
1463
|
+
/** Opens a project (JSON or object). `clearHistory` drops the undo stack (opening a file); otherwise it is one undo step. */
|
|
1464
|
+
load(project, options = {}) {
|
|
1465
|
+
let parsed;
|
|
1466
|
+
try {
|
|
1467
|
+
parsed = parseVideoProject(project);
|
|
1468
|
+
} catch (error) {
|
|
1469
|
+
return this.refuse(error.message);
|
|
1470
|
+
}
|
|
1471
|
+
if (options.clearHistory) {
|
|
1472
|
+
this.history.clear();
|
|
1473
|
+
this.error = null;
|
|
1474
|
+
this.selection = [];
|
|
1475
|
+
this.setProject(parsed);
|
|
1476
|
+
this.player.seek(0);
|
|
1477
|
+
return { ok: true, project: parsed, issues: this.issues, outcomes: [{ op: "replace_project" }] };
|
|
1478
|
+
}
|
|
1479
|
+
return this.apply({ op: "replace_project", project: parsed }, "Replace project");
|
|
1480
|
+
}
|
|
1481
|
+
setReadOnly(readOnly) {
|
|
1482
|
+
if (this.readOnly === readOnly) return;
|
|
1483
|
+
this.readOnly = readOnly;
|
|
1484
|
+
this.notify();
|
|
1485
|
+
}
|
|
1486
|
+
/** Selects clips (unknown ids are dropped). `add` toggles them into the current selection. */
|
|
1487
|
+
select(clips, options = {}) {
|
|
1488
|
+
const known = clips?.filter((id) => findVideoClip(this.project, id)) ?? [];
|
|
1489
|
+
let next;
|
|
1490
|
+
if (options.add) {
|
|
1491
|
+
next = [...this.selection];
|
|
1492
|
+
for (const id of known) next = next.includes(id) ? next.filter((x) => x !== id) : [...next, id];
|
|
1493
|
+
} else next = [...new Set(known)];
|
|
1494
|
+
if (next.length === this.selection.length && next.every((id, i) => id === this.selection[i])) return;
|
|
1495
|
+
this.selection = next;
|
|
1496
|
+
this.notify();
|
|
1497
|
+
}
|
|
1498
|
+
play() {
|
|
1499
|
+
this.player.play();
|
|
1500
|
+
}
|
|
1501
|
+
pause() {
|
|
1502
|
+
this.player.pause();
|
|
1503
|
+
}
|
|
1504
|
+
toggle() {
|
|
1505
|
+
this.player.toggle();
|
|
1506
|
+
}
|
|
1507
|
+
/** Moves the playhead (ms). */
|
|
1508
|
+
seek(time) {
|
|
1509
|
+
this.player.seek(time);
|
|
1510
|
+
}
|
|
1511
|
+
/** Moves the playhead by whole frames (negative steps back), pausing. */
|
|
1512
|
+
step(frames) {
|
|
1513
|
+
this.player.pause();
|
|
1514
|
+
const fps = this.project.fps;
|
|
1515
|
+
this.player.seek(Math.max(0, snapToVideoFrame(this.player.currentTime, fps) + frames * 1e3 / fps));
|
|
1516
|
+
}
|
|
1517
|
+
setLoop(loop) {
|
|
1518
|
+
this.player.setLoop(loop);
|
|
1519
|
+
}
|
|
1520
|
+
undo() {
|
|
1521
|
+
return this.history.undo();
|
|
1522
|
+
}
|
|
1523
|
+
redo() {
|
|
1524
|
+
return this.history.redo();
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* `time` moved to the nearest clip edge, marker or the playhead within `threshold` ms (default:
|
|
1528
|
+
* 8 frames), else to the nearest frame. Clips in `exclude` (the ones being dragged) are ignored.
|
|
1529
|
+
*/
|
|
1530
|
+
snap(time, options = {}) {
|
|
1531
|
+
const threshold = options.threshold ?? 8 * 1e3 / this.project.fps;
|
|
1532
|
+
const points = videoSnapPoints(this.project, { exclude: options.exclude, playhead: options.playhead === false ? void 0 : this.player.currentTime });
|
|
1533
|
+
const hit = snapVideoTime(time, points, threshold);
|
|
1534
|
+
return hit.snapped !== null ? hit : { time: Math.max(0, snapToVideoFrame(time, this.project.fps)), snapped: null };
|
|
1535
|
+
}
|
|
1536
|
+
/** Splits the selected clips (or every clip) under the playhead there, as one undo step. */
|
|
1537
|
+
splitAtPlayhead() {
|
|
1538
|
+
const t = snapToVideoFrame(this.player.currentTime, this.project.fps);
|
|
1539
|
+
const under = videoClipsAt(this.project, t).filter(({ clip }) => t > clip.start && t < videoClipEnd(clip));
|
|
1540
|
+
const chosen = this.selection.length ? under.filter(({ clip }) => this.selection.includes(clip.id)) : under;
|
|
1541
|
+
if (!chosen.length) return this.refuse("No clip is under the playhead to split.");
|
|
1542
|
+
return this.apply(chosen.map(({ clip }) => ({ op: "split_clip", clip: clip.id, at: t })), "Split");
|
|
1543
|
+
}
|
|
1544
|
+
/** Deletes the selected clips; `ripple` closes the gaps they leave. */
|
|
1545
|
+
deleteSelection(options = {}) {
|
|
1546
|
+
if (!this.selection.length) return this.refuse("Select clips to delete first.");
|
|
1547
|
+
return this.apply({ op: "delete_clips", clips: [...this.selection], ripple: options.ripple }, options.ripple ? "Ripple delete" : "Delete clips");
|
|
1548
|
+
}
|
|
1549
|
+
/** What plays at `time` (default: the playhead). */
|
|
1550
|
+
frame(time = this.player.currentTime) {
|
|
1551
|
+
return videoFrameAt(this.project, time);
|
|
1552
|
+
}
|
|
1553
|
+
/** The frame at `time` (default: the playhead) as SVG. */
|
|
1554
|
+
toSvg(time = this.player.currentTime, options = {}) {
|
|
1555
|
+
return videoProjectSvg(this.project, time, options);
|
|
1556
|
+
}
|
|
1557
|
+
/** The project as an ffmpeg command. */
|
|
1558
|
+
toFfmpeg(options = {}) {
|
|
1559
|
+
return toVideoFfmpeg(this.project, options);
|
|
1560
|
+
}
|
|
1561
|
+
/** One track as a CMX 3600 EDL. */
|
|
1562
|
+
toEdl(options = {}) {
|
|
1563
|
+
return toVideoEdl(this.project, options);
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
// src/video-editor/tools.ts
|
|
1568
|
+
var READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
1569
|
+
var WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
1570
|
+
var WRITE_IDEMPOTENT = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
1571
|
+
var DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
|
|
1572
|
+
var object = (properties, required = [], description) => ({
|
|
1573
|
+
type: "object",
|
|
1574
|
+
...description ? { description } : {},
|
|
1575
|
+
properties,
|
|
1576
|
+
...required.length ? { required } : {},
|
|
1577
|
+
additionalProperties: false
|
|
1578
|
+
});
|
|
1579
|
+
var MS = (what) => ({ type: "number", minimum: 0, description: `${what}, in milliseconds.` });
|
|
1580
|
+
var CLIP_ID = { type: "string", minLength: 1, description: 'A clip id from get_video_project (e.g. "c1").' };
|
|
1581
|
+
var TRACK_ID = { type: "string", minLength: 1, description: 'A track id from get_video_project (e.g. "v1" or "a1").' };
|
|
1582
|
+
var ASSET_ID = { type: "string", minLength: 1, description: "An asset id from get_video_project." };
|
|
1583
|
+
var COLOR = (what) => ({ type: "string", minLength: 1, description: `${what}: a CSS colour such as "#ffcc00".` });
|
|
1584
|
+
var RIPPLE = { type: "boolean", description: "Push (or pull) the later clips on the same track along by the change, keeping the cuts after it in sync (default false: refuse if it would overlap)." };
|
|
1585
|
+
var TRANSFORM = object(
|
|
1586
|
+
{
|
|
1587
|
+
x: { type: "number", description: "Offset of the centre to the right of the frame's centre, in pixels (default 0)." },
|
|
1588
|
+
y: { type: "number", description: "Offset of the centre below the frame's centre, in pixels (default 0)." },
|
|
1589
|
+
scale: { type: "number", minimum: 0, description: "Size after fitting (1 = fitted size, default 1)." },
|
|
1590
|
+
rotation: { type: "number", description: "Degrees clockwise (default 0)." }
|
|
1591
|
+
},
|
|
1592
|
+
[],
|
|
1593
|
+
"Where the picture sits after it is fitted."
|
|
1594
|
+
);
|
|
1595
|
+
var TRANSITION = object(
|
|
1596
|
+
{
|
|
1597
|
+
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) or wipe (an edge travels across revealing this clip)." },
|
|
1598
|
+
duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms, centred on the cut. A crossfade or wipe needs half of it in media past the cut on both sides (handles); a dip needs none." },
|
|
1599
|
+
color: COLOR("For dip: the colour passed through (default black)"),
|
|
1600
|
+
direction: { type: "string", enum: [...VIDEO_WIPE_DIRECTIONS], description: "For wipe: the way the edge travels (default right)." }
|
|
1601
|
+
},
|
|
1602
|
+
["type", "duration"],
|
|
1603
|
+
"A transition from the clip that ends where this one starts on the same track."
|
|
1604
|
+
);
|
|
1605
|
+
var CLIP_COMMON = {
|
|
1606
|
+
name: { type: "string", description: "A label for the clip." },
|
|
1607
|
+
opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1)." },
|
|
1608
|
+
transform: TRANSFORM,
|
|
1609
|
+
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
|
+
fadeIn: MS("Fade up from nothing (picture and sound) over this long"),
|
|
1611
|
+
fadeOut: MS("Fade down to nothing over this long")
|
|
1612
|
+
};
|
|
1613
|
+
var MEDIA_FIELDS = {
|
|
1614
|
+
in: MS("Where in the source it starts (default 0)"),
|
|
1615
|
+
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)." },
|
|
1617
|
+
muted: { type: "boolean", description: "Silence this clip (default false)." }
|
|
1618
|
+
};
|
|
1619
|
+
var TEXT_FIELDS = {
|
|
1620
|
+
text: { type: "string", minLength: 1, description: `The title's text ("\\n" breaks lines).` },
|
|
1621
|
+
color: COLOR("Text colour (default white)"),
|
|
1622
|
+
fontSize: { type: "number", exclusiveMinimum: 0, description: "Pixels (default 64)." },
|
|
1623
|
+
fontFamily: { type: "string", description: "CSS font family (default sans-serif)." },
|
|
1624
|
+
fontWeight: { type: "number", minimum: 100, maximum: 1e3, description: "CSS weight (default 700)." },
|
|
1625
|
+
background: COLOR("A box behind the text (default none)")
|
|
1626
|
+
};
|
|
1627
|
+
var PLACEMENT = {
|
|
1628
|
+
id: { type: "string", minLength: 1, description: 'Id for the clip (default: a fresh one such as "c3").' },
|
|
1629
|
+
start: MS("Where it starts on the timeline (default: the end of the track)"),
|
|
1630
|
+
duration: { type: "number", exclusiveMinimum: 0, description: "Length on the timeline, in ms (default: the rest of the source for media, 5000 for stills, 3000 for titles and colours)." }
|
|
1631
|
+
};
|
|
1632
|
+
var NEW_CLIP = {
|
|
1633
|
+
description: "The clip: media (a stretch of an asset), text (a title) or color (a solid fill).",
|
|
1634
|
+
oneOf: [
|
|
1635
|
+
object({ type: { const: "media", description: "A stretch of an asset." }, asset: ASSET_ID, ...PLACEMENT, ...CLIP_COMMON, ...MEDIA_FIELDS }, ["type", "asset"], "A media clip."),
|
|
1636
|
+
object({ type: { const: "text", description: "A title over the picture." }, ...PLACEMENT, ...CLIP_COMMON, ...TEXT_FIELDS }, ["type", "text"], "A title."),
|
|
1637
|
+
object({ type: { const: "color", description: "A solid colour filling the frame." }, color: COLOR("The fill"), ...PLACEMENT, ...CLIP_COMMON }, ["type", "color"], "A colour.")
|
|
1638
|
+
]
|
|
1639
|
+
};
|
|
1640
|
+
var SETTINGS = {
|
|
1641
|
+
name: { type: "string", description: "Project name." },
|
|
1642
|
+
width: { type: "integer", minimum: 16, maximum: 8192, description: "Frame width in pixels." },
|
|
1643
|
+
height: { type: "integer", minimum: 16, maximum: 8192, description: "Frame height in pixels." },
|
|
1644
|
+
fps: { type: "number", exclusiveMinimum: 0, maximum: 240, description: "Frames per second (e.g. 24, 25, 30, 60)." },
|
|
1645
|
+
background: COLOR("What shows where no clip covers the frame (default black)")
|
|
1646
|
+
};
|
|
1647
|
+
var TRACK_FIELDS = {
|
|
1648
|
+
name: { type: "string", description: "Track label." },
|
|
1649
|
+
muted: { type: "boolean", description: "Silence every clip on it." },
|
|
1650
|
+
hidden: { type: "boolean", description: "Leave its pictures out of the frame." },
|
|
1651
|
+
locked: { type: "boolean", description: "Refuse edits to its clips." },
|
|
1652
|
+
volume: { type: "number", minimum: 0, maximum: 4, description: "Gain for every clip on it (1 = unchanged)." }
|
|
1653
|
+
};
|
|
1654
|
+
var ASSET_FIELDS = {
|
|
1655
|
+
src: { type: "string", minLength: 1, description: "URL, path or data URL the player and exporter can open." },
|
|
1656
|
+
name: { type: "string", description: "Display name (e.g. the file name)." },
|
|
1657
|
+
duration: { type: "number", exclusiveMinimum: 0, description: "Length of the source in ms (required to check trims of video and audio)." },
|
|
1658
|
+
width: { type: "number", exclusiveMinimum: 0, description: "Pixel width (video and image; default the project width)." },
|
|
1659
|
+
height: { type: "number", exclusiveMinimum: 0, description: "Pixel height." },
|
|
1660
|
+
hasAudio: { type: "boolean", description: "For video: whether it has sound (default true)." },
|
|
1661
|
+
poster: { type: "string", description: "For video: a still (URL) to show in rendered frames." }
|
|
1662
|
+
};
|
|
1663
|
+
var issueSummary = (issues) => ({
|
|
1664
|
+
errors: issues.filter((i) => i.severity === "error").length,
|
|
1665
|
+
warnings: issues.filter((i) => i.severity === "warning").length,
|
|
1666
|
+
notes: issues.filter((i) => i.severity === "note").length,
|
|
1667
|
+
lines: issues.map((i) => `${i.severity}: ${i.message}`)
|
|
1668
|
+
});
|
|
1669
|
+
function describeVideoProject(project) {
|
|
1670
|
+
return {
|
|
1671
|
+
...project.name ? { name: project.name } : {},
|
|
1672
|
+
width: project.width,
|
|
1673
|
+
height: project.height,
|
|
1674
|
+
fps: project.fps,
|
|
1675
|
+
duration: videoProjectDuration(project),
|
|
1676
|
+
...project.background ? { background: project.background } : {},
|
|
1677
|
+
assets: project.assets,
|
|
1678
|
+
tracks: project.tracks.map((t, index) => ({
|
|
1679
|
+
id: t.id,
|
|
1680
|
+
kind: t.kind,
|
|
1681
|
+
index,
|
|
1682
|
+
...t.name ? { name: t.name } : {},
|
|
1683
|
+
...t.muted ? { muted: true } : {},
|
|
1684
|
+
...t.hidden ? { hidden: true } : {},
|
|
1685
|
+
...t.locked ? { locked: true } : {},
|
|
1686
|
+
...t.volume !== void 0 ? { volume: t.volume } : {},
|
|
1687
|
+
clips: sortedVideoClips(t).map((c) => ({ ...c, end: videoClipEnd(c), ...c.type === "media" ? { source: videoClipSourceRange(c) } : {} }))
|
|
1688
|
+
})),
|
|
1689
|
+
markers: project.markers ?? []
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
function createVideoEditorToolProvider(controller, options = {}) {
|
|
1693
|
+
const readOnlyReason = () => {
|
|
1694
|
+
const r = typeof options.readOnly === "function" ? options.readOnly() : options.readOnly;
|
|
1695
|
+
if (r) return typeof r === "string" ? r : "This video project is read-only.";
|
|
1696
|
+
return controller.readOnlyReason();
|
|
1697
|
+
};
|
|
1698
|
+
const project = () => controller.getProject();
|
|
1699
|
+
const outcome = (result, extra = {}) => {
|
|
1700
|
+
if (!result.ok) throw new Error(result.error);
|
|
1701
|
+
return { ...extra, duration: videoProjectDuration(result.project), issues: issueSummary(result.issues) };
|
|
1702
|
+
};
|
|
1703
|
+
const apply = (edits, label, extra) => {
|
|
1704
|
+
const result = controller.apply(edits, label);
|
|
1705
|
+
return outcome(result, result.ok && extra ? extra(result) : {});
|
|
1706
|
+
};
|
|
1707
|
+
const clipOut = (p, id) => {
|
|
1708
|
+
for (const t of p.tracks) {
|
|
1709
|
+
const c = t.clips.find((x) => x.id === id);
|
|
1710
|
+
if (c) return { track: t.id, ...c, end: videoClipEnd(c), ...c.type === "media" ? { source: videoClipSourceRange(c) } : {} };
|
|
1711
|
+
}
|
|
1712
|
+
return null;
|
|
1713
|
+
};
|
|
1714
|
+
const getProject = {
|
|
1715
|
+
name: "get_video_project",
|
|
1716
|
+
title: "Read the video project",
|
|
1717
|
+
description: "Returns the whole project: frame size, fps, length (ms), assets (id, kind, src, duration), tracks top to bottom (an earlier video track draws over a later one) with each clip in time order (id, type, start, duration, end, source in/out for media, fades, transition) and markers, plus the playhead, selection, issues and whether it can be edited.",
|
|
1718
|
+
inputSchema: object({}),
|
|
1719
|
+
annotations: { title: "Read the video project", ...READ },
|
|
1720
|
+
handler: () => {
|
|
1721
|
+
const state = controller.getState();
|
|
1722
|
+
const reason = readOnlyReason();
|
|
1723
|
+
return { ...describeVideoProject(state.project), playhead: state.time, selection: state.selection, issues: issueSummary(state.issues), editable: !reason, ...reason ? { readOnlyReason: reason } : {} };
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
const getFrame = {
|
|
1727
|
+
name: "get_video_frame",
|
|
1728
|
+
title: "What plays at a moment",
|
|
1729
|
+
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).",
|
|
1730
|
+
inputSchema: object({ time: MS("Time from the start (default: the playhead)") }),
|
|
1731
|
+
annotations: { title: "What plays at a moment", ...READ },
|
|
1732
|
+
handler: ({ time }) => {
|
|
1733
|
+
const frame = videoFrameAt(project(), time ?? controller.getState().time);
|
|
1734
|
+
return { ...frame, timecode: formatVideoTimecode(frame.time, project().fps) };
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
const findGaps = {
|
|
1738
|
+
name: "find_video_gaps",
|
|
1739
|
+
title: "Find gaps",
|
|
1740
|
+
description: "Lists empty stretches between clips (and before the first) on every track or one track, as { track, start, end } in ms. close_video_gaps removes them.",
|
|
1741
|
+
inputSchema: object({ track: { ...TRACK_ID, description: "Only this track (default all)." } }),
|
|
1742
|
+
annotations: { title: "Find gaps", ...READ },
|
|
1743
|
+
handler: ({ track }) => {
|
|
1744
|
+
if (track !== void 0 && !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(", ")}.`);
|
|
1745
|
+
return { gaps: findVideoGaps(project(), track) };
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
const checkProject = {
|
|
1749
|
+
name: "check_video_project",
|
|
1750
|
+
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.",
|
|
1752
|
+
inputSchema: object({}),
|
|
1753
|
+
annotations: { title: "Check the video project", ...READ },
|
|
1754
|
+
handler: () => {
|
|
1755
|
+
const issues = controller.getState().issues;
|
|
1756
|
+
return { ...issueSummary(issues), issues };
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
const renderFrame = {
|
|
1760
|
+
name: "render_video_frame",
|
|
1761
|
+
title: "Render a frame",
|
|
1762
|
+
description: 'Draws the project at one moment (or several, as one labelled contact sheet) to check the picture, without changing it: SVG by default, PNG (format "png") when the host can rasterize. Images and video posters are drawn; video without a poster shows as a labelled box with its source timecode.',
|
|
1763
|
+
inputSchema: object({
|
|
1764
|
+
time: MS("Time from the start (default: the playhead)"),
|
|
1765
|
+
times: { type: "array", items: { type: "number", minimum: 0 }, minItems: 2, maxItems: 16, description: 'Several moments (ms) drawn as one contact sheet instead of "time".' },
|
|
1766
|
+
columns: { type: "integer", minimum: 1, maximum: 8, description: "With times: frames a row (default about square)." },
|
|
1767
|
+
format: { type: "string", enum: ["svg", "png"], description: "svg (default) or png (only when the host supports it)." }
|
|
1768
|
+
}),
|
|
1769
|
+
annotations: { title: "Render a frame", ...READ },
|
|
1770
|
+
handler: async ({ time, times, columns, format = "svg" }) => {
|
|
1771
|
+
const p = project();
|
|
1772
|
+
const svg = times ? videoContactSheetSvg(p, times, columns ? { columns } : {}) : videoProjectSvg(p, time ?? controller.getState().time);
|
|
1773
|
+
if (format === "png") {
|
|
1774
|
+
if (!options.rasterize) throw new Error('This host cannot make PNG images; call render_video_frame with format "svg".');
|
|
1775
|
+
const size = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg);
|
|
1776
|
+
const image = await options.rasterize(svg, { width: Number(size[1]), height: Number(size[2]) });
|
|
1777
|
+
return { content: [{ type: "image", data: image.data, mimeType: image.mimeType }] };
|
|
1778
|
+
}
|
|
1779
|
+
return { svg };
|
|
1780
|
+
}
|
|
1781
|
+
};
|
|
1782
|
+
const exportFfmpeg = {
|
|
1783
|
+
name: "export_video_ffmpeg",
|
|
1784
|
+
title: "Export an ffmpeg command",
|
|
1785
|
+
description: "Returns the ffmpeg command that renders the project to a file: { args (for spawn), command (one shell line), filterComplex, duration, notes on anything approximated }. Fails when the project has no clips.",
|
|
1786
|
+
inputSchema: object({
|
|
1787
|
+
output: { type: "string", minLength: 1, description: "Output file name (default output.mp4)." },
|
|
1788
|
+
crf: { type: "integer", minimum: 0, maximum: 51, description: "x264 quality, lower is better (default 18)." }
|
|
1789
|
+
}),
|
|
1790
|
+
annotations: { title: "Export an ffmpeg command", ...READ },
|
|
1791
|
+
handler: ({ output, crf }) => controller.toFfmpeg({ ...output ? { output } : {}, ...crf !== void 0 ? { crf } : {} })
|
|
1792
|
+
};
|
|
1793
|
+
const exportEdl = {
|
|
1794
|
+
name: "export_video_edl",
|
|
1795
|
+
title: "Export an EDL",
|
|
1796
|
+
description: "Returns one track as a CMX 3600 edit decision list (text) for conforming in another editor: events with source and record timecode, dissolves, speed changes and clip names.",
|
|
1797
|
+
inputSchema: object({
|
|
1798
|
+
track: { ...TRACK_ID, description: "Track to export (default: the bottom video track)." },
|
|
1799
|
+
title: { type: "string", description: "TITLE line (default: the project name)." }
|
|
1800
|
+
}),
|
|
1801
|
+
annotations: { title: "Export an EDL", ...READ },
|
|
1802
|
+
handler: ({ track, title }) => ({ edl: controller.toEdl({ ...track ? { track } : {}, ...title ? { title } : {} }) })
|
|
1803
|
+
};
|
|
1804
|
+
const seek = {
|
|
1805
|
+
name: "seek_video",
|
|
1806
|
+
title: "Show a moment",
|
|
1807
|
+
description: "Moves the playhead the person sees to a time, a clip's start or a marker, and plays or pauses. Does not change the project. Returns the playhead and the clips under it.",
|
|
1808
|
+
inputSchema: object({
|
|
1809
|
+
time: MS("Time from the start"),
|
|
1810
|
+
clip: { ...CLIP_ID, description: 'Go to the start of this clip instead of "time".' },
|
|
1811
|
+
marker: { type: "string", minLength: 1, description: 'Go to this marker instead of "time".' },
|
|
1812
|
+
play: { type: "boolean", description: "true plays from there, false pauses (default: leave as is)." }
|
|
1813
|
+
}),
|
|
1814
|
+
annotations: { title: "Show a moment", ...WRITE_IDEMPOTENT },
|
|
1815
|
+
handler: ({ time, clip, marker, play }) => {
|
|
1816
|
+
const p = project();
|
|
1817
|
+
if (clip !== void 0) {
|
|
1818
|
+
const c = clipOut(p, clip);
|
|
1819
|
+
if (!c) throw new Error(`There is no clip "${clip}".`);
|
|
1820
|
+
controller.seek(c.start);
|
|
1821
|
+
} else if (marker !== void 0) {
|
|
1822
|
+
const m = (p.markers ?? []).find((x) => x.id === marker);
|
|
1823
|
+
if (!m) throw new Error(`There is no marker "${marker}"; the markers are ${(p.markers ?? []).map((x) => `"${x.id}"`).join(", ") || "none"}.`);
|
|
1824
|
+
controller.seek(m.time);
|
|
1825
|
+
} else if (time !== void 0) controller.seek(time);
|
|
1826
|
+
if (play === true) controller.play();
|
|
1827
|
+
if (play === false) controller.pause();
|
|
1828
|
+
const state = controller.getState();
|
|
1829
|
+
return { time: state.time, timecode: formatVideoTimecode(state.time, p.fps), playing: state.playing, clips: state.current };
|
|
1830
|
+
}
|
|
1831
|
+
};
|
|
1832
|
+
const setSettings = {
|
|
1833
|
+
name: "set_video_settings",
|
|
1834
|
+
title: "Change project settings",
|
|
1835
|
+
description: "Sets the name, frame size, frame rate or background colour (one undo step). Returns the length and issues.",
|
|
1836
|
+
inputSchema: object(SETTINGS),
|
|
1837
|
+
annotations: { title: "Change project settings", ...WRITE_IDEMPOTENT },
|
|
1838
|
+
handler: (args) => {
|
|
1839
|
+
if (!Object.keys(args).length) throw new Error("Give at least one setting to change (name, width, height, fps, background).");
|
|
1840
|
+
return apply({ op: "set_settings", ...args }, "Change settings");
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
1843
|
+
const addAsset = {
|
|
1844
|
+
name: "add_video_asset",
|
|
1845
|
+
title: "Add media",
|
|
1846
|
+
description: "Adds a video, audio or image file to the project so clips can use it (one undo step). Give the duration of video and audio so trims can be checked. Returns the asset id.",
|
|
1847
|
+
inputSchema: object({ id: { type: "string", minLength: 1, description: "Id for it (default: from its name or file name)." }, kind: { type: "string", enum: ["video", "audio", "image"], description: "What it is." }, ...ASSET_FIELDS }, ["kind", "src"]),
|
|
1848
|
+
annotations: { title: "Add media", ...WRITE },
|
|
1849
|
+
handler: (asset) => apply({ op: "add_asset", asset }, "Add media", (r) => ({ asset: r.outcomes[0].asset }))
|
|
1850
|
+
};
|
|
1851
|
+
const updateAsset = {
|
|
1852
|
+
name: "update_video_asset",
|
|
1853
|
+
title: "Change media details",
|
|
1854
|
+
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.",
|
|
1855
|
+
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"]),
|
|
1856
|
+
annotations: { title: "Change media details", ...WRITE_IDEMPOTENT },
|
|
1857
|
+
handler: ({ asset, set, remove }) => apply({ op: "update_asset", asset, set, remove }, "Change media", (r) => ({ asset: r.project.assets.find((a) => a.id === asset) }))
|
|
1858
|
+
};
|
|
1859
|
+
const removeAsset = {
|
|
1860
|
+
name: "remove_video_asset",
|
|
1861
|
+
title: "Remove media",
|
|
1862
|
+
description: "Removes an asset no clip uses (one undo step); refused while clips use it, naming them. Returns the remaining asset ids.",
|
|
1863
|
+
inputSchema: object({ asset: ASSET_ID }, ["asset"]),
|
|
1864
|
+
annotations: { title: "Remove media", ...DESTRUCTIVE },
|
|
1865
|
+
handler: ({ asset }) => apply({ op: "remove_asset", asset }, "Remove media", (r) => ({ removed: asset, assets: r.project.assets.map((a) => a.id) }))
|
|
1866
|
+
};
|
|
1867
|
+
const addTrack = {
|
|
1868
|
+
name: "add_video_track",
|
|
1869
|
+
title: "Add a track",
|
|
1870
|
+
description: "Adds an empty video or audio track (one undo step). By default a video track goes on top (drawing over the others) and an audio track at the bottom. Returns its id and the track order.",
|
|
1871
|
+
inputSchema: object({ kind: { type: "string", enum: ["video", "audio"], description: "Video (pictures, titles, colours) or audio." }, id: { type: "string", minLength: 1, description: 'Id (default "v2", "a2", \u2026).' }, at: { type: "integer", minimum: 0, description: "Position, 0 = top." }, ...TRACK_FIELDS }, ["kind"]),
|
|
1872
|
+
annotations: { title: "Add a track", ...WRITE },
|
|
1873
|
+
handler: ({ kind, id, at, ...fields }) => apply({ op: "add_track", track: { kind, ...id ? { id } : {}, ...fields }, at }, "Add track", (r) => ({ track: r.outcomes[0].track, tracks: r.project.tracks.map((t) => t.id) }))
|
|
1874
|
+
};
|
|
1875
|
+
const updateTrack = {
|
|
1876
|
+
name: "update_video_track",
|
|
1877
|
+
title: "Change a track",
|
|
1878
|
+
description: "Renames, mutes, hides, locks or sets the volume of a track (one undo step). Returns the track's settings.",
|
|
1879
|
+
inputSchema: object({ track: TRACK_ID, set: object(TRACK_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "muted", "hidden", "locked", "volume"] }, description: "Fields to clear." } }, ["track"]),
|
|
1880
|
+
annotations: { title: "Change a track", ...WRITE_IDEMPOTENT },
|
|
1881
|
+
handler: ({ track, set, remove }) => apply({ op: "update_track", track, set, remove }, "Change track", (r) => {
|
|
1882
|
+
const { clips, ...t } = r.project.tracks.find((x) => x.id === track);
|
|
1883
|
+
return { track: { ...t, clips: clips.length } };
|
|
1884
|
+
})
|
|
1885
|
+
};
|
|
1886
|
+
const removeTrack = {
|
|
1887
|
+
name: "remove_video_track",
|
|
1888
|
+
title: "Remove a track",
|
|
1889
|
+
description: "Deletes a track and every clip on it (one undo step; refused when locked). Returns the clips removed with it.",
|
|
1890
|
+
inputSchema: object({ track: TRACK_ID }, ["track"]),
|
|
1891
|
+
annotations: { title: "Remove a track", ...DESTRUCTIVE },
|
|
1892
|
+
handler: ({ track }) => apply({ op: "remove_track", track }, "Remove track", (r) => ({ removed: track, clips: r.outcomes[0].clips }))
|
|
1893
|
+
};
|
|
1894
|
+
const moveTrack = {
|
|
1895
|
+
name: "move_video_track",
|
|
1896
|
+
title: "Reorder a track",
|
|
1897
|
+
description: "Moves a track to a new position (0 = top; an earlier video track draws over later ones). Returns the track order.",
|
|
1898
|
+
inputSchema: object({ track: TRACK_ID, to: { type: "integer", minimum: 0, description: "New position, 0 = top." } }, ["track", "to"]),
|
|
1899
|
+
annotations: { title: "Reorder a track", ...WRITE_IDEMPOTENT },
|
|
1900
|
+
handler: ({ track, to }) => apply({ op: "move_track", track, to }, "Move track", (r) => ({ tracks: r.project.tracks.map((t) => t.id) }))
|
|
1901
|
+
};
|
|
1902
|
+
const addClip = {
|
|
1903
|
+
name: "add_video_clip",
|
|
1904
|
+
title: "Add a clip",
|
|
1905
|
+
description: 'Puts a clip on a track (one undo step): media (a stretch of an asset from its "in" point), a title or a colour. With no start it goes at the end of the track; with ripple it is inserted at "start", pushing later clips along; otherwise an overlap is refused with the next free place. Returns the clip with its end and source range.',
|
|
1906
|
+
inputSchema: object({ track: TRACK_ID, clip: NEW_CLIP, ripple: RIPPLE }, ["track", "clip"]),
|
|
1907
|
+
annotations: { title: "Add a clip", ...WRITE },
|
|
1908
|
+
handler: ({ track, clip, ripple }) => apply({ op: "add_clip", track, clip, ripple }, "Add clip", (r) => ({ clip: clipOut(r.project, r.outcomes[0].clip) }))
|
|
1909
|
+
};
|
|
1910
|
+
const moveClip = {
|
|
1911
|
+
name: "move_video_clip",
|
|
1912
|
+
title: "Move a clip",
|
|
1913
|
+
description: "Moves a clip to a new start and/or another track of the same kind (one undo step). Refused if it would overlap another clip, naming it and the next free place. Returns the clip.",
|
|
1914
|
+
inputSchema: object({ clip: CLIP_ID, start: MS("New start on the timeline (default: unchanged)"), track: { ...TRACK_ID, description: "Track to move it to (default: its own)." } }, ["clip"]),
|
|
1915
|
+
annotations: { title: "Move a clip", ...WRITE_IDEMPOTENT },
|
|
1916
|
+
handler: ({ clip, start, track }) => {
|
|
1917
|
+
if (start === void 0 && track === void 0) throw new Error('Give "start", "track" or both.');
|
|
1918
|
+
return apply({ op: "move_clip", clip, start, track }, "Move clip", (r) => ({ clip: clipOut(r.project, clip) }));
|
|
1919
|
+
}
|
|
1920
|
+
};
|
|
1921
|
+
const trimClip = {
|
|
1922
|
+
name: "trim_video_clip",
|
|
1923
|
+
title: "Trim a clip",
|
|
1924
|
+
description: 'Moves one edge of a clip to a timeline time (one undo step): "start" also moves its source in point, "end" its out point. Refused past the ends of the source (the error says how far it can go) or below one frame. With ripple the clip keeps its start and the later clips on the track move by the change, so no gap or overlap opens. Returns the clip.',
|
|
1925
|
+
inputSchema: object({ clip: CLIP_ID, edge: { type: "string", enum: ["start", "end"], description: "Which edge to move." }, to: MS("Where that edge goes on the timeline"), ripple: RIPPLE }, ["clip", "edge", "to"]),
|
|
1926
|
+
annotations: { title: "Trim a clip", ...WRITE_IDEMPOTENT },
|
|
1927
|
+
handler: ({ clip, edge, to, ripple }) => apply({ op: "trim_clip", clip, edge, to, ripple }, "Trim clip", (r) => ({ clip: clipOut(r.project, clip) }))
|
|
1928
|
+
};
|
|
1929
|
+
const splitClip = {
|
|
1930
|
+
name: "split_video_clip",
|
|
1931
|
+
title: "Split a clip",
|
|
1932
|
+
description: "Cuts a clip in two at a timeline time (at least a frame from either end), as one undo step. The left part keeps the id, fade in and transition; the right part gets a new id and the fade out. Returns both parts.",
|
|
1933
|
+
inputSchema: object({ clip: CLIP_ID, at: MS("Where to cut, on the timeline"), id: { type: "string", minLength: 1, description: 'Id for the right part (default "<clip>-1").' } }, ["clip", "at"]),
|
|
1934
|
+
annotations: { title: "Split a clip", ...WRITE },
|
|
1935
|
+
handler: ({ clip, at, id }) => apply({ op: "split_clip", clip, at, id }, "Split clip", (r) => ({ left: clipOut(r.project, clip), right: clipOut(r.project, r.outcomes[0].clip) }))
|
|
1936
|
+
};
|
|
1937
|
+
const deleteClips = {
|
|
1938
|
+
name: "delete_video_clips",
|
|
1939
|
+
title: "Delete clips",
|
|
1940
|
+
description: "Removes clips (one undo step). With ripple the later clips on each track move up to close the gaps. Returns the ids removed.",
|
|
1941
|
+
inputSchema: object({ clips: { type: "array", items: CLIP_ID, minItems: 1, description: "Clip ids." }, ripple: { type: "boolean", description: "Close the gaps they leave (default false)." } }, ["clips"]),
|
|
1942
|
+
annotations: { title: "Delete clips", ...DESTRUCTIVE },
|
|
1943
|
+
handler: ({ clips, ripple }) => apply({ op: "delete_clips", clips, ripple }, ripple ? "Ripple delete" : "Delete clips", () => ({ removed: clips }))
|
|
1944
|
+
};
|
|
1945
|
+
const updateClip = {
|
|
1946
|
+
name: "update_video_clip",
|
|
1947
|
+
title: "Change a clip",
|
|
1948
|
+
description: `Changes a clip's properties (one undo step): opacity, transform, fit, fades, name; for media volume, mute, speed (keeps the stretch of source, so the length changes) or asset; for titles text and style; for colours the colour. Fields in "remove" go back to their defaults. Use move / trim for timing. Returns the clip.`,
|
|
1949
|
+
inputSchema: object(
|
|
1950
|
+
{
|
|
1951
|
+
clip: CLIP_ID,
|
|
1952
|
+
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)."),
|
|
1953
|
+
remove: { type: "array", items: { type: "string", enum: ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "in", "speed", "volume", "muted", "fontSize", "fontFamily", "fontWeight", "background"] }, description: "Fields to reset to their defaults." }
|
|
1954
|
+
},
|
|
1955
|
+
["clip"]
|
|
1956
|
+
),
|
|
1957
|
+
annotations: { title: "Change a clip", ...WRITE_IDEMPOTENT },
|
|
1958
|
+
handler: ({ clip, set, remove }) => {
|
|
1959
|
+
const current = clipOut(project(), clip);
|
|
1960
|
+
if (current && set) {
|
|
1961
|
+
const allowed = /* @__PURE__ */ new Set(["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", ...current.type === "media" ? ["in", "speed", "volume", "muted", "asset"] : current.type === "text" ? Object.keys(TEXT_FIELDS) : ["color"]]);
|
|
1962
|
+
const wrong = Object.keys(set).filter((k) => !allowed.has(k));
|
|
1963
|
+
if (wrong.length) throw new Error(`${wrong.map((k) => `"${k}"`).join(", ")} cannot be set on a ${current.type} clip; it takes ${[...allowed].join(", ")}.`);
|
|
1964
|
+
}
|
|
1965
|
+
return apply({ op: "update_clip", clip, set, remove }, "Change clip", (r) => ({ clip: clipOut(r.project, clip) }));
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
const setTransition = {
|
|
1969
|
+
name: "set_video_transition",
|
|
1970
|
+
title: "Set a transition",
|
|
1971
|
+
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). A crossfade or wipe 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.",
|
|
1972
|
+
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"]),
|
|
1973
|
+
annotations: { title: "Set a transition", ...WRITE_IDEMPOTENT },
|
|
1974
|
+
handler: ({ clip, transition }) => apply({ op: "set_transition", clip, transition }, transition ? "Set transition" : "Remove transition", (r) => ({ clip: clipOut(r.project, clip) }))
|
|
1975
|
+
};
|
|
1976
|
+
const closeGaps = {
|
|
1977
|
+
name: "close_video_gaps",
|
|
1978
|
+
title: "Close gaps",
|
|
1979
|
+
description: "Slides the clips on a track earlier so no gaps are left between them (from a time on, default the start), as one undo step. Returns the clips that moved.",
|
|
1980
|
+
inputSchema: object({ track: TRACK_ID, from: MS("Only close gaps after this time (default 0)") }, ["track"]),
|
|
1981
|
+
annotations: { title: "Close gaps", ...WRITE_IDEMPOTENT },
|
|
1982
|
+
handler: ({ track, from }) => apply({ op: "close_gaps", track, from }, "Close gaps", (r) => ({ moved: r.outcomes[0].clips }))
|
|
1983
|
+
};
|
|
1984
|
+
const addMarker = {
|
|
1985
|
+
name: "add_video_marker",
|
|
1986
|
+
title: "Add a marker",
|
|
1987
|
+
description: "Adds a named marker at a time (one undo step), e.g. a beat or a note for review. Clip edges snap to markers. Returns its id.",
|
|
1988
|
+
inputSchema: object({ time: MS("Where it goes"), label: { type: "string", description: "What it marks." }, color: COLOR("Marker colour"), id: { type: "string", minLength: 1, description: 'Id (default "m1", "m2", \u2026).' } }, ["time"]),
|
|
1989
|
+
annotations: { title: "Add a marker", ...WRITE },
|
|
1990
|
+
handler: (marker) => apply({ op: "add_marker", marker }, "Add marker", (r) => ({ marker: r.outcomes[0].marker }))
|
|
1991
|
+
};
|
|
1992
|
+
const removeMarker = {
|
|
1993
|
+
name: "remove_video_marker",
|
|
1994
|
+
title: "Remove a marker",
|
|
1995
|
+
description: "Removes a marker by id (one undo step). Returns the remaining markers.",
|
|
1996
|
+
inputSchema: object({ marker: { type: "string", minLength: 1, description: "Marker id from get_video_project." } }, ["marker"]),
|
|
1997
|
+
annotations: { title: "Remove a marker", ...DESTRUCTIVE },
|
|
1998
|
+
handler: ({ marker }) => apply({ op: "remove_marker", marker }, "Remove marker", (r) => ({ markers: r.project.markers ?? [] }))
|
|
1999
|
+
};
|
|
2000
|
+
const EDIT_OP = {
|
|
2001
|
+
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 }.`,
|
|
2003
|
+
properties: { op: { type: "string", enum: VIDEO_EDIT_OPS.filter((o) => o !== "replace_project"), description: "Which edit." } },
|
|
2004
|
+
required: ["op"]
|
|
2005
|
+
};
|
|
2006
|
+
const editVideo = {
|
|
2007
|
+
name: "edit_video",
|
|
2008
|
+
title: "Make several edits at once",
|
|
2009
|
+
description: `Applies several edits in order as one undo step, all or nothing: if one fails nothing changes and the error names the op ("op 2 (trim_clip): \u2026"). Use it for a rough cut (assets, then clips) or anything that only makes sense together. Returns each op's outcome (the clip, clips, track, asset or marker it made or changed), the length and issues. With dry_run it only reports them.`,
|
|
2010
|
+
inputSchema: object({ ops: { type: "array", items: EDIT_OP, minItems: 1, maxItems: 200, description: "Edits, applied in order." }, dry_run: { type: "boolean", description: "Check without applying (default false)." } }, ["ops"]),
|
|
2011
|
+
annotations: { title: "Make several edits at once", ...WRITE },
|
|
2012
|
+
handler: ({ ops, dry_run = false }) => {
|
|
2013
|
+
if (ops.some((o) => o.op === "replace_project")) throw new Error("edit_video cannot replace the whole project; use the individual ops.");
|
|
2014
|
+
const result = controller.apply(ops, "Edit video", { dryRun: dry_run });
|
|
2015
|
+
return outcome(result, result.ok ? { outcomes: result.outcomes, ...dry_run ? { dry_run: true, hint: "Nothing was applied; call again without dry_run to apply." } : {} } : {});
|
|
2016
|
+
}
|
|
2017
|
+
};
|
|
2018
|
+
const undo = {
|
|
2019
|
+
name: "undo_video",
|
|
2020
|
+
title: "Undo a video edit",
|
|
2021
|
+
description: "Undoes the last edit. Returns whether something was undone and its label.",
|
|
2022
|
+
inputSchema: object({}),
|
|
2023
|
+
annotations: { title: "Undo a video edit", ...WRITE },
|
|
2024
|
+
handler: () => {
|
|
2025
|
+
const label = controller.getState().undoLabel;
|
|
2026
|
+
const undone = controller.undo();
|
|
2027
|
+
return { undone, ...undone ? { label } : {} };
|
|
2028
|
+
}
|
|
2029
|
+
};
|
|
2030
|
+
const redo = {
|
|
2031
|
+
name: "redo_video",
|
|
2032
|
+
title: "Redo a video edit",
|
|
2033
|
+
description: "Redoes the last undone edit. Returns whether something was redone and its label.",
|
|
2034
|
+
inputSchema: object({}),
|
|
2035
|
+
annotations: { title: "Redo a video edit", ...WRITE },
|
|
2036
|
+
handler: () => {
|
|
2037
|
+
const label = controller.getState().redoLabel;
|
|
2038
|
+
const redone = controller.redo();
|
|
2039
|
+
return { redone, ...redone ? { label } : {} };
|
|
2040
|
+
}
|
|
2041
|
+
};
|
|
2042
|
+
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];
|
|
2044
|
+
return {
|
|
2045
|
+
id: options.id ?? "video",
|
|
2046
|
+
namespace: options.namespace,
|
|
2047
|
+
tools: () => readOnlyReason() ? reads : [...reads, ...writes],
|
|
2048
|
+
subscribe: (notify) => {
|
|
2049
|
+
let last = readOnlyReason();
|
|
2050
|
+
return controller.subscribe(() => {
|
|
2051
|
+
const now = readOnlyReason();
|
|
2052
|
+
if (now !== last) {
|
|
2053
|
+
last = now;
|
|
2054
|
+
notify();
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// src/video-editor/ui.ts
|
|
2062
|
+
var STEPS = [40, 100, 200, 500, 1e3, 2e3, 5e3, 1e4, 15e3, 3e4, 6e4, 12e4, 3e5, 6e5];
|
|
2063
|
+
function videoRulerTicks(duration, pxPerSecond, fps) {
|
|
2064
|
+
const pxPerMs = pxPerSecond / 1e3;
|
|
2065
|
+
const major = STEPS.find((s) => s * pxPerMs >= 80) ?? STEPS[STEPS.length - 1];
|
|
2066
|
+
const minor = STEPS.slice().reverse().find((s) => s < major && major % s === 0 && s * pxPerMs >= 10);
|
|
2067
|
+
const step = minor ?? major;
|
|
2068
|
+
const ticks = [];
|
|
2069
|
+
for (let t = 0; t <= duration + 1e-6; t += step) {
|
|
2070
|
+
const isMajor = Math.abs(t / major - Math.round(t / major)) < 1e-9;
|
|
2071
|
+
ticks.push({ time: t, x: t * pxPerMs, major: isMajor, ...isMajor ? { label: formatVideoTimecode(t, fps).replace(/^00:/, "") } : {} });
|
|
2072
|
+
}
|
|
2073
|
+
return ticks;
|
|
2074
|
+
}
|
|
2075
|
+
function videoClipTint(clip, asset) {
|
|
2076
|
+
if (clip.type === "text") return "#a855f7";
|
|
2077
|
+
if (clip.type === "color") return clip.color;
|
|
2078
|
+
if (!asset) return "#ef4444";
|
|
2079
|
+
return asset.kind === "audio" ? "#10b981" : asset.kind === "image" ? "#f59e0b" : "#3b82f6";
|
|
2080
|
+
}
|
|
2081
|
+
function videoClipLabel(clip, asset) {
|
|
2082
|
+
if (clip.name) return clip.name;
|
|
2083
|
+
if (clip.type === "text") return clip.text.split("\n")[0];
|
|
2084
|
+
if (clip.type === "color") return clip.color;
|
|
2085
|
+
const base = asset?.name ?? asset?.src.replace(/^.*[\\/]/, "") ?? clip.asset;
|
|
2086
|
+
const speed = videoClipSpeed(clip);
|
|
2087
|
+
return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
|
|
2088
|
+
}
|
|
2089
|
+
function planVideoClipDrag(project, input) {
|
|
2090
|
+
const at = findVideoClip(project, input.clip);
|
|
2091
|
+
if (!at) return null;
|
|
2092
|
+
const { clip, track } = at;
|
|
2093
|
+
const snap = input.snap ?? ((t) => t);
|
|
2094
|
+
if (input.mode === "move") {
|
|
2095
|
+
const raw = Math.max(0, clip.start + input.delta);
|
|
2096
|
+
const head = snap(raw) - raw;
|
|
2097
|
+
const tail = snap(raw + clip.duration) - (raw + clip.duration);
|
|
2098
|
+
const shifts = [head, tail].filter((d) => Math.abs(d) > 1e-9);
|
|
2099
|
+
const start = Math.max(0, raw + (shifts.length ? shifts.reduce((a, b) => Math.abs(b) < Math.abs(a) ? b : a) : 0));
|
|
2100
|
+
const target = input.track ?? track.id;
|
|
2101
|
+
const moved = Math.abs(start - clip.start) > 1e-6 || target !== track.id;
|
|
2102
|
+
return { start, duration: clip.duration, track: target, edit: moved ? { op: "move_clip", clip: clip.id, start, ...target !== track.id ? { track: target } : {} } : null };
|
|
2103
|
+
}
|
|
2104
|
+
const minimum = 1e3 / project.fps;
|
|
2105
|
+
if (input.mode === "trim-start") {
|
|
2106
|
+
const to2 = Math.min(snap(Math.max(0, clip.start + input.delta)), videoClipEnd(clip) - minimum);
|
|
2107
|
+
const changed2 = Math.abs(to2 - clip.start) > 1e-6;
|
|
2108
|
+
const plan = input.ripple ? { start: clip.start, duration: clip.duration - (to2 - clip.start) } : { start: to2, duration: videoClipEnd(clip) - to2 };
|
|
2109
|
+
return { ...plan, track: track.id, edit: changed2 ? { op: "trim_clip", clip: clip.id, edge: "start", to: to2, ...input.ripple ? { ripple: true } : {} } : null };
|
|
2110
|
+
}
|
|
2111
|
+
const to = Math.max(snap(videoClipEnd(clip) + input.delta), clip.start + minimum);
|
|
2112
|
+
const changed = Math.abs(to - videoClipEnd(clip)) > 1e-6;
|
|
2113
|
+
return { start: clip.start, duration: to - clip.start, track: track.id, edit: changed ? { op: "trim_clip", clip: clip.id, edge: "end", to, ...input.ripple ? { ripple: true } : {} } : null };
|
|
2114
|
+
}
|
|
2115
|
+
function videoDropTracks(project, clipId) {
|
|
2116
|
+
const at = findVideoClip(project, clipId);
|
|
2117
|
+
if (!at) return [];
|
|
2118
|
+
return project.tracks.filter((t) => t.kind === at.track.kind && !t.locked).map((t) => t.id);
|
|
2119
|
+
}
|
|
2120
|
+
function videoAssetDropEdit(project, assetId, options = {}) {
|
|
2121
|
+
const asset = videoAssetMap(project).get(assetId);
|
|
2122
|
+
if (!asset) return null;
|
|
2123
|
+
const kind = asset.kind === "audio" ? "audio" : "video";
|
|
2124
|
+
const track = options.track ?? project.tracks.find((t) => t.kind === kind && !t.locked)?.id;
|
|
2125
|
+
if (!track) return null;
|
|
2126
|
+
return { op: "add_clip", track, clip: { type: "media", asset: asset.id, ...options.start !== void 0 ? { start: options.start } : {} }, ...options.ripple ? { ripple: true } : {} };
|
|
2127
|
+
}
|
|
2128
|
+
function videoAssetKindOf(typeOrName) {
|
|
2129
|
+
const s = typeOrName.toLowerCase().replace(/[?#].*$/, "");
|
|
2130
|
+
if (s.startsWith("video/") || /\.(mp4|m4v|mov|webm|ogv|mkv)$/.test(s)) return "video";
|
|
2131
|
+
if (s.startsWith("audio/") || /\.(mp3|m4a|aac|wav|ogg|oga|flac|opus)$/.test(s)) return "audio";
|
|
2132
|
+
if (s.startsWith("image/") || /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/.test(s)) return "image";
|
|
2133
|
+
return null;
|
|
2134
|
+
}
|
|
2135
|
+
function probeVideoMedia(src, kind, timeout = 15e3) {
|
|
2136
|
+
return new Promise((resolve, reject) => {
|
|
2137
|
+
if (typeof document === "undefined") return reject(new Error("Media can only be probed in a browser."));
|
|
2138
|
+
const timer = setTimeout(() => reject(new Error(`"${src}" took too long to load.`)), timeout);
|
|
2139
|
+
const done = (probe) => {
|
|
2140
|
+
clearTimeout(timer);
|
|
2141
|
+
resolve(probe);
|
|
2142
|
+
};
|
|
2143
|
+
const failed = () => {
|
|
2144
|
+
clearTimeout(timer);
|
|
2145
|
+
reject(new Error(`"${src}" could not be loaded as ${kind}.`));
|
|
2146
|
+
};
|
|
2147
|
+
if (kind === "image") {
|
|
2148
|
+
const img = new Image();
|
|
2149
|
+
img.onload = () => done({ kind, width: img.naturalWidth, height: img.naturalHeight });
|
|
2150
|
+
img.onerror = failed;
|
|
2151
|
+
img.src = src;
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
const el = document.createElement(kind);
|
|
2155
|
+
el.preload = "metadata";
|
|
2156
|
+
el.onloadedmetadata = () => {
|
|
2157
|
+
const duration = Number.isFinite(el.duration) ? Math.round(el.duration * 1e3) : void 0;
|
|
2158
|
+
const v = el;
|
|
2159
|
+
done({ kind, ...duration ? { duration } : {}, ...kind === "video" && v.videoWidth ? { width: v.videoWidth, height: v.videoHeight } : {} });
|
|
2160
|
+
};
|
|
2161
|
+
el.onerror = failed;
|
|
2162
|
+
el.src = src;
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
export {
|
|
2166
|
+
VIDEO_CLIP_TYPES,
|
|
2167
|
+
VIDEO_DEFAULT_STILL_DURATION,
|
|
2168
|
+
VIDEO_DEFAULT_TITLE_DURATION,
|
|
2169
|
+
VIDEO_EDIT_OPS,
|
|
2170
|
+
VIDEO_FITS,
|
|
2171
|
+
VIDEO_ISSUE_CODES,
|
|
2172
|
+
VIDEO_TIME_EPSILON,
|
|
2173
|
+
VIDEO_TRANSITION_TYPES,
|
|
2174
|
+
VIDEO_WIPE_DIRECTIONS,
|
|
2175
|
+
VideoEditorController,
|
|
2176
|
+
VideoPreviewEngine,
|
|
2177
|
+
allVideoClipIds,
|
|
2178
|
+
applyVideoEdits,
|
|
2179
|
+
checkVideoProject,
|
|
2180
|
+
createVideoEditorToolProvider,
|
|
2181
|
+
createVideoProject,
|
|
2182
|
+
describeVideoProject,
|
|
2183
|
+
drawVideoFrame,
|
|
2184
|
+
ffmpegFilterValue,
|
|
2185
|
+
findVideoClip,
|
|
2186
|
+
findVideoGaps,
|
|
2187
|
+
formatVideoSeconds,
|
|
2188
|
+
formatVideoTimecode,
|
|
2189
|
+
nextFreeVideoSlot,
|
|
2190
|
+
parseVideoAsset,
|
|
2191
|
+
parseVideoClip,
|
|
2192
|
+
parseVideoMarker,
|
|
2193
|
+
parseVideoProject,
|
|
2194
|
+
parseVideoTrack,
|
|
2195
|
+
parseVideoTransform,
|
|
2196
|
+
parseVideoTransition,
|
|
2197
|
+
placeVideoRect,
|
|
2198
|
+
planVideoClipDrag,
|
|
2199
|
+
probeVideoMedia,
|
|
2200
|
+
snapToVideoFrame,
|
|
2201
|
+
snapVideoTime,
|
|
2202
|
+
sortedVideoClips,
|
|
2203
|
+
toVideoEdl,
|
|
2204
|
+
toVideoFfmpeg,
|
|
2205
|
+
videoAssetDropEdit,
|
|
2206
|
+
videoAssetKindOf,
|
|
2207
|
+
videoAssetMap,
|
|
2208
|
+
videoClipAfter,
|
|
2209
|
+
videoClipBefore,
|
|
2210
|
+
videoClipEnd,
|
|
2211
|
+
videoClipHandles,
|
|
2212
|
+
videoClipLabel,
|
|
2213
|
+
videoClipOverhang,
|
|
2214
|
+
videoClipSourceRange,
|
|
2215
|
+
videoClipSpeed,
|
|
2216
|
+
videoClipTint,
|
|
2217
|
+
videoClipsAt,
|
|
2218
|
+
videoContactSheetSvg,
|
|
2219
|
+
videoDropTracks,
|
|
2220
|
+
videoFrameAt,
|
|
2221
|
+
videoFrameDuration,
|
|
2222
|
+
videoFrameToSvg,
|
|
2223
|
+
videoFrames,
|
|
2224
|
+
videoIssueKey,
|
|
2225
|
+
videoOverlap,
|
|
2226
|
+
videoProjectDuration,
|
|
2227
|
+
videoProjectSvg,
|
|
2228
|
+
videoRulerTicks,
|
|
2229
|
+
videoSnapPoints,
|
|
2230
|
+
videoTextStyle,
|
|
2231
|
+
videoTitleFrame,
|
|
2232
|
+
videoWipeRect
|
|
2233
|
+
};
|
|
2234
|
+
//# sourceMappingURL=index.js.map
|