@pixerate/editor 0.91.0 → 0.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/video-editor/index.cjs +604 -340
- package/dist/video-editor/index.cjs.map +1 -1
- package/dist/video-editor/index.d.cts +470 -28
- package/dist/video-editor/index.d.ts +470 -28
- package/dist/video-editor/index.js +521 -257
- package/dist/video-editor/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/pixerate-video-editor/SKILL.md +22 -3
|
@@ -20,7 +20,7 @@ var VIDEO_BLEND_MODES = ["normal", "multiply", "screen", "overlay", "darken", "l
|
|
|
20
20
|
var VIDEO_CLIP_ANIMATION_EFFECTS = ["fade", "rise", "drop", "slide-left", "slide-right", "zoom", "pop"];
|
|
21
21
|
var VIDEO_FADE_CURVES = ["linear", "equal-power", "s-curve", "exponential", "logarithmic"];
|
|
22
22
|
var VIDEO_PLACEMENT_MODES = ["refuse", "overwrite", "insert"];
|
|
23
|
-
var VIDEO_CLIP_TYPES = ["media", "text", "color"];
|
|
23
|
+
var VIDEO_CLIP_TYPES = ["media", "text", "color", "sequence"];
|
|
24
24
|
|
|
25
25
|
// src/video-editor/keyframes.ts
|
|
26
26
|
var VIDEO_EASE_NAMES = ["linear", "hold", "ease-in", "ease-out", "ease-in-out"];
|
|
@@ -911,6 +911,20 @@ function parseVideoClip(v, path = "clip") {
|
|
|
911
911
|
fadeInCurve: oneOf(v.fadeInCurve, VIDEO_FADE_CURVES, `${path}.fadeInCurve`),
|
|
912
912
|
fadeOutCurve: oneOf(v.fadeOutCurve, VIDEO_FADE_CURVES, `${path}.fadeOutCurve`)
|
|
913
913
|
});
|
|
914
|
+
if (type === "sequence") {
|
|
915
|
+
for (const key of ["speed", "timeRemap"])
|
|
916
|
+
if (v[key] !== void 0 && !(key === "speed" && v[key] === 1)) fail(`${path}.${key}`, 'is not supported on a compound clip (type "sequence"), which plays its sequence at normal speed; remove it, or change the speed of the clips inside the sequence');
|
|
917
|
+
return compact({
|
|
918
|
+
type,
|
|
919
|
+
...base,
|
|
920
|
+
sequence: str(v.sequence, `${path}.sequence`),
|
|
921
|
+
in: num(v.in, `${path}.in`, { optional: true, min: 0 }),
|
|
922
|
+
volume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
|
|
923
|
+
muted: bool(v.muted, `${path}.muted`),
|
|
924
|
+
fadeInCurve: oneOf(v.fadeInCurve, VIDEO_FADE_CURVES, `${path}.fadeInCurve`),
|
|
925
|
+
fadeOutCurve: oneOf(v.fadeOutCurve, VIDEO_FADE_CURVES, `${path}.fadeOutCurve`)
|
|
926
|
+
});
|
|
927
|
+
}
|
|
914
928
|
if (type === "text")
|
|
915
929
|
return compact({
|
|
916
930
|
type,
|
|
@@ -939,6 +953,21 @@ function parseVideoTrack(v, path = "track") {
|
|
|
939
953
|
clips: (v.clips ?? []).map((c, i) => parseVideoClip(c, `${path}.clips[${i}]`))
|
|
940
954
|
});
|
|
941
955
|
}
|
|
956
|
+
function parseVideoSequence(v, path = "sequence") {
|
|
957
|
+
if (!isObject(v)) return fail(path, "must be an object { id, name, tracks }");
|
|
958
|
+
if (v.tracks !== void 0 && !Array.isArray(v.tracks)) fail(`${path}.tracks`, "must be an array");
|
|
959
|
+
const sequence = compact({
|
|
960
|
+
id: str(v.id, `${path}.id`),
|
|
961
|
+
name: str(v.name, `${path}.name`, true),
|
|
962
|
+
tracks: (v.tracks ?? []).map((t, i) => parseVideoTrack(t, `${path}.tracks[${i}]`))
|
|
963
|
+
});
|
|
964
|
+
const seen = /* @__PURE__ */ new Set();
|
|
965
|
+
for (const t of sequence.tracks) {
|
|
966
|
+
if (seen.has(t.id)) throw new Error(`Two tracks in sequence "${sequence.id}" share the id "${t.id}"; ids must be unique.`);
|
|
967
|
+
seen.add(t.id);
|
|
968
|
+
}
|
|
969
|
+
return sequence;
|
|
970
|
+
}
|
|
942
971
|
function parseVideoMarker(v, path = "marker") {
|
|
943
972
|
if (!isObject(v)) return fail(path, "must be an object");
|
|
944
973
|
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) });
|
|
@@ -954,7 +983,7 @@ function parseVideoProject(input) {
|
|
|
954
983
|
}
|
|
955
984
|
if (!isObject(v)) return fail("The project", "must be an object");
|
|
956
985
|
if (v.version !== void 0 && v.version !== 1) fail("version", "must be 1");
|
|
957
|
-
for (const key of ["assets", "tracks", "markers"]) if (v[key] !== void 0 && !Array.isArray(v[key])) fail(key, "must be an array");
|
|
986
|
+
for (const key of ["assets", "tracks", "sequences", "markers"]) if (v[key] !== void 0 && !Array.isArray(v[key])) fail(key, "must be an array");
|
|
958
987
|
const project = compact({
|
|
959
988
|
version: 1,
|
|
960
989
|
name: str(v.name, "name", true),
|
|
@@ -964,6 +993,7 @@ function parseVideoProject(input) {
|
|
|
964
993
|
background: str(v.background, "background", true),
|
|
965
994
|
assets: (v.assets ?? []).map((a, i) => parseVideoAsset(a, `assets[${i}]`)),
|
|
966
995
|
tracks: (v.tracks ?? []).map((t, i) => parseVideoTrack(t, `tracks[${i}]`)),
|
|
996
|
+
sequences: v.sequences === void 0 ? void 0 : v.sequences.map((q, i) => parseVideoSequence(q, `sequences[${i}]`)),
|
|
967
997
|
markers: v.markers === void 0 ? void 0 : v.markers.map((m, i) => parseVideoMarker(m, `markers[${i}]`))
|
|
968
998
|
});
|
|
969
999
|
const unique = (ids, what) => {
|
|
@@ -975,7 +1005,8 @@ function parseVideoProject(input) {
|
|
|
975
1005
|
};
|
|
976
1006
|
unique(project.assets.map((a) => a.id), "assets");
|
|
977
1007
|
unique(project.tracks.map((t) => t.id), "tracks");
|
|
978
|
-
unique(project.
|
|
1008
|
+
unique((project.sequences ?? []).map((q) => q.id), "sequences");
|
|
1009
|
+
unique([...project.tracks, ...(project.sequences ?? []).flatMap((q) => q.tracks)].flatMap((t) => t.clips.map((c) => c.id)), "clips");
|
|
979
1010
|
unique((project.markers ?? []).map((m) => m.id), "markers");
|
|
980
1011
|
return project;
|
|
981
1012
|
}
|
|
@@ -1001,6 +1032,7 @@ var videoClipEnd = (clip) => clip.start + clip.duration;
|
|
|
1001
1032
|
var videoTrackSyncLocked = (track) => track.syncLocked !== false;
|
|
1002
1033
|
var videoClipSpeed = (clip) => clip.type === "media" ? clip.speed ?? 1 : 1;
|
|
1003
1034
|
function videoClipSourceRange(clip) {
|
|
1035
|
+
if (clip.type === "sequence") return { in: clip.in ?? 0, out: (clip.in ?? 0) + clip.duration };
|
|
1004
1036
|
if (clip.type !== "media") return null;
|
|
1005
1037
|
const start = clip.in ?? 0;
|
|
1006
1038
|
if (clip.timeRemap?.keys.length) return { in: start + clip.timeRemap.keys[0].v, out: start + clip.timeRemap.keys[clip.timeRemap.keys.length - 1].v };
|
|
@@ -1050,6 +1082,33 @@ var videoAssetHasSound = (asset) => !!asset && (asset.kind === "audio" || asset.
|
|
|
1050
1082
|
function videoAssetMap(project) {
|
|
1051
1083
|
return new Map(project.assets.map((a) => [a.id, a]));
|
|
1052
1084
|
}
|
|
1085
|
+
function videoSequenceMap(project) {
|
|
1086
|
+
return new Map((project.sequences ?? []).map((q) => [q.id, q]));
|
|
1087
|
+
}
|
|
1088
|
+
function videoSequenceDuration(sequence) {
|
|
1089
|
+
let end = 0;
|
|
1090
|
+
for (const track of sequence.tracks) for (const clip of track.clips) end = Math.max(end, videoClipEnd(clip));
|
|
1091
|
+
return end;
|
|
1092
|
+
}
|
|
1093
|
+
var videoSequenceChildren = (sequence) => [...new Set(sequence.tracks.flatMap((t) => t.clips.flatMap((c) => c.type === "sequence" ? [c.sequence] : [])))];
|
|
1094
|
+
function findVideoSequenceCycle(project, id) {
|
|
1095
|
+
const sequences = videoSequenceMap(project);
|
|
1096
|
+
const walk = (at, path) => {
|
|
1097
|
+
const seq = sequences.get(at);
|
|
1098
|
+
if (!seq) return null;
|
|
1099
|
+
for (const child of videoSequenceChildren(seq)) {
|
|
1100
|
+
if (child === id) return [...path, child];
|
|
1101
|
+
if (path.includes(child)) continue;
|
|
1102
|
+
const found = walk(child, [...path, child]);
|
|
1103
|
+
if (found) return found;
|
|
1104
|
+
}
|
|
1105
|
+
return null;
|
|
1106
|
+
};
|
|
1107
|
+
return walk(id, [id]);
|
|
1108
|
+
}
|
|
1109
|
+
function allVideoTracks(project) {
|
|
1110
|
+
return [...project.tracks.map((track) => ({ track })), ...(project.sequences ?? []).flatMap((q) => q.tracks.map((track) => ({ sequence: q.id, track })))];
|
|
1111
|
+
}
|
|
1053
1112
|
function sortedVideoClips(track) {
|
|
1054
1113
|
return [...track.clips].sort((a, b) => a.start - b.start || a.duration - b.duration);
|
|
1055
1114
|
}
|
|
@@ -1062,7 +1121,15 @@ function findVideoClip(project, id) {
|
|
|
1062
1121
|
return null;
|
|
1063
1122
|
}
|
|
1064
1123
|
function allVideoClipIds(project) {
|
|
1065
|
-
return project.
|
|
1124
|
+
return allVideoTracks(project).flatMap(({ track }) => track.clips.map((c) => c.id));
|
|
1125
|
+
}
|
|
1126
|
+
function findVideoSequenceClip(project, id) {
|
|
1127
|
+
for (const sequence of project.sequences ?? [])
|
|
1128
|
+
for (const track of sequence.tracks) {
|
|
1129
|
+
const clip = track.clips.find((c) => c.id === id);
|
|
1130
|
+
if (clip) return { sequence, track, clip };
|
|
1131
|
+
}
|
|
1132
|
+
return null;
|
|
1066
1133
|
}
|
|
1067
1134
|
function videoProjectDuration(project) {
|
|
1068
1135
|
let end = 0;
|
|
@@ -1078,7 +1145,12 @@ function videoClipAfter(track, clip) {
|
|
|
1078
1145
|
for (const c of track.clips) if (c !== clip && Math.abs(c.start - videoClipEnd(clip)) <= VIDEO_TIME_EPSILON) return c;
|
|
1079
1146
|
return null;
|
|
1080
1147
|
}
|
|
1081
|
-
function videoClipHandles(clip, assets) {
|
|
1148
|
+
function videoClipHandles(clip, assets, sequences) {
|
|
1149
|
+
if (clip.type === "sequence") {
|
|
1150
|
+
const seq = sequences?.get(clip.sequence);
|
|
1151
|
+
const range2 = videoClipSourceRange(clip);
|
|
1152
|
+
return { before: range2.in, after: seq ? Math.max(0, videoSequenceDuration(seq) - range2.out) : Infinity };
|
|
1153
|
+
}
|
|
1082
1154
|
if (clip.type !== "media") return { before: Infinity, after: Infinity };
|
|
1083
1155
|
const asset = assets.get(clip.asset);
|
|
1084
1156
|
const speed = videoClipSpeed(clip);
|
|
@@ -1210,13 +1282,15 @@ function videoClipAnimated(clip, project) {
|
|
|
1210
1282
|
function videoClipKeyframes(clip) {
|
|
1211
1283
|
const out = [
|
|
1212
1284
|
{ property: "opacity", keys: videoKeysOf(clip.opacity) },
|
|
1213
|
-
{ property: "volume", keys: clip.type === "media" ? videoKeysOf(clip.volume) : [] },
|
|
1285
|
+
{ property: "volume", keys: clip.type === "media" || clip.type === "sequence" ? videoKeysOf(clip.volume) : [] },
|
|
1214
1286
|
...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p, keys: videoKeysOf(clip.transform?.[p]) }))
|
|
1215
1287
|
];
|
|
1216
1288
|
return out.filter((o) => o.keys.length);
|
|
1217
1289
|
}
|
|
1218
1290
|
var VIDEO_ISSUE_CODES = [
|
|
1219
1291
|
"missing-asset",
|
|
1292
|
+
"missing-sequence",
|
|
1293
|
+
"sequence-cycle",
|
|
1220
1294
|
"wrong-track",
|
|
1221
1295
|
"overlap",
|
|
1222
1296
|
"source-overrun",
|
|
@@ -1229,23 +1303,41 @@ var VIDEO_ISSUE_CODES = [
|
|
|
1229
1303
|
"keyframes-outside-clip",
|
|
1230
1304
|
"unknown-duration",
|
|
1231
1305
|
"unused-asset",
|
|
1306
|
+
"unused-sequence",
|
|
1232
1307
|
"marker-past-end",
|
|
1233
1308
|
"empty"
|
|
1234
1309
|
];
|
|
1235
|
-
var videoIssueKey = (i) => [i.code, i.track ?? "", i.clip ?? "", i.asset ?? "", i.marker ?? ""].join("|");
|
|
1310
|
+
var videoIssueKey = (i) => [i.code, i.sequence ?? "", i.track ?? "", i.clip ?? "", i.asset ?? "", i.marker ?? ""].join("|");
|
|
1236
1311
|
function checkVideoProject(project) {
|
|
1237
1312
|
const issues = [];
|
|
1238
1313
|
const assets = videoAssetMap(project);
|
|
1314
|
+
const sequences = videoSequenceMap(project);
|
|
1239
1315
|
const used = /* @__PURE__ */ new Set();
|
|
1316
|
+
const usedSequences = /* @__PURE__ */ new Set();
|
|
1317
|
+
const cycles = /* @__PURE__ */ new Map();
|
|
1240
1318
|
for (const asset of project.assets)
|
|
1241
1319
|
if ((asset.kind === "video" || asset.kind === "audio") && asset.duration === void 0)
|
|
1242
1320
|
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.` });
|
|
1243
|
-
|
|
1321
|
+
const checkTrack = (track, sequence) => {
|
|
1244
1322
|
const clips = sortedVideoClips(track);
|
|
1323
|
+
const where = sequence ? ` in sequence "${sequence}"` : "";
|
|
1245
1324
|
for (let i = 0; i < clips.length; i++) {
|
|
1246
1325
|
const clip = clips[i];
|
|
1247
|
-
const at = { track: track.id, clip: clip.id };
|
|
1248
|
-
if (clip.type === "
|
|
1326
|
+
const at = { ...sequence ? { sequence } : {}, track: track.id, clip: clip.id };
|
|
1327
|
+
if (clip.type === "sequence") {
|
|
1328
|
+
usedSequences.add(clip.sequence);
|
|
1329
|
+
const seq = sequences.get(clip.sequence);
|
|
1330
|
+
if (!seq) issues.push({ code: "missing-sequence", severity: "error", ...at, message: `Compound clip "${clip.id}" plays sequence "${clip.sequence}", which is not in the project; add the sequence or delete the clip.` });
|
|
1331
|
+
else {
|
|
1332
|
+
if (!cycles.has(seq.id)) cycles.set(seq.id, findVideoSequenceCycle(project, seq.id));
|
|
1333
|
+
const cycle = cycles.get(seq.id);
|
|
1334
|
+
if (cycle) issues.push({ code: "sequence-cycle", severity: "error", ...at, message: `Compound clip "${clip.id}" plays sequence "${seq.id}", which contains itself (${cycle.join(" \u2192 ")}), so it is left out of the picture and sound; remove the compound clip that closes the loop.` });
|
|
1335
|
+
const length = videoSequenceDuration(seq);
|
|
1336
|
+
const range = videoClipSourceRange(clip);
|
|
1337
|
+
if (range.out > length + VIDEO_TIME_EPSILON)
|
|
1338
|
+
issues.push({ code: "source-overrun", severity: "error", ...at, message: `Compound clip "${clip.id}" plays to ${formatVideoSeconds(range.out)} of sequence "${seq.id}", which is only ${formatVideoSeconds(length)} long; shorten the clip or move its in point.` });
|
|
1339
|
+
}
|
|
1340
|
+
} else if (clip.type === "media") {
|
|
1249
1341
|
used.add(clip.asset);
|
|
1250
1342
|
const asset = assets.get(clip.asset);
|
|
1251
1343
|
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.` });
|
|
@@ -1259,7 +1351,7 @@ function checkVideoProject(project) {
|
|
|
1259
1351
|
} 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.` });
|
|
1260
1352
|
const next = clips[i + 1];
|
|
1261
1353
|
if (next && next.start < videoClipEnd(clip) - VIDEO_TIME_EPSILON)
|
|
1262
|
-
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.` });
|
|
1354
|
+
issues.push({ code: "overlap", severity: "error", ...at, message: `Clips "${clip.id}" and "${next.id}" overlap on track "${track.id}"${where} from ${formatVideoSeconds(next.start)}; move one of them.` });
|
|
1263
1355
|
if ((clip.fadeIn ?? 0) + (clip.fadeOut ?? 0) > clip.duration + VIDEO_TIME_EPSILON)
|
|
1264
1356
|
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.` });
|
|
1265
1357
|
const anim = (clip.animateIn ? clip.animateIn.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0) + (clip.animateOut ? clip.animateOut.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0);
|
|
@@ -1294,8 +1386,8 @@ function checkVideoProject(project) {
|
|
|
1294
1386
|
continue;
|
|
1295
1387
|
}
|
|
1296
1388
|
if (clip.transition.type === "dip") continue;
|
|
1297
|
-
const outgoing = videoClipHandles(before, assets).after;
|
|
1298
|
-
const incoming = videoClipHandles(clip, assets).before;
|
|
1389
|
+
const outgoing = videoClipHandles(before, assets, sequences).after;
|
|
1390
|
+
const incoming = videoClipHandles(clip, assets, sequences).before;
|
|
1299
1391
|
const half = d / 2;
|
|
1300
1392
|
if (outgoing < half - VIDEO_TIME_EPSILON || incoming < half - VIDEO_TIME_EPSILON) {
|
|
1301
1393
|
const fits = 2 * Math.min(outgoing, incoming);
|
|
@@ -1308,7 +1400,11 @@ function checkVideoProject(project) {
|
|
|
1308
1400
|
}
|
|
1309
1401
|
}
|
|
1310
1402
|
}
|
|
1311
|
-
}
|
|
1403
|
+
};
|
|
1404
|
+
for (const track of project.tracks) checkTrack(track);
|
|
1405
|
+
for (const sequence of project.sequences ?? []) for (const track of sequence.tracks) checkTrack(track, sequence.id);
|
|
1406
|
+
for (const sequence of project.sequences ?? [])
|
|
1407
|
+
if (!usedSequences.has(sequence.id)) issues.push({ code: "unused-sequence", severity: "note", sequence: sequence.id, message: `Sequence "${sequence.id}" is not played by any compound clip.` });
|
|
1312
1408
|
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.` });
|
|
1313
1409
|
const end = videoProjectDuration(project);
|
|
1314
1410
|
for (const marker of project.markers ?? [])
|
|
@@ -1404,6 +1500,8 @@ var Editor = class {
|
|
|
1404
1500
|
}
|
|
1405
1501
|
clip(id) {
|
|
1406
1502
|
const at = findVideoClip(this.p, id);
|
|
1503
|
+
const nested = at ? null : findVideoSequenceClip(this.p, id);
|
|
1504
|
+
if (nested) refuse(`Clip "${id}" is inside sequence "${nested.sequence.id}", and clips inside sequences cannot be edited yet; edit the compound clip that plays the sequence instead (list_video_sequences shows which).`);
|
|
1407
1505
|
if (!at) {
|
|
1408
1506
|
const ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));
|
|
1409
1507
|
refuse(`There is no clip ${namedWithSuggestion(id, ids)}${ids.length ? "" : "; the project has no clips yet"}.`);
|
|
@@ -1427,6 +1525,11 @@ var Editor = class {
|
|
|
1427
1525
|
}
|
|
1428
1526
|
kindFits(track, clip) {
|
|
1429
1527
|
const asset = clip.type === "media" ? this.asset(clip.asset) : null;
|
|
1528
|
+
if (clip.type === "sequence") {
|
|
1529
|
+
const ids = (this.p.sequences ?? []).map((q) => q.id);
|
|
1530
|
+
if (typeof clip.sequence !== "string" || !ids.includes(clip.sequence)) refuse(`There is no sequence ${namedWithSuggestion(String(clip.sequence), ids)}; the sequences are ${ids.map((q) => `"${q}"`).join(", ") || "none"}.`);
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1430
1533
|
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.`);
|
|
1431
1534
|
if (track.kind === "video" && asset?.kind === "audio") refuse(`Track "${track.id}" is a video track; audio asset "${asset.id}" goes on an audio track.`);
|
|
1432
1535
|
}
|
|
@@ -1440,7 +1543,7 @@ var Editor = class {
|
|
|
1440
1543
|
return 1e3 / this.p.fps;
|
|
1441
1544
|
}
|
|
1442
1545
|
ids() {
|
|
1443
|
-
return this.p
|
|
1546
|
+
return allVideoClipIds(this.p);
|
|
1444
1547
|
}
|
|
1445
1548
|
/**
|
|
1446
1549
|
* The tracks a ripple on `sources` moves, top to bottom: the sources, and every unlocked
|
|
@@ -1468,8 +1571,13 @@ var Editor = class {
|
|
|
1468
1571
|
range(from, to) {
|
|
1469
1572
|
if (!(to - from > VIDEO_TIME_EPSILON)) refuse(`"to" (${formatVideoSeconds(to)}) must be after "from" (${formatVideoSeconds(from)}).`);
|
|
1470
1573
|
}
|
|
1471
|
-
/** Moves a media clip's in point by `delta` of timeline time (stills, titles and colours have none). */
|
|
1574
|
+
/** Moves a media or compound clip's in point by `delta` of timeline time (stills, titles and colours have none). */
|
|
1472
1575
|
moveIn(clip, delta) {
|
|
1576
|
+
if (clip.type === "sequence") {
|
|
1577
|
+
clip.in = Math.max(0, (clip.in ?? 0) + delta);
|
|
1578
|
+
if (clip.in < 1e-9) delete clip.in;
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1473
1581
|
if (clip.type !== "media" || videoAssetMap(this.p).get(clip.asset)?.kind === "image") return;
|
|
1474
1582
|
clip.in = Math.max(0, (clip.in ?? 0) + delta * videoClipSpeed(clip));
|
|
1475
1583
|
if (clip.in < 1e-9) delete clip.in;
|
|
@@ -1497,7 +1605,7 @@ var Editor = class {
|
|
|
1497
1605
|
clip.duration = at - clip.start;
|
|
1498
1606
|
delete clip.fadeOut;
|
|
1499
1607
|
delete clip.animateOut;
|
|
1500
|
-
if (clip.type === "media") delete clip.fadeOutCurve;
|
|
1608
|
+
if (clip.type === "media" || clip.type === "sequence") delete clip.fadeOutCurve;
|
|
1501
1609
|
track.clips.push(right);
|
|
1502
1610
|
track.clips.sort((a, b) => a.start - b.start);
|
|
1503
1611
|
return right;
|
|
@@ -1619,7 +1727,7 @@ var Editor = class {
|
|
|
1619
1727
|
}
|
|
1620
1728
|
case "remove_asset": {
|
|
1621
1729
|
const asset = this.asset(e.asset);
|
|
1622
|
-
const users = this.p.
|
|
1730
|
+
const users = allVideoTracks(this.p).flatMap(({ track: t }) => t.clips.filter((c) => c.type === "media" && c.asset === asset.id).map((c) => c.id));
|
|
1623
1731
|
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.`);
|
|
1624
1732
|
this.p.assets = this.p.assets.filter((a) => a !== asset);
|
|
1625
1733
|
return this.outcomes.push({ op: e.op, asset: asset.id });
|
|
@@ -1661,7 +1769,7 @@ var Editor = class {
|
|
|
1661
1769
|
const mode = this.placement(e.mode, e.ripple);
|
|
1662
1770
|
const draft = { ...e.clip, type: e.clip.type ?? "media" };
|
|
1663
1771
|
this.kindFits(track, draft);
|
|
1664
|
-
const ids = this.
|
|
1772
|
+
const ids = this.ids();
|
|
1665
1773
|
if (draft.id !== void 0 && ids.includes(draft.id)) refuse(`There is already a clip "${draft.id}".`);
|
|
1666
1774
|
draft.id ??= freshId("c", ids);
|
|
1667
1775
|
if (draft.duration === void 0) {
|
|
@@ -1671,6 +1779,10 @@ var Editor = class {
|
|
|
1671
1779
|
else if (asset.duration === void 0) refuse(`Asset "${asset.id}" has no duration, so give the clip a "duration".`);
|
|
1672
1780
|
else draft.duration = (asset.duration - (draft.in ?? 0)) / (draft.speed ?? 1);
|
|
1673
1781
|
if (!(draft.duration > 0)) refuse(`"in" (${draft.in} ms) is at or past the end of "${asset.id}" (${asset.duration} ms).`);
|
|
1782
|
+
} else if (draft.type === "sequence") {
|
|
1783
|
+
const length = videoSequenceDuration(videoSequenceMap(this.p).get(draft.sequence)) - (draft.in ?? 0);
|
|
1784
|
+
if (!(length > 0)) refuse(`"in" (${draft.in ?? 0} ms) is at or past the end of sequence "${draft.sequence}".`);
|
|
1785
|
+
draft.duration = length;
|
|
1674
1786
|
} else draft.duration = VIDEO_DEFAULT_TITLE_DURATION;
|
|
1675
1787
|
}
|
|
1676
1788
|
const end = sortedVideoClips(track).reduce((m, c) => Math.max(m, videoClipEnd(c)), 0);
|
|
@@ -1705,7 +1817,7 @@ var Editor = class {
|
|
|
1705
1817
|
if (e.edge === "start") {
|
|
1706
1818
|
const delta = e.to - clip.start;
|
|
1707
1819
|
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)}.`);
|
|
1708
|
-
if (clip.type === "media" && !isRamped(clip)) {
|
|
1820
|
+
if (clip.type === "media" && !isRamped(clip) || clip.type === "sequence") {
|
|
1709
1821
|
const sourceIn = (clip.in ?? 0) + delta * speed;
|
|
1710
1822
|
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)}).`);
|
|
1711
1823
|
clip.in = Math.max(0, sourceIn);
|
|
@@ -1739,6 +1851,11 @@ var Editor = class {
|
|
|
1739
1851
|
if (asset?.duration !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
|
|
1740
1852
|
refuse(`"${clip.id}" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (clip.in ?? 0)) / speed)}, where "${asset.id}" runs out.`);
|
|
1741
1853
|
}
|
|
1854
|
+
if (clip.type === "sequence") {
|
|
1855
|
+
const seq = videoSequenceMap(this.p).get(clip.sequence);
|
|
1856
|
+
const length = seq ? videoSequenceDuration(seq) : Infinity;
|
|
1857
|
+
if ((clip.in ?? 0) + duration > length + VIDEO_TIME_EPSILON) refuse(`"${clip.id}" can end at most at ${formatVideoSeconds(clip.start + length - (clip.in ?? 0))}, where sequence "${clip.sequence}" ends.`);
|
|
1858
|
+
}
|
|
1742
1859
|
if (e.ripple) {
|
|
1743
1860
|
const tracks = this.rippleTracks([track]);
|
|
1744
1861
|
let created = [];
|
|
@@ -1813,6 +1930,7 @@ var Editor = class {
|
|
|
1813
1930
|
case "set_time_remap": {
|
|
1814
1931
|
const { track, clip } = this.clip(e.clip);
|
|
1815
1932
|
this.unlocked(track);
|
|
1933
|
+
if (clip.type === "sequence") refuse(`Clip "${clip.id}" is a compound clip, which plays its sequence at normal speed; ramp the clips inside the sequence instead.`);
|
|
1816
1934
|
if (clip.type !== "media") refuse(`Clip "${clip.id}" is a ${clip.type === "text" ? "title" : "colour"}, so it has no source to ramp; only video and audio clips change speed.`);
|
|
1817
1935
|
const asset = this.asset(clip.asset);
|
|
1818
1936
|
if (asset.kind === "image") refuse(`Clip "${clip.id}" is a still ("${asset.id}"), so it has no motion to ramp.`);
|
|
@@ -1870,16 +1988,16 @@ var Editor = class {
|
|
|
1870
1988
|
case "slip_clip": {
|
|
1871
1989
|
const { track, clip } = this.clip(e.clip);
|
|
1872
1990
|
this.unlocked(track);
|
|
1873
|
-
if (clip.type !== "media") refuse(`Clip "${clip.id}" is a ${clip.type === "text" ? "title" : "colour"}, so it has no source to slip; only video and
|
|
1874
|
-
const
|
|
1875
|
-
if (asset.kind === "image") refuse(`Clip "${clip.id}" is a still ("${asset
|
|
1876
|
-
const handles = videoClipHandles(clip, videoAssetMap(this.p));
|
|
1991
|
+
if (clip.type !== "media" && clip.type !== "sequence") refuse(`Clip "${clip.id}" is a ${clip.type === "text" ? "title" : "colour"}, so it has no source to slip; only video, audio and compound clips slip.`);
|
|
1992
|
+
const source = clip.type === "sequence" ? `sequence "${clip.sequence}"` : `"${this.asset(clip.asset).id}"`;
|
|
1993
|
+
if (clip.type === "media" && this.asset(clip.asset).kind === "image") refuse(`Clip "${clip.id}" is a still ("${clip.asset}"), so it has no source to slip; only video and audio clips slip.`);
|
|
1994
|
+
const handles = videoClipHandles(clip, videoAssetMap(this.p), videoSequenceMap(this.p));
|
|
1877
1995
|
const { lead, tail } = videoClipOverhang(track, clip);
|
|
1878
1996
|
const delta = this.clamp(
|
|
1879
1997
|
e.delta,
|
|
1880
1998
|
lead - handles.before,
|
|
1881
1999
|
handles.after - tail,
|
|
1882
|
-
() => e.delta > 0 ? `"${clip.id}" already plays to the end of
|
|
2000
|
+
() => e.delta > 0 ? `"${clip.id}" already plays to the end of ${source}${tail ? ` (less the ${tail} ms the transition after it needs)` : ""}, so it cannot slip later; slip it earlier (a negative delta).` : `"${clip.id}" already starts at the beginning of ${source}${lead ? ` (plus the ${lead} ms its transition needs)` : ""}, so it cannot slip earlier; slip it later (a positive delta).`
|
|
1883
2001
|
);
|
|
1884
2002
|
this.moveIn(clip, delta);
|
|
1885
2003
|
return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, delta });
|
|
@@ -1897,8 +2015,8 @@ var Editor = class {
|
|
|
1897
2015
|
refuse(`"${L.id}" ends at ${formatVideoSeconds(videoClipEnd(L))} but "${R.id}" starts at ${formatVideoSeconds(R.start)}; a roll needs two clips that meet at a cut (use trim_clip to move one edge).`);
|
|
1898
2016
|
}
|
|
1899
2017
|
const assets = videoAssetMap(this.p);
|
|
1900
|
-
const lo = Math.max(-(L.duration - this.frame), -(videoClipHandles(R, assets).before - videoClipOverhang(track, R).lead));
|
|
1901
|
-
const hi = Math.min(R.duration - this.frame, videoClipHandles(L, assets).after - videoClipOverhang(track, L).tail);
|
|
2018
|
+
const lo = Math.max(-(L.duration - this.frame), -(videoClipHandles(R, assets, videoSequenceMap(this.p)).before - videoClipOverhang(track, R).lead));
|
|
2019
|
+
const hi = Math.min(R.duration - this.frame, videoClipHandles(L, assets, videoSequenceMap(this.p)).after - videoClipOverhang(track, L).tail);
|
|
1902
2020
|
const delta = this.clamp(
|
|
1903
2021
|
e.delta,
|
|
1904
2022
|
lo,
|
|
@@ -1924,8 +2042,8 @@ var Editor = class {
|
|
|
1924
2042
|
earlier: before ? before.duration - this.frame : clip.start - others.filter((c) => videoClipEnd(c) <= clip.start + VIDEO_TIME_EPSILON).reduce((m, c) => Math.max(m, videoClipEnd(c)), 0),
|
|
1925
2043
|
later: after ? after.duration - this.frame : others.filter((c) => c.start >= end - VIDEO_TIME_EPSILON).reduce((m, c) => Math.min(m, c.start - end), Infinity)
|
|
1926
2044
|
};
|
|
1927
|
-
const lo = -Math.min(room.earlier, after ? videoClipHandles(after, assets).before - videoClipOverhang(track, after).lead : Infinity);
|
|
1928
|
-
const hi = Math.min(room.later, before ? videoClipHandles(before, assets).after - videoClipOverhang(track, before).tail : Infinity);
|
|
2045
|
+
const lo = -Math.min(room.earlier, after ? videoClipHandles(after, assets, videoSequenceMap(this.p)).before - videoClipOverhang(track, after).lead : Infinity);
|
|
2046
|
+
const hi = Math.min(room.later, before ? videoClipHandles(before, assets, videoSequenceMap(this.p)).after - videoClipOverhang(track, before).tail : Infinity);
|
|
1929
2047
|
const delta = this.clamp(
|
|
1930
2048
|
e.delta,
|
|
1931
2049
|
lo,
|
|
@@ -1967,7 +2085,7 @@ var Editor = class {
|
|
|
1967
2085
|
const { track, clip } = this.clip(e.clip);
|
|
1968
2086
|
this.unlocked(track);
|
|
1969
2087
|
if (!VIDEO_KEYFRAME_PROPERTIES.includes(e.property)) refuse(`"${e.property}" cannot be keyframed; use one of ${VIDEO_KEYFRAME_PROPERTIES.join(", ")}.`);
|
|
1970
|
-
if (e.property === "volume" && clip.type !== "media") refuse(`Clip "${clip.id}" is a ${clip.type} clip and has no volume; volume keys go on media clips.`);
|
|
2088
|
+
if (e.property === "volume" && clip.type !== "media" && clip.type !== "sequence") refuse(`Clip "${clip.id}" is a ${clip.type} clip and has no volume; volume keys go on media and compound clips.`);
|
|
1971
2089
|
const limits = VIDEO_KEYFRAME_LIMITS[e.property];
|
|
1972
2090
|
const transform = VIDEO_TRANSFORM_PROPERTIES.includes(e.property);
|
|
1973
2091
|
const c = clip;
|
|
@@ -2118,13 +2236,13 @@ function videoFadeCurveGain(curve, x) {
|
|
|
2118
2236
|
}
|
|
2119
2237
|
function fadeGain(clip, time, sound = false) {
|
|
2120
2238
|
let g = 1;
|
|
2121
|
-
const shape = sound && clip.type === "media" ? clip : void 0;
|
|
2239
|
+
const shape = sound && (clip.type === "media" || clip.type === "sequence") ? clip : void 0;
|
|
2122
2240
|
if (clip.fadeIn) g *= videoFadeCurveGain(shape?.fadeInCurve, (time - clip.start) / clip.fadeIn);
|
|
2123
2241
|
if (clip.fadeOut) g *= videoFadeCurveGain(shape?.fadeOutCurve, (videoClipEnd(clip) - time) / clip.fadeOut);
|
|
2124
2242
|
return g;
|
|
2125
2243
|
}
|
|
2126
2244
|
function videoClipSoundGain(clip, clipTime) {
|
|
2127
|
-
const volume = clip.type === "media" ? videoValueAt(clip.volume, clipTime, 1) : 1;
|
|
2245
|
+
const volume = clip.type === "media" || clip.type === "sequence" ? videoValueAt(clip.volume, clipTime, 1) : 1;
|
|
2128
2246
|
return volume * fadeGain(clip, clip.start + Math.min(Math.max(clipTime, 0), clip.duration), true);
|
|
2129
2247
|
}
|
|
2130
2248
|
function activeOnTrack(track, time) {
|
|
@@ -2144,11 +2262,15 @@ function activeOnTrack(track, time) {
|
|
|
2144
2262
|
}
|
|
2145
2263
|
var sourceTimeAt = (clip, time) => clip.type === "media" ? videoClipSourceTime(clip, time - clip.start) : 0;
|
|
2146
2264
|
function videoFrameAt(project, time) {
|
|
2147
|
-
const
|
|
2265
|
+
const { layers, audio } = tracksAt(project, project.tracks, time, { assets: videoAssetMap(project), sequences: videoSequenceMap(project), within: [], prefix: "" });
|
|
2266
|
+
return { time, width: project.width, height: project.height, background: project.background ?? "#000000", layers, audio };
|
|
2267
|
+
}
|
|
2268
|
+
function tracksAt(project, tracks, time, scope) {
|
|
2269
|
+
const { assets, prefix } = scope;
|
|
2148
2270
|
const layers = [];
|
|
2149
2271
|
const audio = [];
|
|
2150
|
-
for (let ti =
|
|
2151
|
-
const track =
|
|
2272
|
+
for (let ti = tracks.length - 1; ti >= 0; ti--) {
|
|
2273
|
+
const track = tracks[ti];
|
|
2152
2274
|
for (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {
|
|
2153
2275
|
if (incoming?.type === "dip" && incoming.progress < 0.5) continue;
|
|
2154
2276
|
if (outgoing?.type === "dip" && outgoing.progress >= 0.5) continue;
|
|
@@ -2157,15 +2279,26 @@ function videoFrameAt(project, time) {
|
|
|
2157
2279
|
const level = (t, transition, into) => videoFadeCurveGain(videoTransitionAudioCurve(transition), t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress);
|
|
2158
2280
|
const mix = (incoming ? level(incoming, incoming.transition, true) : 1) * (outgoing ? level(outgoing, outgoing.next.transition, false) : 1);
|
|
2159
2281
|
const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
|
|
2160
|
-
const sourceTime = sourceTimeAt(clip, time);
|
|
2282
|
+
const sourceTime = clip.type === "sequence" ? (clip.in ?? 0) + time - clip.start : sourceTimeAt(clip, time);
|
|
2161
2283
|
const clipTime = time - clip.start;
|
|
2284
|
+
let nested = null;
|
|
2285
|
+
if (clip.type === "sequence") {
|
|
2286
|
+
const seq = scope.sequences.get(clip.sequence);
|
|
2287
|
+
if (!seq || scope.within.includes(seq.id)) continue;
|
|
2288
|
+
nested = tracksAt(project, seq.tracks, sourceTime, { ...scope, within: [...scope.within, seq.id], prefix: `${prefix}${clip.id}/` });
|
|
2289
|
+
}
|
|
2162
2290
|
const audible = videoAssetHasSound(asset);
|
|
2163
2291
|
if (audible && clip.type === "media" && !clip.muted && !track.muted) {
|
|
2164
2292
|
const gain = videoValueAt(clip.volume, clipTime, 1) * (track.volume ?? 1) * fadeGain(clip, inside, true) * mix;
|
|
2165
|
-
audio.push({ clip: clip.id, track: track.id, asset: asset.id, src: asset.src, sourceTime, gain, speed: videoClipSpeedAt(clip, clipTime) });
|
|
2293
|
+
audio.push({ clip: prefix + clip.id, track: track.id, asset: asset.id, src: asset.src, sourceTime, gain, speed: videoClipSpeedAt(clip, clipTime) });
|
|
2294
|
+
}
|
|
2295
|
+
if (nested && clip.type === "sequence" && !clip.muted && !track.muted) {
|
|
2296
|
+
const gain = videoValueAt(clip.volume, clipTime, 1) * (track.volume ?? 1) * fadeGain(clip, inside, true) * mix;
|
|
2297
|
+
for (const a of nested.audio) audio.push({ ...a, gain: a.gain * gain });
|
|
2166
2298
|
}
|
|
2167
2299
|
if (track.kind !== "video" || track.hidden) continue;
|
|
2168
2300
|
if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
|
|
2301
|
+
if (nested && !nested.layers.length) continue;
|
|
2169
2302
|
const shown = videoClipAnimated(clip, project);
|
|
2170
2303
|
let opacity = videoValueAt(shown.opacity, clipTime, 1) * fade;
|
|
2171
2304
|
let wipe;
|
|
@@ -2188,25 +2321,29 @@ function videoFrameAt(project, time) {
|
|
|
2188
2321
|
shift = videoTransitionEase(outgoing.progress);
|
|
2189
2322
|
}
|
|
2190
2323
|
const size = clip.type === "media" ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
|
|
2191
|
-
const fit = clip.fit ?? (clip.type === "media" ? "contain" : "fill");
|
|
2324
|
+
const fit = clip.fit ?? (clip.type === "media" || clip.type === "sequence" ? "contain" : "fill");
|
|
2192
2325
|
const placed = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, shown.transform, clipTime);
|
|
2193
2326
|
const across = shiftDirection === "left" || shiftDirection === "right";
|
|
2194
2327
|
const sign = shiftDirection === "left" || shiftDirection === "up" ? -1 : 1;
|
|
2195
2328
|
const rect = shift ? { ...placed, ...across ? { x: placed.x + sign * shift * project.width } : { y: placed.y + sign * shift * project.height } } : placed;
|
|
2196
|
-
const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp012(opacity), rect, rotation: videoValueAt(shown.transform?.rotation, clipTime, 0) };
|
|
2329
|
+
const layer = { clip: prefix + clip.id, track: track.id, type: clip.type, opacity: clamp012(opacity), rect, rotation: videoValueAt(shown.transform?.rotation, clipTime, 0) };
|
|
2197
2330
|
if (wipe) layer.wipe = wipe;
|
|
2198
2331
|
if (iris) layer.iris = iris;
|
|
2199
2332
|
if (clip.blend && clip.blend !== "normal") layer.blend = clip.blend;
|
|
2200
2333
|
const grade = normalizeVideoGrade(clip.grade);
|
|
2201
2334
|
if (grade) layer.grade = grade;
|
|
2202
2335
|
if (clip.type === "media") Object.assign(layer, { asset: asset.id, assetKind: asset.kind, src: asset.src, sourceTime });
|
|
2336
|
+
if (clip.type === "sequence") Object.assign(layer, { sequence: clip.sequence, sourceTime, layers: nested.layers });
|
|
2203
2337
|
if (clip.type === "text") layer.text = videoTextStyle(clip);
|
|
2204
2338
|
if (clip.type === "color") layer.color = clip.color;
|
|
2205
2339
|
if (layer.opacity > 0) layers.push(layer);
|
|
2206
|
-
if (dip && dip.opacity > 0) layers.push({ clip: `transition:${incoming ? clip.id : outgoing.next.id}`, track: track.id, type: "color", color: dip.color, opacity: clamp012(dip.opacity), rect: { x: 0, y: 0, width: project.width, height: project.height }, rotation: 0 });
|
|
2340
|
+
if (dip && dip.opacity > 0) layers.push({ clip: `transition:${prefix}${incoming ? clip.id : outgoing.next.id}`, track: track.id, type: "color", color: dip.color, opacity: clamp012(dip.opacity), rect: { x: 0, y: 0, width: project.width, height: project.height }, rotation: 0 });
|
|
2207
2341
|
}
|
|
2208
2342
|
}
|
|
2209
|
-
return {
|
|
2343
|
+
return { layers, audio };
|
|
2344
|
+
}
|
|
2345
|
+
function videoFrameLeafLayers(layers) {
|
|
2346
|
+
return layers.flatMap((l) => l.layers ? videoFrameLeafLayers(l.layers) : [l]);
|
|
2210
2347
|
}
|
|
2211
2348
|
|
|
2212
2349
|
// src/video-editor/audio.ts
|
|
@@ -2967,6 +3104,7 @@ function videoWordAt(words, time) {
|
|
|
2967
3104
|
// src/video-editor/render.ts
|
|
2968
3105
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2969
3106
|
var n = (v) => String(Math.round(v * 100) / 100);
|
|
3107
|
+
var n4 = (v) => String(Math.round(v * 1e4) / 1e4);
|
|
2970
3108
|
var videoIrisRadius = (frame, progress) => progress * Math.hypot(frame.width / 2, frame.height / 2);
|
|
2971
3109
|
function videoWipeRect(rect, wipe) {
|
|
2972
3110
|
const { direction, progress } = wipe;
|
|
@@ -2979,13 +3117,16 @@ function videoWipeRect(rect, wipe) {
|
|
|
2979
3117
|
}
|
|
2980
3118
|
function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
2981
3119
|
const defs = [];
|
|
2982
|
-
const body = [];
|
|
2983
3120
|
const posters = new Map(assets.map((a) => [a.id, a.poster]));
|
|
2984
|
-
|
|
3121
|
+
const draw = (layers, prefix) => layers.map((layer, index) => {
|
|
3122
|
+
const i = `${prefix}${index}`;
|
|
2985
3123
|
const { rect } = layer;
|
|
2986
3124
|
const attrs = [];
|
|
3125
|
+
const style = [];
|
|
2987
3126
|
if (layer.opacity < 1) attrs.push(`opacity="${n(layer.opacity)}"`);
|
|
2988
|
-
if (layer.blend)
|
|
3127
|
+
if (layer.blend) style.push(`mix-blend-mode:${layer.blend === "add" ? "plus-lighter" : layer.blend}`);
|
|
3128
|
+
if (layer.layers) style.push("isolation:isolate");
|
|
3129
|
+
if (style.length) attrs.push(`style="${style.join(";")}"`);
|
|
2989
3130
|
if (layer.grade) {
|
|
2990
3131
|
defs.push(videoGradeSvgFilter(`g${i}`, layer.grade, layer.grade.lut ? options.lutFor?.(layer.grade.lut.src) : null));
|
|
2991
3132
|
attrs.push(`filter="url(#g${i})"`);
|
|
@@ -2998,7 +3139,9 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
|
2998
3139
|
}
|
|
2999
3140
|
const box = `x="${n(rect.x)}" y="${n(rect.y)}" width="${n(rect.width)}" height="${n(rect.height)}"`;
|
|
3000
3141
|
let inner = "";
|
|
3001
|
-
if (layer.
|
|
3142
|
+
if (layer.layers) {
|
|
3143
|
+
inner = `<g transform="translate(${n(rect.x)} ${n(rect.y)}) scale(${n4(rect.width / frame.width)} ${n4(rect.height / frame.height)})">${draw(layer.layers, `${i}-`)}</g>`;
|
|
3144
|
+
} else if (layer.type === "color") inner = `<rect ${box} fill="${esc(layer.color ?? "#000000")}"/>`;
|
|
3002
3145
|
else if (layer.type === "text" && layer.text) {
|
|
3003
3146
|
const t = layer.text;
|
|
3004
3147
|
const lines = t.text.split("\n");
|
|
@@ -3026,9 +3169,10 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
|
3026
3169
|
defs.push(`<clipPath id="i${i}"><circle cx="${n(frame.width / 2)}" cy="${n(frame.height / 2)}" r="${n(videoIrisRadius(frame, layer.iris.progress))}"/></clipPath>`);
|
|
3027
3170
|
item = `<g clip-path="url(#i${i})">${item}</g>`;
|
|
3028
3171
|
}
|
|
3029
|
-
|
|
3030
|
-
});
|
|
3031
|
-
|
|
3172
|
+
return item;
|
|
3173
|
+
}).join("");
|
|
3174
|
+
const body = draw(frame.layers, "");
|
|
3175
|
+
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}</svg>`;
|
|
3032
3176
|
}
|
|
3033
3177
|
function videoProjectSvg(project, time, options = {}) {
|
|
3034
3178
|
return videoFrameToSvg(videoFrameAt(project, time), { fps: project.fps, ...options }, project.assets);
|
|
@@ -3112,13 +3256,36 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
|
|
|
3112
3256
|
ctx.globalAlpha = 1;
|
|
3113
3257
|
ctx.fillStyle = frame.background;
|
|
3114
3258
|
ctx.fillRect(0, 0, frame.width, frame.height);
|
|
3259
|
+
drawVideoLayers(ctx, frame, frame.layers, sourceFor, scale, options, 0);
|
|
3260
|
+
ctx.restore();
|
|
3261
|
+
}
|
|
3262
|
+
function drawVideoLayers(ctx, frame, layers, sourceFor, scale, options, depth) {
|
|
3115
3263
|
const cw = Math.max(1, Math.round(frame.width * scale));
|
|
3116
3264
|
const ch = Math.max(1, Math.round(frame.height * scale));
|
|
3117
|
-
for (const layer of
|
|
3265
|
+
for (const layer of layers) {
|
|
3266
|
+
let source = sourceFor;
|
|
3267
|
+
if (layer.layers) {
|
|
3268
|
+
const group = options.scratch?.(cw, ch, 2 * depth);
|
|
3269
|
+
if (!group) {
|
|
3270
|
+
drawVideoLayers(ctx, frame, layer.layers, sourceFor, scale, options, depth + 1);
|
|
3271
|
+
continue;
|
|
3272
|
+
}
|
|
3273
|
+
const g = group.ctx;
|
|
3274
|
+
g.save();
|
|
3275
|
+
g.setTransform(1, 0, 0, 1, 0, 0);
|
|
3276
|
+
g.clearRect(0, 0, cw, ch);
|
|
3277
|
+
g.setTransform(scale, 0, 0, scale, 0, 0);
|
|
3278
|
+
g.globalAlpha = 1;
|
|
3279
|
+
g.globalCompositeOperation = "source-over";
|
|
3280
|
+
drawVideoLayers(g, frame, layer.layers, sourceFor, scale, options, depth + 1);
|
|
3281
|
+
g.restore();
|
|
3282
|
+
source = (l) => l === layer ? group.canvas : sourceFor(l);
|
|
3283
|
+
}
|
|
3118
3284
|
const setting = layer.grade?.lut;
|
|
3119
3285
|
const lut = setting ? options.lutFor?.(setting.src) : null;
|
|
3120
|
-
const scratch = lut ? options.scratch?.(cw, ch) : null;
|
|
3286
|
+
const scratch = lut ? options.scratch?.(cw, ch, 2 * depth + 1) : null;
|
|
3121
3287
|
ctx.save();
|
|
3288
|
+
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
3122
3289
|
if (lut && scratch) {
|
|
3123
3290
|
const s = scratch.ctx;
|
|
3124
3291
|
s.save();
|
|
@@ -3127,7 +3294,7 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
|
|
|
3127
3294
|
s.setTransform(scale, 0, 0, scale, 0, 0);
|
|
3128
3295
|
s.globalAlpha = 1;
|
|
3129
3296
|
s.globalCompositeOperation = "source-over";
|
|
3130
|
-
paintVideoLayer(s, frame, layer,
|
|
3297
|
+
paintVideoLayer(s, frame, layer, source, options);
|
|
3131
3298
|
s.restore();
|
|
3132
3299
|
const img = s.getImageData(0, 0, cw, ch);
|
|
3133
3300
|
applyVideoLutToPixels(lut, img.data, setting.strength ?? 1);
|
|
@@ -3139,11 +3306,10 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
|
|
|
3139
3306
|
} else {
|
|
3140
3307
|
ctx.globalAlpha = layer.opacity;
|
|
3141
3308
|
if (layer.blend && layer.blend !== "normal") ctx.globalCompositeOperation = layer.blend === "add" ? "lighter" : layer.blend;
|
|
3142
|
-
paintVideoLayer(ctx, frame, layer,
|
|
3309
|
+
paintVideoLayer(ctx, frame, layer, source, options);
|
|
3143
3310
|
}
|
|
3144
3311
|
ctx.restore();
|
|
3145
3312
|
}
|
|
3146
|
-
ctx.restore();
|
|
3147
3313
|
}
|
|
3148
3314
|
|
|
3149
3315
|
// src/video-editor/export.ts
|
|
@@ -3185,6 +3351,7 @@ function toVideoEdl(project, options = {}) {
|
|
|
3185
3351
|
if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${asset?.name ?? asset?.src ?? clip.asset}`);
|
|
3186
3352
|
if (clip.type === "text") lines.push(`* TITLE: ${clip.text.replace(/\n/g, " ")}`);
|
|
3187
3353
|
if (clip.type === "color") lines.push(`* COLOR: ${clip.color}`);
|
|
3354
|
+
if (clip.type === "sequence") lines.push(`* COMPOUND CLIP: ${clip.name ?? clip.sequence} (sequence ${clip.sequence} from ${tc(clip.in ?? 0)}, not flattened)`);
|
|
3188
3355
|
lines.push("");
|
|
3189
3356
|
}
|
|
3190
3357
|
return lines.join("\n");
|
|
@@ -3264,6 +3431,7 @@ function toVideoLoudnessMeasure(project, options = {}) {
|
|
|
3264
3431
|
function buildFfmpeg(project, options, measure) {
|
|
3265
3432
|
const notes = [];
|
|
3266
3433
|
const assets = videoAssetMap(project);
|
|
3434
|
+
const sequences = videoSequenceMap(project);
|
|
3267
3435
|
const D = videoProjectDuration(project);
|
|
3268
3436
|
if (D <= 0) throw new Error("The project has no clips, so there is nothing to render.");
|
|
3269
3437
|
const W = even(project.width);
|
|
@@ -3271,8 +3439,6 @@ function buildFfmpeg(project, options, measure) {
|
|
|
3271
3439
|
const fps = project.fps;
|
|
3272
3440
|
const inputs = [];
|
|
3273
3441
|
const graph = measure ? [] : [`color=c=${color(project.background ?? "#000000")}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];
|
|
3274
|
-
const audio = [];
|
|
3275
|
-
let cur = "base";
|
|
3276
3442
|
let usesDrawtext = false;
|
|
3277
3443
|
const animated = /* @__PURE__ */ new Set();
|
|
3278
3444
|
const ramped = /* @__PURE__ */ new Set();
|
|
@@ -3296,7 +3462,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
3296
3462
|
const pts = videoTimeRemapBreakpoints(remap, Math.max(80, 2e3 / fps));
|
|
3297
3463
|
const first = pts[0];
|
|
3298
3464
|
const last = pts[pts.length - 1];
|
|
3299
|
-
const n2 =
|
|
3465
|
+
const n2 = ak;
|
|
3300
3466
|
const fmtA = "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo";
|
|
3301
3467
|
const silence = (ms) => `anullsrc=r=48000:cl=stereo,atrim=duration=${sec(ms)},${fmtA}`;
|
|
3302
3468
|
const pieces = [];
|
|
@@ -3345,180 +3511,228 @@ function buildFfmpeg(project, options, measure) {
|
|
|
3345
3511
|
} else inputs.push(["-ss", sec((clip.in ?? 0) - lead * speed), "-t", sec(span * speed), "-i", src]);
|
|
3346
3512
|
return inputs.length - 1;
|
|
3347
3513
|
};
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
const
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
const ai = index ?? input(clip, asset, lead, tail);
|
|
3365
|
-
const trackGain = track.volume ?? 1;
|
|
3366
|
-
const volume = isVideoKeyframes(clip.volume) ? `volume='${trackGain}*(${expr(clip.volume, `(t*1000${lead ? `-${Math.round(lead)}` : ""})`, 1, "volume")})':eval=frame` : `volume=${(clip.volume ?? 1) * trackGain}`;
|
|
3367
|
-
let source2 = `[${ai}:a]`;
|
|
3368
|
-
if (clip.timeRemap) {
|
|
3369
|
-
source2 = `[r${audio.length}]`;
|
|
3370
|
-
graph.push(...rampedSound(clip.timeRemap, `${ai}:a`, source2, lead, clip.duration + tail));
|
|
3371
|
-
ramped.add("sound");
|
|
3514
|
+
let ak = 0;
|
|
3515
|
+
let q = 0;
|
|
3516
|
+
const compose = (tracks, base, length, scope) => {
|
|
3517
|
+
let cur2 = base;
|
|
3518
|
+
const sounds = [];
|
|
3519
|
+
for (const track of [...tracks].reverse()) {
|
|
3520
|
+
for (const raw of sortedVideoClips(track)) {
|
|
3521
|
+
const clip = videoClipAnimated(raw, project);
|
|
3522
|
+
const { lead, tail } = videoClipOverhang(track, clip);
|
|
3523
|
+
const t0 = clip.start - lead;
|
|
3524
|
+
const span = clip.duration + lead + tail;
|
|
3525
|
+
const end = videoClipEnd(clip);
|
|
3526
|
+
const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
|
|
3527
|
+
if (clip.type === "media" && !asset) {
|
|
3528
|
+
notes.push(`Clip "${clip.id}" was left out: its asset "${clip.asset}" is missing.`);
|
|
3529
|
+
continue;
|
|
3372
3530
|
}
|
|
3373
|
-
const
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
const
|
|
3402
|
-
|
|
3403
|
-
const
|
|
3404
|
-
const
|
|
3405
|
-
|
|
3531
|
+
const shows = scope.picture && track.kind === "video" && !track.hidden;
|
|
3532
|
+
let compound = null;
|
|
3533
|
+
if (clip.type === "sequence") {
|
|
3534
|
+
const seq = sequences.get(clip.sequence);
|
|
3535
|
+
if (!seq || scope.within.includes(seq.id)) {
|
|
3536
|
+
notes.push(`Compound clip "${clip.id}" was left out: its sequence "${clip.sequence}" ${seq ? "contains itself" : "is missing"}.`);
|
|
3537
|
+
continue;
|
|
3538
|
+
}
|
|
3539
|
+
const from = (clip.in ?? 0) - lead;
|
|
3540
|
+
if (from < -0.5) notes.push(`Compound clip "${clip.id}"'s transition needs ${Math.round(-from)} ms of sequence "${seq.id}" before its start, which is not there; it shows nothing then.`);
|
|
3541
|
+
const length2 = Math.max(videoSequenceDuration(seq), from + span);
|
|
3542
|
+
const hears = scope.sound && !clip.muted && !track.muted;
|
|
3543
|
+
const n2 = q++;
|
|
3544
|
+
const nestedBase = shows ? `nb${n2}` : null;
|
|
3545
|
+
if (nestedBase) graph.push(`color=c=black@0:s=${W}x${H}:r=${fps}:d=${sec(length2)},format=yuva420p[${nestedBase}]`);
|
|
3546
|
+
const sub = compose(seq.tracks, nestedBase, length2, { within: [...scope.within, seq.id], picture: shows, sound: hears });
|
|
3547
|
+
compound = { picture: null, sound: null };
|
|
3548
|
+
if (sub.video) {
|
|
3549
|
+
graph.push(`[${sub.video}]trim=start=${sec(Math.max(0, from))}:duration=${sec(span)},setpts=PTS-STARTPTS[sq${n2}]`);
|
|
3550
|
+
compound.picture = `[sq${n2}]`;
|
|
3551
|
+
}
|
|
3552
|
+
if (sub.audio.length) {
|
|
3553
|
+
graph.push(`${sub.audio.join("")}amix=inputs=${sub.audio.length}:normalize=0:duration=longest,apad,atrim=start=${sec(Math.max(0, from))}:end=${sec(from + span)}[sm${n2}]`);
|
|
3554
|
+
compound.sound = `[sm${n2}]`;
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
let index;
|
|
3558
|
+
if (shows && asset && asset.kind !== "audio") index = input(clip, asset, lead, tail);
|
|
3559
|
+
const audible = clip.type === "sequence" ? !!compound?.sound : videoAssetHasSound(asset);
|
|
3560
|
+
if (scope.sound && audible && (clip.type === "media" || clip.type === "sequence") && !clip.muted && !track.muted) {
|
|
3561
|
+
const trackGain = track.volume ?? 1;
|
|
3562
|
+
const volume = isVideoKeyframes(clip.volume) ? `volume='${trackGain}*(${expr(clip.volume, `(t*1000${lead ? `-${Math.round(lead)}` : ""})`, 1, "volume")})':eval=frame` : `volume=${(clip.volume ?? 1) * trackGain}`;
|
|
3563
|
+
const ramp = clip.type === "media" ? clip.timeRemap : void 0;
|
|
3564
|
+
const ai = compound ? -1 : index ?? input(clip, asset, lead, tail);
|
|
3565
|
+
let source2 = compound?.sound ?? `[${ai}:a]`;
|
|
3566
|
+
if (ramp) {
|
|
3567
|
+
source2 = `[r${ak}]`;
|
|
3568
|
+
graph.push(...rampedSound(ramp, `${ai}:a`, source2, lead, clip.duration + tail));
|
|
3569
|
+
ramped.add("sound");
|
|
3570
|
+
}
|
|
3571
|
+
const chain2 = [...ramp ? [] : ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip))], volume];
|
|
3572
|
+
const before2 = clip.transition ? videoClipBefore(track, clip) : null;
|
|
3573
|
+
const next2 = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition);
|
|
3574
|
+
const inCurve = clip.transition ? afadeCurve(videoTransitionAudioCurve(clip.transition)) : "";
|
|
3575
|
+
const outCurve = next2 ? afadeCurve(videoTransitionAudioCurve(next2.transition)) : "";
|
|
3576
|
+
if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}${inCurve}`);
|
|
3577
|
+
if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}${outCurve}`);
|
|
3578
|
+
if (before2 && clip.transition.type === "dip") chain2.push(`afade=t=in:st=0:d=${sec(clip.transition.duration / 2)}${inCurve}`);
|
|
3579
|
+
if (next2?.transition.type === "dip") chain2.push(`afade=t=out:st=${sec(span - next2.transition.duration / 2)}:d=${sec(next2.transition.duration / 2)}${outCurve}`);
|
|
3580
|
+
if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}${afadeCurve(clip.fadeInCurve)}`);
|
|
3581
|
+
if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}${afadeCurve(clip.fadeOutCurve)}`);
|
|
3582
|
+
chain2.push(`adelay=${Math.round(t0)}:all=1`);
|
|
3583
|
+
graph.push(`${source2}${chain2.join(",")}[a${ak}]`);
|
|
3584
|
+
sounds.push(`[a${ak++}]`);
|
|
3585
|
+
}
|
|
3586
|
+
if (!shows || compound && !compound.picture) continue;
|
|
3587
|
+
const enable = `enable='between(t,${sec(t0)},${sec(t0 + span)})'`;
|
|
3588
|
+
const next = `v${k++}`;
|
|
3589
|
+
const onTimeline = `(t*1000-${Math.round(clip.start)})`;
|
|
3590
|
+
const onChain = (v) => `(${v}*1000${lead ? `-${Math.round(lead)}` : ""})`;
|
|
3591
|
+
const tf = clip.transform;
|
|
3592
|
+
const moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(tf?.[p]));
|
|
3593
|
+
if (clip.type === "text" && options.titleImage) {
|
|
3594
|
+
inputs.push(["-loop", "1", "-framerate", String(fps), "-t", sec(span), "-i", options.titleImage(moving ? { ...clip, transform: void 0 } : clip, { width: project.width, height: project.height })]);
|
|
3595
|
+
index = inputs.length - 1;
|
|
3596
|
+
} else if (clip.type === "text") {
|
|
3597
|
+
usesDrawtext = true;
|
|
3598
|
+
if (scope.within.length) notes.push(`Title "${clip.id}" is inside a compound clip and drawn with drawtext, which leaves the sequence's transparent areas transparent, so it shows only over pictures in the sequence; pass titleImage to draw it anywhere.`);
|
|
3599
|
+
const op = isVideoKeyframes(clip.opacity) ? `clip(${expr(clip.opacity, onTimeline, 1, "opacity")},0,1)` : String(clip.opacity ?? 1);
|
|
3600
|
+
const alpha = [op, clip.fadeIn ? `min(1,(t-${sec(clip.start)})/${sec(clip.fadeIn)})` : "", clip.fadeOut ? `min(1,(${sec(end)}-t)/${sec(clip.fadeOut)})` : ""].filter(Boolean).join("*");
|
|
3601
|
+
const coord = (axis) => {
|
|
3602
|
+
const v = tf?.[axis];
|
|
3603
|
+
const base2 = `(${axis === "x" ? "w-text_w" : "h-text_h"})/2`;
|
|
3604
|
+
return isVideoKeyframes(v) ? `${axis}='${base2}+(${expr(v, onTimeline, 0, "position")})'` : `${axis}=${base2}+${v ?? 0}`;
|
|
3605
|
+
};
|
|
3606
|
+
const fontSize = clip.fontSize ?? 64;
|
|
3607
|
+
const opts = [
|
|
3608
|
+
`text=${ffmpegFilterValue(clip.text)}`,
|
|
3609
|
+
options.fontFile ? `fontfile=${ffmpegFilterValue(options.fontFile)}` : `font=${ffmpegFilterValue(clip.fontFamily ?? "sans-serif")}`,
|
|
3610
|
+
isVideoKeyframes(tf?.scale) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, "scale")})'` : `fontsize=${Math.round(fontSize * (tf?.scale ?? 1))}`,
|
|
3611
|
+
`fontcolor=${color(clip.color ?? "#ffffff")}`,
|
|
3612
|
+
coord("x"),
|
|
3613
|
+
coord("y"),
|
|
3614
|
+
`alpha='${alpha}'`,
|
|
3615
|
+
...clip.background ? ["box=1", `boxcolor=${color(clip.background)}`, `boxborderw=${Math.round((clip.fontSize ?? 64) / 2)}`] : [],
|
|
3616
|
+
`enable='between(t,${sec(clip.start)},${sec(end)})'`
|
|
3617
|
+
];
|
|
3618
|
+
if (tf?.rotation) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
|
|
3619
|
+
if (videoGradeFfmpegFilters(clip.grade).length || normalizeVideoGradeLut(clip.grade?.lut)) notes.push(`Title "${clip.id}" is drawn without its grade (drawtext draws straight onto the picture); pass titleImage to grade titles.`);
|
|
3620
|
+
if (clip.blend && clip.blend !== "normal") notes.push(`Title "${clip.id}" is drawn without its blend mode (drawtext draws straight onto the picture); pass titleImage to blend titles.`);
|
|
3621
|
+
graph.push(`[${cur2}]drawtext=${opts.join(":")}[${next}]`);
|
|
3622
|
+
cur2 = next;
|
|
3623
|
+
continue;
|
|
3624
|
+
}
|
|
3625
|
+
const size = asset ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
|
|
3626
|
+
const placed = clip.type === "text" && !moving ? void 0 : tf;
|
|
3627
|
+
const still = Object.fromEntries(Object.entries(placed ?? {}).filter(([, v]) => !isVideoKeyframes(v)));
|
|
3628
|
+
const rect = placeVideoRect(project, size, clip.type === "text" ? "fill" : clip.fit ?? (clip.type === "media" || clip.type === "sequence" ? "contain" : "fill"), still);
|
|
3629
|
+
const chain = [];
|
|
3630
|
+
let source;
|
|
3631
|
+
if (clip.type === "color") source = `color=c=${color(clip.color)}:s=${even(rect.width)}x${even(rect.height)}:r=${fps}:d=${sec(span)}`;
|
|
3632
|
+
else if (compound?.picture) {
|
|
3633
|
+
source = compound.picture;
|
|
3634
|
+
chain.push(`scale=${even(rect.width)}:${even(rect.height)}`);
|
|
3635
|
+
} else {
|
|
3636
|
+
source = `[${index}:v]`;
|
|
3637
|
+
if (clip.type === "media" && clip.timeRemap) chain.push(...rampedPicture(clip.timeRemap, lead, clip.duration + tail), `scale=${even(rect.width)}:${even(rect.height)}`);
|
|
3638
|
+
else chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
|
|
3639
|
+
}
|
|
3640
|
+
const grade = videoGradeFfmpegFilters(clip.grade);
|
|
3641
|
+
if (grade.length) chain.push("format=rgba", ...grade);
|
|
3642
|
+
const lut = normalizeVideoGradeLut(clip.grade?.lut);
|
|
3643
|
+
if (lut) {
|
|
3644
|
+
chain.push("format=gbrap");
|
|
3645
|
+
const path = options.resolveLut ? options.resolveLut(lut.src) : lut.src;
|
|
3646
|
+
if (/^(blob|data|https?):/i.test(path)) notes.push(`ffmpeg's lut3d reads only local files, and clip "${clip.id}"'s LUT is "${path.slice(0, 80)}"; save it and pass resolveLut to give its path.`);
|
|
3647
|
+
const file = ffmpegFilterValue(path);
|
|
3648
|
+
const into2 = `q${next}`;
|
|
3649
|
+
graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[${into2}]`);
|
|
3650
|
+
const lut3d = `lut3d=file=${file}:interp=tetrahedral`;
|
|
3651
|
+
if ((lut.strength ?? 1) >= 1) graph.push(`[${into2}]${lut3d}[r${next}]`);
|
|
3652
|
+
else graph.push(`[${into2}]split[o${next}][p${next}]`, `[p${next}]${lut3d}[s${next}]`, `[s${next}][o${next}]blend=all_mode=normal:all_opacity=${lut.strength}[r${next}]`);
|
|
3653
|
+
source = `[r${next}]`;
|
|
3654
|
+
chain.length = 0;
|
|
3655
|
+
}
|
|
3656
|
+
chain.push("format=yuva420p");
|
|
3657
|
+
if (isVideoKeyframes(clip.opacity)) chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='alpha(X,Y)*clip(${expr(clip.opacity, onChain("T"), 1, "opacity")},0,1)'`);
|
|
3658
|
+
else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
|
|
3659
|
+
if (clip.fadeIn) chain.push(`fade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}:alpha=1`);
|
|
3660
|
+
if (clip.fadeOut) chain.push(`fade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}:alpha=1`);
|
|
3661
|
+
if (lead && clip.transition.type === "crossfade") chain.push(`fade=t=in:st=0:d=${sec(2 * lead)}:alpha=1`);
|
|
3662
|
+
if (lead && clip.transition.type === "wipe") {
|
|
3663
|
+
const d = sec(2 * lead);
|
|
3664
|
+
const p = `min(1,T/${d})`;
|
|
3665
|
+
const dir = clip.transition.direction ?? "right";
|
|
3666
|
+
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}))`;
|
|
3667
|
+
chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
|
|
3668
|
+
}
|
|
3669
|
+
if (lead && clip.transition.type === "iris") {
|
|
3670
|
+
const r = videoIrisRadius({ width: W, height: H }, 1);
|
|
3671
|
+
chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(lte(hypot(X+${Math.round(rect.x)}-${W / 2},Y+${Math.round(rect.y)}-${H / 2}),${Math.round(r)}*min(1,T/${sec(2 * lead)})),alpha(X,Y),0)'`);
|
|
3672
|
+
}
|
|
3673
|
+
const rotation = placed?.rotation;
|
|
3674
|
+
if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=black@0:ow='hypot(iw,ih)':oh=ow`);
|
|
3675
|
+
else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=black@0:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
|
|
3676
|
+
if (isVideoKeyframes(placed?.scale)) {
|
|
3677
|
+
const s = expr(placed.scale, onChain("t"), 1, "scale");
|
|
3678
|
+
chain.push(`scale=w='max(2,trunc(iw*(${s})/2)*2)':h='max(2,trunc(ih*(${s})/2)*2)':eval=frame`);
|
|
3679
|
+
}
|
|
3680
|
+
chain.push(`setpts=PTS+${sec(t0)}/TB`);
|
|
3681
|
+
const cx = rect.x + rect.width / 2;
|
|
3682
|
+
const cy = rect.y + rect.height / 2;
|
|
3683
|
+
const shifts = { x: [], y: [] };
|
|
3684
|
+
const shiftBy = (direction, from, d, entering) => {
|
|
3685
|
+
const dir = direction ?? "right";
|
|
3686
|
+
const axis = dir === "left" || dir === "right" ? "x" : "y";
|
|
3687
|
+
const size2 = axis === "x" ? W : H;
|
|
3688
|
+
const sign = dir === "left" || dir === "up" ? -1 : 1;
|
|
3689
|
+
const ease = `(1-cos(PI*clip((t-${sec(from)})/${sec(d)},0,1)))/2`;
|
|
3690
|
+
shifts[axis].push(`${sign * size2}*(${ease}${entering ? "-1" : ""})`);
|
|
3406
3691
|
};
|
|
3407
|
-
const
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
if (/^(blob|data|https?):/i.test(path)) notes.push(`ffmpeg's lut3d reads only local files, and clip "${clip.id}"'s LUT is "${path.slice(0, 80)}"; save it and pass resolveLut to give its path.`);
|
|
3445
|
-
const file = ffmpegFilterValue(path);
|
|
3446
|
-
const into2 = `q${next}`;
|
|
3447
|
-
graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[${into2}]`);
|
|
3448
|
-
const lut3d = `lut3d=file=${file}:interp=tetrahedral`;
|
|
3449
|
-
if ((lut.strength ?? 1) >= 1) graph.push(`[${into2}]${lut3d}[r${next}]`);
|
|
3450
|
-
else graph.push(`[${into2}]split[o${next}][p${next}]`, `[p${next}]${lut3d}[s${next}]`, `[s${next}][o${next}]blend=all_mode=normal:all_opacity=${lut.strength}[r${next}]`);
|
|
3451
|
-
source = `[r${next}]`;
|
|
3452
|
-
chain.length = 0;
|
|
3453
|
-
}
|
|
3454
|
-
chain.push("format=yuva420p");
|
|
3455
|
-
if (isVideoKeyframes(clip.opacity)) chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='alpha(X,Y)*clip(${expr(clip.opacity, onChain("T"), 1, "opacity")},0,1)'`);
|
|
3456
|
-
else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
|
|
3457
|
-
if (clip.fadeIn) chain.push(`fade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}:alpha=1`);
|
|
3458
|
-
if (clip.fadeOut) chain.push(`fade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}:alpha=1`);
|
|
3459
|
-
if (lead && clip.transition.type === "crossfade") chain.push(`fade=t=in:st=0:d=${sec(2 * lead)}:alpha=1`);
|
|
3460
|
-
if (lead && clip.transition.type === "wipe") {
|
|
3461
|
-
const d = sec(2 * lead);
|
|
3462
|
-
const p = `min(1,T/${d})`;
|
|
3463
|
-
const dir = clip.transition.direction ?? "right";
|
|
3464
|
-
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}))`;
|
|
3465
|
-
chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
|
|
3466
|
-
}
|
|
3467
|
-
if (lead && clip.transition.type === "iris") {
|
|
3468
|
-
const r = videoIrisRadius({ width: W, height: H }, 1);
|
|
3469
|
-
chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(lte(hypot(X+${Math.round(rect.x)}-${W / 2},Y+${Math.round(rect.y)}-${H / 2}),${Math.round(r)}*min(1,T/${sec(2 * lead)})),alpha(X,Y),0)'`);
|
|
3470
|
-
}
|
|
3471
|
-
const rotation = placed?.rotation;
|
|
3472
|
-
if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=black@0:ow='hypot(iw,ih)':oh=ow`);
|
|
3473
|
-
else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=black@0:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
|
|
3474
|
-
if (isVideoKeyframes(placed?.scale)) {
|
|
3475
|
-
const s = expr(placed.scale, onChain("t"), 1, "scale");
|
|
3476
|
-
chain.push(`scale=w='max(2,trunc(iw*(${s})/2)*2)':h='max(2,trunc(ih*(${s})/2)*2)':eval=frame`);
|
|
3477
|
-
}
|
|
3478
|
-
chain.push(`setpts=PTS+${sec(t0)}/TB`);
|
|
3479
|
-
const cx = rect.x + rect.width / 2;
|
|
3480
|
-
const cy = rect.y + rect.height / 2;
|
|
3481
|
-
const shifts = { x: [], y: [] };
|
|
3482
|
-
const shiftBy = (direction, from, d, entering) => {
|
|
3483
|
-
const dir = direction ?? "right";
|
|
3484
|
-
const axis = dir === "left" || dir === "right" ? "x" : "y";
|
|
3485
|
-
const size2 = axis === "x" ? W : H;
|
|
3486
|
-
const sign = dir === "left" || dir === "up" ? -1 : 1;
|
|
3487
|
-
const ease = `(1-cos(PI*clip((t-${sec(from)})/${sec(d)},0,1)))/2`;
|
|
3488
|
-
shifts[axis].push(`${sign * size2}*(${ease}${entering ? "-1" : ""})`);
|
|
3489
|
-
};
|
|
3490
|
-
const into = lead ? clip.transition.type : void 0;
|
|
3491
|
-
if (into === "push" || into === "slide") shiftBy(clip.transition.direction, clip.start - lead, 2 * lead, true);
|
|
3492
|
-
const after = tail ? sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition) : void 0;
|
|
3493
|
-
if (after?.transition.type === "push") shiftBy(after.transition.direction, end - tail, 2 * tail, false);
|
|
3494
|
-
const at = (axis, c) => {
|
|
3495
|
-
const v = placed?.[axis];
|
|
3496
|
-
const extra = shifts[axis].map((s) => `+${s}`).join("");
|
|
3497
|
-
return isVideoKeyframes(v) ? `${axis}='${Math.round(axis === "x" ? project.width / 2 : project.height / 2)}+(${expr(v, onTimeline, 0, "position")})-${axis === "x" ? "w" : "h"}/2${extra}'` : `${axis}='${Math.round(c)}-${axis === "x" ? "w" : "h"}/2${extra}'`;
|
|
3498
|
-
};
|
|
3499
|
-
graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
|
|
3500
|
-
if (clip.blend && clip.blend !== "normal") {
|
|
3501
|
-
graph.push(`color=c=black@0:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[t${next}]`);
|
|
3502
|
-
graph.push(`[t${next}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable},format=gbrap,split[f${next}][g${next}]`);
|
|
3503
|
-
graph.push(`[${cur}]format=gbrap,split[c${next}][d${next}]`);
|
|
3504
|
-
const layerFirst = clip.blend === "color-dodge" || clip.blend === "color-burn";
|
|
3505
|
-
graph.push(`${layerFirst ? `[f${next}][c${next}]` : `[c${next}][f${next}]`}blend=all_mode=${FFMPEG_BLEND[clip.blend]}[b${next}]`);
|
|
3506
|
-
graph.push(`[g${next}]alphaextract[m${next}]`);
|
|
3507
|
-
graph.push(`[b${next}][m${next}]alphamerge[x${next}]`);
|
|
3508
|
-
graph.push(`[d${next}][x${next}]overlay=format=gbrp,format=yuva420p[${next}]`);
|
|
3509
|
-
blended.add(clip.blend);
|
|
3510
|
-
} else graph.push(`[${cur}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable}[${next}]`);
|
|
3511
|
-
cur = next;
|
|
3512
|
-
const before = clip.transition ? videoClipBefore(track, clip) : null;
|
|
3513
|
-
if (before && clip.transition.type === "dip") {
|
|
3514
|
-
const d = clip.transition.duration;
|
|
3515
|
-
const dip = `d${k}`;
|
|
3516
|
-
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}]`);
|
|
3517
|
-
graph.push(`[${cur}][l${dip}]overlay=eof_action=pass:enable='between(t,${sec(clip.start - d / 2)},${sec(clip.start + d / 2)})'[${dip}]`);
|
|
3518
|
-
cur = dip;
|
|
3692
|
+
const into = lead ? clip.transition.type : void 0;
|
|
3693
|
+
if (into === "push" || into === "slide") shiftBy(clip.transition.direction, clip.start - lead, 2 * lead, true);
|
|
3694
|
+
const after = tail ? sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition) : void 0;
|
|
3695
|
+
if (after?.transition.type === "push") shiftBy(after.transition.direction, end - tail, 2 * tail, false);
|
|
3696
|
+
const at = (axis, c) => {
|
|
3697
|
+
const v = placed?.[axis];
|
|
3698
|
+
const extra = shifts[axis].map((s) => `+${s}`).join("");
|
|
3699
|
+
return isVideoKeyframes(v) ? `${axis}='${Math.round(axis === "x" ? project.width / 2 : project.height / 2)}+(${expr(v, onTimeline, 0, "position")})-${axis === "x" ? "w" : "h"}/2${extra}'` : `${axis}='${Math.round(c)}-${axis === "x" ? "w" : "h"}/2${extra}'`;
|
|
3700
|
+
};
|
|
3701
|
+
graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
|
|
3702
|
+
if (clip.blend && clip.blend !== "normal") {
|
|
3703
|
+
graph.push(`color=c=black@0:s=${W}x${H}:r=${fps}:d=${sec(length)},format=yuva420p[t${next}]`);
|
|
3704
|
+
const clear = scope.within.length > 0;
|
|
3705
|
+
graph.push(`[t${next}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable},format=gbrap,split${clear ? `=3` : ""}[f${next}][g${next}]${clear ? `[h${next}]` : ""}`);
|
|
3706
|
+
graph.push(`[${cur2}]format=gbrap,split${clear ? `=3` : ""}[c${next}][d${next}]${clear ? `[e${next}]` : ""}`);
|
|
3707
|
+
const layerFirst = clip.blend === "color-dodge" || clip.blend === "color-burn";
|
|
3708
|
+
graph.push(`${layerFirst ? `[f${next}][c${next}]` : `[c${next}][f${next}]`}blend=all_mode=${FFMPEG_BLEND[clip.blend]}[b${next}]`);
|
|
3709
|
+
graph.push(`[g${next}]alphaextract[m${next}]`);
|
|
3710
|
+
if (clear) {
|
|
3711
|
+
graph.push(`[e${next}]alphaextract[ea${next}]`);
|
|
3712
|
+
graph.push(`[b${next}][ea${next}]alphamerge[bb${next}]`);
|
|
3713
|
+
graph.push(`[h${next}]format=gbrp[ho${next}]`);
|
|
3714
|
+
graph.push(`[ho${next}][bb${next}]overlay=format=gbrp[bm${next}]`);
|
|
3715
|
+
graph.push(`[bm${next}][m${next}]alphamerge[x${next}]`);
|
|
3716
|
+
} else graph.push(`[b${next}][m${next}]alphamerge[x${next}]`);
|
|
3717
|
+
graph.push(`[d${next}][x${next}]overlay=format=gbrp,format=yuva420p[${next}]`);
|
|
3718
|
+
blended.add(clip.blend);
|
|
3719
|
+
} else graph.push(`[${cur2}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable}[${next}]`);
|
|
3720
|
+
cur2 = next;
|
|
3721
|
+
const before = clip.transition ? videoClipBefore(track, clip) : null;
|
|
3722
|
+
if (before && clip.transition.type === "dip") {
|
|
3723
|
+
const d = clip.transition.duration;
|
|
3724
|
+
const dip = `d${k}`;
|
|
3725
|
+
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}]`);
|
|
3726
|
+
graph.push(`[${cur2}][l${dip}]overlay=eof_action=pass:enable='between(t,${sec(clip.start - d / 2)},${sec(clip.start + d / 2)})'[${dip}]`);
|
|
3727
|
+
cur2 = dip;
|
|
3728
|
+
}
|
|
3519
3729
|
}
|
|
3520
3730
|
}
|
|
3521
|
-
|
|
3731
|
+
return { video: cur2, audio: sounds };
|
|
3732
|
+
};
|
|
3733
|
+
const top = compose(project.tracks, measure ? null : "base", D, { within: [], picture: !measure, sound: true });
|
|
3734
|
+
const audio = top.audio;
|
|
3735
|
+
const cur = top.video;
|
|
3522
3736
|
if (!measure) graph.push(`[${cur}]format=yuv420p[vout]`);
|
|
3523
3737
|
if (measure && !audio.length) throw new Error("Nothing on the timeline makes a sound, so there is no loudness to measure.");
|
|
3524
3738
|
const loudness = options.loudness || measure ? `,${loudnormFilter(options.loudness, measure)}` : "";
|
|
@@ -3526,7 +3740,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
3526
3740
|
if (options.loudness && !audio.length) notes.push("Nothing makes a sound, so there was no loudness to normalise.");
|
|
3527
3741
|
else if (options.loudness && !options.loudness.measured)
|
|
3528
3742
|
notes.push("Loudness is normalised in one pass, which adjusts the level as it goes; for a fixed gain that keeps the dynamics, run toVideoLoudnessMeasure first and pass its result as loudness.measured.");
|
|
3529
|
-
if (project.
|
|
3743
|
+
if (allVideoTracks(project).some(({ track: t }) => t.clips.some((c) => c.type === "media" && assets.get(c.asset)?.kind === "video" && assets.get(c.asset)?.hasAudio === void 0)))
|
|
3530
3744
|
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.');
|
|
3531
3745
|
if (animated.size)
|
|
3532
3746
|
notes.push(`Keyframed ${[...animated].sort().join(", ")} ${animated.size > 1 ? "are" : "is"} rendered as straight lines between keys, with eased segments sampled every two frames${animated.has("scale") ? "; animated scale needs ffmpeg 5 or later (scale with eval=frame)" : ""}.`);
|
|
@@ -3643,7 +3857,7 @@ var videoSourceFrameTime = (frame, fps) => (frame + 0.5) * 1e3 / fps;
|
|
|
3643
3857
|
var videoFrameCacheKey = (src, frame) => `${frame}@${src}`;
|
|
3644
3858
|
function videoFrameRequestsAt(project, time, srcFor = (l) => l.src) {
|
|
3645
3859
|
const out = [];
|
|
3646
|
-
for (const layer of videoFrameAt(project, time).layers) {
|
|
3860
|
+
for (const layer of videoFrameLeafLayers(videoFrameAt(project, time).layers)) {
|
|
3647
3861
|
if (layer.type !== "media" || layer.assetKind !== "video") continue;
|
|
3648
3862
|
const src = srcFor(layer);
|
|
3649
3863
|
if (!src) continue;
|
|
@@ -3928,7 +4142,7 @@ var VideoPreviewEngine = class {
|
|
|
3928
4142
|
playing = false;
|
|
3929
4143
|
fps = 30;
|
|
3930
4144
|
unsubscribeLuts;
|
|
3931
|
-
|
|
4145
|
+
scratchCanvases = /* @__PURE__ */ new Map();
|
|
3932
4146
|
/** The canvas's pixels and the frame they show, or `null` when its context cannot be read. */
|
|
3933
4147
|
readPixels() {
|
|
3934
4148
|
const ctx = this.ctx;
|
|
@@ -4009,7 +4223,7 @@ var VideoPreviewEngine = class {
|
|
|
4009
4223
|
}
|
|
4010
4224
|
};
|
|
4011
4225
|
const sounding = new Map(frame.audio.map((a) => [a.clip, a]));
|
|
4012
|
-
for (const layer of frame.layers) {
|
|
4226
|
+
for (const layer of videoFrameLeafLayers(frame.layers)) {
|
|
4013
4227
|
if (layer.type !== "media" || layer.assetKind !== "video" || !layer.src) continue;
|
|
4014
4228
|
const sound = sounding.get(layer.clip);
|
|
4015
4229
|
sync(layer.clip, this.srcFor(layer), "video", layer.sourceTime ?? 0, sound?.speed ?? 1, sound ? sound.gain : null);
|
|
@@ -4077,16 +4291,18 @@ var VideoPreviewEngine = class {
|
|
|
4077
4291
|
if (!lut) this.complete = false;
|
|
4078
4292
|
return lut;
|
|
4079
4293
|
};
|
|
4080
|
-
scratch = (width, height) => {
|
|
4081
|
-
if (this.
|
|
4082
|
-
|
|
4294
|
+
scratch = (width, height, slot) => {
|
|
4295
|
+
if (!this.scratchCanvases.has(slot)) {
|
|
4296
|
+
let made = null;
|
|
4297
|
+
if (this.options.createScratch) made = this.options.createScratch(width, height, slot);
|
|
4083
4298
|
else if (typeof document !== "undefined") {
|
|
4084
4299
|
const canvas2 = document.createElement("canvas");
|
|
4085
4300
|
const ctx = canvas2.getContext("2d", { willReadFrequently: true });
|
|
4086
|
-
|
|
4087
|
-
}
|
|
4301
|
+
made = ctx ? { canvas: canvas2, ctx } : null;
|
|
4302
|
+
}
|
|
4303
|
+
this.scratchCanvases.set(slot, made);
|
|
4088
4304
|
}
|
|
4089
|
-
const s = this.
|
|
4305
|
+
const s = this.scratchCanvases.get(slot);
|
|
4090
4306
|
if (!s) return null;
|
|
4091
4307
|
const c = s.canvas;
|
|
4092
4308
|
if (c.width !== width) c.width = width;
|
|
@@ -4457,14 +4673,16 @@ var VIDEO_FADE_CURVE_LABELS = {
|
|
|
4457
4673
|
};
|
|
4458
4674
|
function videoClipTint(clip, asset) {
|
|
4459
4675
|
if (clip.type === "text") return "#a855f7";
|
|
4676
|
+
if (clip.type === "sequence") return "#0d9488";
|
|
4460
4677
|
if (clip.type === "color") return clip.color;
|
|
4461
4678
|
if (!asset) return "#ef4444";
|
|
4462
4679
|
return asset.kind === "audio" ? "#10b981" : asset.kind === "image" ? "#f59e0b" : "#3b82f6";
|
|
4463
4680
|
}
|
|
4464
|
-
function videoClipLabel(clip, asset) {
|
|
4681
|
+
function videoClipLabel(clip, asset, sequence) {
|
|
4465
4682
|
if (clip.name) return clip.name;
|
|
4466
4683
|
if (clip.type === "text") return clip.text.split("\n")[0];
|
|
4467
4684
|
if (clip.type === "color") return clip.color;
|
|
4685
|
+
if (clip.type === "sequence") return `Compound: ${sequence?.name ?? clip.sequence}`;
|
|
4468
4686
|
const base = asset?.name ?? asset?.src.replace(/^.*[\\/]/, "") ?? clip.asset;
|
|
4469
4687
|
const speed = videoClipSpeed(clip);
|
|
4470
4688
|
return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
|
|
@@ -4558,7 +4776,7 @@ var KEY_LABELS = {
|
|
|
4558
4776
|
scale: { label: "Scale", step: 0.05 },
|
|
4559
4777
|
rotation: { label: "Rotation", step: 1 }
|
|
4560
4778
|
};
|
|
4561
|
-
var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? clip.transform?.[property] : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
|
|
4779
|
+
var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? clip.transform?.[property] : property === "volume" ? clip.type === "media" || clip.type === "sequence" ? clip.volume : void 0 : clip.opacity;
|
|
4562
4780
|
var playheadInClip = (clip, time) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));
|
|
4563
4781
|
function videoKeyframeRows(project, clipId, time) {
|
|
4564
4782
|
const at = findVideoClip(project, clipId);
|
|
@@ -4566,7 +4784,7 @@ function videoKeyframeRows(project, clipId, time) {
|
|
|
4566
4784
|
const { clip } = at;
|
|
4567
4785
|
const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
|
|
4568
4786
|
const visual = !(asset?.kind === "audio" || at.track.kind === "audio");
|
|
4569
|
-
const audible = clip.type === "media" && videoAssetHasSound(asset);
|
|
4787
|
+
const audible = clip.type === "media" && videoAssetHasSound(asset) || clip.type === "sequence";
|
|
4570
4788
|
const properties = [...visual ? ["opacity"] : [], ...audible ? ["volume"] : [], ...visual ? VIDEO_TRANSFORM_PROPERTIES : []];
|
|
4571
4789
|
const clipTime = playheadInClip(clip, time);
|
|
4572
4790
|
return properties.map((property) => {
|
|
@@ -4799,6 +5017,34 @@ var videoAssetOut = (a) => {
|
|
|
4799
5017
|
const { transcript, ...rest } = a;
|
|
4800
5018
|
return transcript ? { ...rest, transcriptWords: transcript.length } : rest;
|
|
4801
5019
|
};
|
|
5020
|
+
var describeVideoTracks = (tracks) => tracks.map((t, index) => ({
|
|
5021
|
+
id: t.id,
|
|
5022
|
+
kind: t.kind,
|
|
5023
|
+
index,
|
|
5024
|
+
...t.name ? { name: t.name } : {},
|
|
5025
|
+
...t.muted ? { muted: true } : {},
|
|
5026
|
+
...t.hidden ? { hidden: true } : {},
|
|
5027
|
+
...t.locked ? { locked: true } : {},
|
|
5028
|
+
...t.syncLocked === false ? { syncLocked: false } : {},
|
|
5029
|
+
...t.volume !== void 0 ? { volume: t.volume } : {},
|
|
5030
|
+
clips: sortedVideoClips(t).map((c) => ({ ...c, end: videoClipEnd(c), ...c.type === "media" || c.type === "sequence" ? { source: videoClipSourceRange(c) } : {} }))
|
|
5031
|
+
}));
|
|
5032
|
+
var videoSequenceUsers = (project, id) => allVideoTracks(project).flatMap(
|
|
5033
|
+
({ sequence, track }) => track.clips.flatMap((c) => c.type === "sequence" && c.sequence === id ? [{ clip: c.id, ...sequence ? { inSequence: sequence } : {}, track: track.id, start: c.start, end: videoClipEnd(c), source: videoClipSourceRange(c) }] : [])
|
|
5034
|
+
);
|
|
5035
|
+
function describeVideoSequence(project, sequence, tracks = true) {
|
|
5036
|
+
const cycle = findVideoSequenceCycle(project, sequence.id);
|
|
5037
|
+
return {
|
|
5038
|
+
id: sequence.id,
|
|
5039
|
+
...sequence.name ? { name: sequence.name } : {},
|
|
5040
|
+
duration: videoSequenceDuration(sequence),
|
|
5041
|
+
clipCount: sequence.tracks.reduce((n2, t) => n2 + t.clips.length, 0),
|
|
5042
|
+
nests: videoSequenceChildren(sequence),
|
|
5043
|
+
usedBy: videoSequenceUsers(project, sequence.id),
|
|
5044
|
+
...cycle ? { cycle } : {},
|
|
5045
|
+
...tracks ? { tracks: describeVideoTracks(sequence.tracks) } : { trackCount: sequence.tracks.length }
|
|
5046
|
+
};
|
|
5047
|
+
}
|
|
4802
5048
|
function describeVideoProject(project) {
|
|
4803
5049
|
return {
|
|
4804
5050
|
...project.name ? { name: project.name } : {},
|
|
@@ -4808,18 +5054,8 @@ function describeVideoProject(project) {
|
|
|
4808
5054
|
duration: videoProjectDuration(project),
|
|
4809
5055
|
...project.background ? { background: project.background } : {},
|
|
4810
5056
|
assets: project.assets.map(videoAssetOut),
|
|
4811
|
-
tracks: project.tracks
|
|
4812
|
-
|
|
4813
|
-
kind: t.kind,
|
|
4814
|
-
index,
|
|
4815
|
-
...t.name ? { name: t.name } : {},
|
|
4816
|
-
...t.muted ? { muted: true } : {},
|
|
4817
|
-
...t.hidden ? { hidden: true } : {},
|
|
4818
|
-
...t.locked ? { locked: true } : {},
|
|
4819
|
-
...t.syncLocked === false ? { syncLocked: false } : {},
|
|
4820
|
-
...t.volume !== void 0 ? { volume: t.volume } : {},
|
|
4821
|
-
clips: sortedVideoClips(t).map((c) => ({ ...c, end: videoClipEnd(c), ...c.type === "media" ? { source: videoClipSourceRange(c) } : {} }))
|
|
4822
|
-
})),
|
|
5057
|
+
tracks: describeVideoTracks(project.tracks),
|
|
5058
|
+
...project.sequences?.length ? { sequences: project.sequences.map((q) => describeVideoSequence(project, q, false)) } : {},
|
|
4823
5059
|
markers: project.markers ?? []
|
|
4824
5060
|
};
|
|
4825
5061
|
}
|
|
@@ -4841,7 +5077,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4841
5077
|
const clipOut = (p, id) => {
|
|
4842
5078
|
for (const t of p.tracks) {
|
|
4843
5079
|
const c = t.clips.find((x) => x.id === id);
|
|
4844
|
-
if (c) return { track: t.id, ...c, end: videoClipEnd(c), ...c.type === "media" ? { source: videoClipSourceRange(c) } : {} };
|
|
5080
|
+
if (c) return { track: t.id, ...c, end: videoClipEnd(c), ...c.type === "media" || c.type === "sequence" ? { source: videoClipSourceRange(c) } : {} };
|
|
4845
5081
|
}
|
|
4846
5082
|
return null;
|
|
4847
5083
|
};
|
|
@@ -4859,7 +5095,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4859
5095
|
const getProject = {
|
|
4860
5096
|
name: "get_video_project",
|
|
4861
5097
|
title: "Read the video project",
|
|
4862
|
-
description:
|
|
5098
|
+
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 and compound clips, fades, transition), a summary of each nested sequence (id, name, length, clip count, the sequences it nests, the compound clips that play it) and markers, plus the playhead, selection, issues and whether it can be edited. Compound clips (type "sequence") play a sequence's tracks as one picture and sound; list_video_sequences reads what is inside them.`,
|
|
4863
5099
|
inputSchema: object({}),
|
|
4864
5100
|
annotations: { title: "Read the video project", ...READ },
|
|
4865
5101
|
handler: () => {
|
|
@@ -4871,7 +5107,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4871
5107
|
const getFrame = {
|
|
4872
5108
|
name: "get_video_frame",
|
|
4873
5109
|
title: "What plays at a moment",
|
|
4874
|
-
description:
|
|
5110
|
+
description: `Returns what plays at a time without changing anything: the pictures back to front (clip, track, asset, source time in ms, opacity, placement rect, rotation, any wipe or iris) and the sounds (clip, source time, gain after volumes, fades and transitions). A compound clip is one picture with its sequence, the moment of the sequence it shows (sourceTime) and the sequence's own pictures as "layers"; its sounds are listed with clip paths such as "compound/inner".`,
|
|
4875
5111
|
inputSchema: object({ time: MS("Time from the start (default: the playhead)") }),
|
|
4876
5112
|
annotations: { title: "What plays at a moment", ...READ },
|
|
4877
5113
|
handler: ({ time }) => {
|
|
@@ -4879,6 +5115,23 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4879
5115
|
return { ...frame, timecode: formatVideoTimecode(frame.time, project().fps) };
|
|
4880
5116
|
}
|
|
4881
5117
|
};
|
|
5118
|
+
const listSequences = {
|
|
5119
|
+
name: "list_video_sequences",
|
|
5120
|
+
title: "List nested sequences",
|
|
5121
|
+
description: `Lists the project's nested sequences, the tracks that compound clips (type "sequence") play as one picture and sound, without changing anything. For each: id, name, length (ms), the sequences it nests, the compound clips that play it (clip id, the track and sequence they sit on, timeline start and end, and the stretch of the sequence they play as source in / out) and its tracks with their clips, in the same shape as get_video_project. A sequence that contains itself lists the loop as "cycle". Clips inside sequences can be read here but not edited yet; edit the compound clip instead (move, trim, its opacity, blend, grade, volume).`,
|
|
5122
|
+
inputSchema: object({ sequence: { type: "string", minLength: 1, description: "Only this sequence id (default: every sequence)." } }),
|
|
5123
|
+
annotations: { title: "List nested sequences", ...READ },
|
|
5124
|
+
handler: ({ sequence }) => {
|
|
5125
|
+
const p = project();
|
|
5126
|
+
const all = p.sequences ?? [];
|
|
5127
|
+
if (sequence !== void 0) {
|
|
5128
|
+
const found = all.find((q) => q.id === sequence);
|
|
5129
|
+
if (!found) throw new Error(`There is no sequence "${sequence}"; ${all.length ? `the sequences are ${all.map((q) => `"${q.id}"`).join(", ")}` : "the project has no sequences"}.`);
|
|
5130
|
+
return { sequences: [describeVideoSequence(p, found)] };
|
|
5131
|
+
}
|
|
5132
|
+
return { sequences: all.map((q) => describeVideoSequence(p, q)) };
|
|
5133
|
+
}
|
|
5134
|
+
};
|
|
4882
5135
|
const findGaps = {
|
|
4883
5136
|
name: "find_video_gaps",
|
|
4884
5137
|
title: "Find gaps",
|
|
@@ -5129,7 +5382,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
5129
5382
|
const updateClip = {
|
|
5130
5383
|
name: "update_video_clip",
|
|
5131
5384
|
title: "Change a clip",
|
|
5132
|
-
description: `Changes a clip's properties (one undo step): opacity, transform, fit, blend mode, colour grade, entrance and exit animations, fades, name; for media volume, mute, the sound's fade curves, speed (keeps the stretch of source, so the length changes) or asset; for titles text and style; for colours the colour. Fields in "remove" go back to their defaults. Use move / trim for timing. Returns the clip.`,
|
|
5385
|
+
description: `Changes a clip's properties (one undo step): opacity, transform, fit, blend mode, colour grade, entrance and exit animations, fades, name; for media volume, mute, the sound's fade curves, speed (keeps the stretch of source, so the length changes) or asset; for compound clips in point, volume, mute and fade curves (no speed); 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.`,
|
|
5133
5386
|
inputSchema: object(
|
|
5134
5387
|
{
|
|
5135
5388
|
clip: CLIP_ID,
|
|
@@ -5142,7 +5395,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
5142
5395
|
handler: ({ clip, set, remove }) => {
|
|
5143
5396
|
const current = clipOut(project(), clip);
|
|
5144
5397
|
if (current && set) {
|
|
5145
|
-
const allowed = /* @__PURE__ */ new Set(["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", ...current.type === "media" ? ["in", "speed", "volume", "muted", "asset", "fadeInCurve", "fadeOutCurve"] : current.type === "text" ? Object.keys(TEXT_FIELDS) : ["color"]]);
|
|
5398
|
+
const allowed = /* @__PURE__ */ new Set(["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", ...current.type === "media" ? ["in", "speed", "volume", "muted", "asset", "fadeInCurve", "fadeOutCurve"] : current.type === "sequence" ? ["in", "volume", "muted", "fadeInCurve", "fadeOutCurve"] : current.type === "text" ? Object.keys(TEXT_FIELDS) : ["color"]]);
|
|
5146
5399
|
const wrong = Object.keys(set).filter((k) => !allowed.has(k));
|
|
5147
5400
|
if (wrong.length) throw new Error(`${wrong.map((k) => `"${k}"`).join(", ")} cannot be set on a ${current.type} clip; it takes ${[...allowed].join(", ")}.`);
|
|
5148
5401
|
}
|
|
@@ -5416,6 +5669,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
5416
5669
|
const at = findVideoClip(project(), id);
|
|
5417
5670
|
if (!at) throw new Error(`There is no clip "${id}"; get_video_project lists the clips.`);
|
|
5418
5671
|
const { clip } = at;
|
|
5672
|
+
if (clip.type === "sequence") throw new Error(`Clip "${id}" is a compound clip, which plays its sequence at normal speed; change the speed of the clips inside the sequence instead.`);
|
|
5419
5673
|
if (clip.type !== "media") throw new Error(`Clip "${id}" is a ${clip.type === "text" ? "title" : "colour"}, so it has no source to change the speed of; use a video or audio clip.`);
|
|
5420
5674
|
if (project().assets.find((a) => a.id === clip.asset)?.kind === "image") throw new Error(`Clip "${id}" is a still, so it has no motion to change the speed of.`);
|
|
5421
5675
|
return clip;
|
|
@@ -5651,6 +5905,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
5651
5905
|
const found = findVideoClip(p, args.clip);
|
|
5652
5906
|
if (!found) throw new Error(`There is no clip "${args.clip}"; get_video_project lists the clips.`);
|
|
5653
5907
|
const c = found.clip;
|
|
5908
|
+
if (c.type === "sequence") throw new Error(`Clip "${c.id}" is a compound clip; its sound is the mix of its sequence "${c.sequence}", so read the levels of the media in it (list_video_sequences lists its clips and assets).`);
|
|
5654
5909
|
if (c.type !== "media") throw new Error(`Clip "${c.id}" is a ${c.type} clip and has no sound; pass a media clip.`);
|
|
5655
5910
|
asset = p.assets.find((a) => a.id === c.asset);
|
|
5656
5911
|
const speed = c.speed ?? 1;
|
|
@@ -5745,7 +6000,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
5745
6000
|
return { time: t, timecode: formatVideoTimecode(t, p.fps), width: pixels.width, height: pixels.height, ...summarizeVideoScopes(pixels, { bins, columns }) };
|
|
5746
6001
|
}
|
|
5747
6002
|
};
|
|
5748
|
-
const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, previewCache, planProxies, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek, setPreviewScale];
|
|
6003
|
+
const reads = [getProject, listSequences, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, previewCache, planProxies, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek, setPreviewScale];
|
|
5749
6004
|
const writes = [setSettings, addAsset, updateAsset, removeAsset, setProxies, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setSpeedRamp, addFreeze, removeRamp, setTranscript, removeRanges, applyGradePreset, copyGrade, setLut, removeLut, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
|
|
5750
6005
|
return {
|
|
5751
6006
|
id: options.id ?? "video",
|
|
@@ -5803,6 +6058,7 @@ export {
|
|
|
5803
6058
|
VideoPreviewEngine,
|
|
5804
6059
|
VideoPreviewPixels,
|
|
5805
6060
|
allVideoClipIds,
|
|
6061
|
+
allVideoTracks,
|
|
5806
6062
|
applyVideoEdits,
|
|
5807
6063
|
applyVideoGrade,
|
|
5808
6064
|
applyVideoLut,
|
|
@@ -5815,12 +6071,15 @@ export {
|
|
|
5815
6071
|
createVideoProject,
|
|
5816
6072
|
decodeVideoAudioPeaks,
|
|
5817
6073
|
describeVideoProject,
|
|
6074
|
+
describeVideoSequence,
|
|
5818
6075
|
drawVideoFrame,
|
|
5819
6076
|
ffmpegFilterValue,
|
|
5820
6077
|
findVideoAudioSilences,
|
|
5821
6078
|
findVideoClip,
|
|
5822
6079
|
findVideoFillers,
|
|
5823
6080
|
findVideoGaps,
|
|
6081
|
+
findVideoSequenceClip,
|
|
6082
|
+
findVideoSequenceCycle,
|
|
5824
6083
|
findVideoTranscriptPauses,
|
|
5825
6084
|
findVideoTranscriptText,
|
|
5826
6085
|
formatVideoSeconds,
|
|
@@ -5846,6 +6105,7 @@ export {
|
|
|
5846
6105
|
parseVideoMarker,
|
|
5847
6106
|
parseVideoPreviewScale,
|
|
5848
6107
|
parseVideoProject,
|
|
6108
|
+
parseVideoSequence,
|
|
5849
6109
|
parseVideoTimeRemap,
|
|
5850
6110
|
parseVideoTrack,
|
|
5851
6111
|
parseVideoTranscript,
|
|
@@ -5912,6 +6172,7 @@ export {
|
|
|
5912
6172
|
videoFrameAt,
|
|
5913
6173
|
videoFrameCacheKey,
|
|
5914
6174
|
videoFrameDuration,
|
|
6175
|
+
videoFrameLeafLayers,
|
|
5915
6176
|
videoFrameRequestsAt,
|
|
5916
6177
|
videoFrameToSvg,
|
|
5917
6178
|
videoFrames,
|
|
@@ -5956,6 +6217,9 @@ export {
|
|
|
5956
6217
|
videoRemoveRangesEdits,
|
|
5957
6218
|
videoRulerTicks,
|
|
5958
6219
|
videoScopeImage,
|
|
6220
|
+
videoSequenceChildren,
|
|
6221
|
+
videoSequenceDuration,
|
|
6222
|
+
videoSequenceMap,
|
|
5959
6223
|
videoShellQuote,
|
|
5960
6224
|
videoSlowMotionRamp,
|
|
5961
6225
|
videoSnapPoints,
|