@pixerate/editor 0.90.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 +1107 -351
- package/dist/video-editor/index.cjs.map +1 -1
- package/dist/video-editor/index.d.cts +754 -28
- package/dist/video-editor/index.d.ts +754 -28
- package/dist/video-editor/index.js +1025 -269
- package/dist/video-editor/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/pixerate-video-editor/SKILL.md +35 -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"];
|
|
@@ -852,12 +852,33 @@ function parseVideoAsset(v, path = "asset") {
|
|
|
852
852
|
height: num(v.height, `${path}.height`, { optional: true, positive: true }),
|
|
853
853
|
hasAudio: bool(v.hasAudio, `${path}.hasAudio`),
|
|
854
854
|
poster: str(v.poster, `${path}.poster`, true),
|
|
855
|
-
transcript: parseVideoTranscriptWords(v.transcript, `${path}.transcript`)
|
|
855
|
+
transcript: parseVideoTranscriptWords(v.transcript, `${path}.transcript`),
|
|
856
|
+
proxies: parseVideoAssetProxies(v.proxies, `${path}.proxies`)
|
|
856
857
|
});
|
|
858
|
+
if (kind !== "video" && asset.proxies) fail(`${path}.proxies`, "are only for video assets");
|
|
857
859
|
if (kind === "image" && asset.transcript) fail(`${path}.transcript`, "is only for video and audio assets");
|
|
858
860
|
if (kind === "image" && asset.duration !== void 0) fail(`${path}.duration`, "is only for video and audio assets");
|
|
859
861
|
return asset;
|
|
860
862
|
}
|
|
863
|
+
function parseVideoAssetProxies(v, path) {
|
|
864
|
+
if (v === void 0) return void 0;
|
|
865
|
+
if (!Array.isArray(v)) return fail(path, "must be a list of { scale, src }");
|
|
866
|
+
const out = v.map((p, i) => {
|
|
867
|
+
const at = `${path}[${i}]`;
|
|
868
|
+
if (!isObject(p)) return fail(at, "must be an object { scale, src }");
|
|
869
|
+
const unknown = Object.keys(p).find((k) => !["scale", "src", "width", "height"].includes(k));
|
|
870
|
+
if (unknown) fail(`${at}.${unknown}`, "is not a proxy setting; use scale, src, width, height");
|
|
871
|
+
const scale = num(p.scale, `${at}.scale`, { positive: true });
|
|
872
|
+
if (scale >= 1) fail(`${at}.scale`, "must be less than 1 (0.5 is half size)");
|
|
873
|
+
return compact({ scale, src: str(p.src, `${at}.src`), width: num(p.width, `${at}.width`, { optional: true, positive: true }), height: num(p.height, `${at}.height`, { optional: true, positive: true }) });
|
|
874
|
+
});
|
|
875
|
+
const scales = /* @__PURE__ */ new Set();
|
|
876
|
+
for (const [i, p] of out.entries()) {
|
|
877
|
+
if (scales.has(p.scale)) fail(`${path}[${i}].scale`, `repeats ${p.scale}; give each proxy its own scale`);
|
|
878
|
+
scales.add(p.scale);
|
|
879
|
+
}
|
|
880
|
+
return out.length ? out : void 0;
|
|
881
|
+
}
|
|
861
882
|
function parseVideoClip(v, path = "clip") {
|
|
862
883
|
if (!isObject(v)) return fail(path, "must be an object");
|
|
863
884
|
const type = oneOf(v.type ?? "media", VIDEO_CLIP_TYPES, `${path}.type`, false);
|
|
@@ -890,6 +911,20 @@ function parseVideoClip(v, path = "clip") {
|
|
|
890
911
|
fadeInCurve: oneOf(v.fadeInCurve, VIDEO_FADE_CURVES, `${path}.fadeInCurve`),
|
|
891
912
|
fadeOutCurve: oneOf(v.fadeOutCurve, VIDEO_FADE_CURVES, `${path}.fadeOutCurve`)
|
|
892
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
|
+
}
|
|
893
928
|
if (type === "text")
|
|
894
929
|
return compact({
|
|
895
930
|
type,
|
|
@@ -918,6 +953,21 @@ function parseVideoTrack(v, path = "track") {
|
|
|
918
953
|
clips: (v.clips ?? []).map((c, i) => parseVideoClip(c, `${path}.clips[${i}]`))
|
|
919
954
|
});
|
|
920
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
|
+
}
|
|
921
971
|
function parseVideoMarker(v, path = "marker") {
|
|
922
972
|
if (!isObject(v)) return fail(path, "must be an object");
|
|
923
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) });
|
|
@@ -933,7 +983,7 @@ function parseVideoProject(input) {
|
|
|
933
983
|
}
|
|
934
984
|
if (!isObject(v)) return fail("The project", "must be an object");
|
|
935
985
|
if (v.version !== void 0 && v.version !== 1) fail("version", "must be 1");
|
|
936
|
-
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");
|
|
937
987
|
const project = compact({
|
|
938
988
|
version: 1,
|
|
939
989
|
name: str(v.name, "name", true),
|
|
@@ -943,6 +993,7 @@ function parseVideoProject(input) {
|
|
|
943
993
|
background: str(v.background, "background", true),
|
|
944
994
|
assets: (v.assets ?? []).map((a, i) => parseVideoAsset(a, `assets[${i}]`)),
|
|
945
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}]`)),
|
|
946
997
|
markers: v.markers === void 0 ? void 0 : v.markers.map((m, i) => parseVideoMarker(m, `markers[${i}]`))
|
|
947
998
|
});
|
|
948
999
|
const unique = (ids, what) => {
|
|
@@ -954,7 +1005,8 @@ function parseVideoProject(input) {
|
|
|
954
1005
|
};
|
|
955
1006
|
unique(project.assets.map((a) => a.id), "assets");
|
|
956
1007
|
unique(project.tracks.map((t) => t.id), "tracks");
|
|
957
|
-
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");
|
|
958
1010
|
unique((project.markers ?? []).map((m) => m.id), "markers");
|
|
959
1011
|
return project;
|
|
960
1012
|
}
|
|
@@ -980,6 +1032,7 @@ var videoClipEnd = (clip) => clip.start + clip.duration;
|
|
|
980
1032
|
var videoTrackSyncLocked = (track) => track.syncLocked !== false;
|
|
981
1033
|
var videoClipSpeed = (clip) => clip.type === "media" ? clip.speed ?? 1 : 1;
|
|
982
1034
|
function videoClipSourceRange(clip) {
|
|
1035
|
+
if (clip.type === "sequence") return { in: clip.in ?? 0, out: (clip.in ?? 0) + clip.duration };
|
|
983
1036
|
if (clip.type !== "media") return null;
|
|
984
1037
|
const start = clip.in ?? 0;
|
|
985
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 };
|
|
@@ -1029,6 +1082,33 @@ var videoAssetHasSound = (asset) => !!asset && (asset.kind === "audio" || asset.
|
|
|
1029
1082
|
function videoAssetMap(project) {
|
|
1030
1083
|
return new Map(project.assets.map((a) => [a.id, a]));
|
|
1031
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
|
+
}
|
|
1032
1112
|
function sortedVideoClips(track) {
|
|
1033
1113
|
return [...track.clips].sort((a, b) => a.start - b.start || a.duration - b.duration);
|
|
1034
1114
|
}
|
|
@@ -1041,7 +1121,15 @@ function findVideoClip(project, id) {
|
|
|
1041
1121
|
return null;
|
|
1042
1122
|
}
|
|
1043
1123
|
function allVideoClipIds(project) {
|
|
1044
|
-
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;
|
|
1045
1133
|
}
|
|
1046
1134
|
function videoProjectDuration(project) {
|
|
1047
1135
|
let end = 0;
|
|
@@ -1057,7 +1145,12 @@ function videoClipAfter(track, clip) {
|
|
|
1057
1145
|
for (const c of track.clips) if (c !== clip && Math.abs(c.start - videoClipEnd(clip)) <= VIDEO_TIME_EPSILON) return c;
|
|
1058
1146
|
return null;
|
|
1059
1147
|
}
|
|
1060
|
-
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
|
+
}
|
|
1061
1154
|
if (clip.type !== "media") return { before: Infinity, after: Infinity };
|
|
1062
1155
|
const asset = assets.get(clip.asset);
|
|
1063
1156
|
const speed = videoClipSpeed(clip);
|
|
@@ -1189,13 +1282,15 @@ function videoClipAnimated(clip, project) {
|
|
|
1189
1282
|
function videoClipKeyframes(clip) {
|
|
1190
1283
|
const out = [
|
|
1191
1284
|
{ property: "opacity", keys: videoKeysOf(clip.opacity) },
|
|
1192
|
-
{ property: "volume", keys: clip.type === "media" ? videoKeysOf(clip.volume) : [] },
|
|
1285
|
+
{ property: "volume", keys: clip.type === "media" || clip.type === "sequence" ? videoKeysOf(clip.volume) : [] },
|
|
1193
1286
|
...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p, keys: videoKeysOf(clip.transform?.[p]) }))
|
|
1194
1287
|
];
|
|
1195
1288
|
return out.filter((o) => o.keys.length);
|
|
1196
1289
|
}
|
|
1197
1290
|
var VIDEO_ISSUE_CODES = [
|
|
1198
1291
|
"missing-asset",
|
|
1292
|
+
"missing-sequence",
|
|
1293
|
+
"sequence-cycle",
|
|
1199
1294
|
"wrong-track",
|
|
1200
1295
|
"overlap",
|
|
1201
1296
|
"source-overrun",
|
|
@@ -1208,23 +1303,41 @@ var VIDEO_ISSUE_CODES = [
|
|
|
1208
1303
|
"keyframes-outside-clip",
|
|
1209
1304
|
"unknown-duration",
|
|
1210
1305
|
"unused-asset",
|
|
1306
|
+
"unused-sequence",
|
|
1211
1307
|
"marker-past-end",
|
|
1212
1308
|
"empty"
|
|
1213
1309
|
];
|
|
1214
|
-
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("|");
|
|
1215
1311
|
function checkVideoProject(project) {
|
|
1216
1312
|
const issues = [];
|
|
1217
1313
|
const assets = videoAssetMap(project);
|
|
1314
|
+
const sequences = videoSequenceMap(project);
|
|
1218
1315
|
const used = /* @__PURE__ */ new Set();
|
|
1316
|
+
const usedSequences = /* @__PURE__ */ new Set();
|
|
1317
|
+
const cycles = /* @__PURE__ */ new Map();
|
|
1219
1318
|
for (const asset of project.assets)
|
|
1220
1319
|
if ((asset.kind === "video" || asset.kind === "audio") && asset.duration === void 0)
|
|
1221
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.` });
|
|
1222
|
-
|
|
1321
|
+
const checkTrack = (track, sequence) => {
|
|
1223
1322
|
const clips = sortedVideoClips(track);
|
|
1323
|
+
const where = sequence ? ` in sequence "${sequence}"` : "";
|
|
1224
1324
|
for (let i = 0; i < clips.length; i++) {
|
|
1225
1325
|
const clip = clips[i];
|
|
1226
|
-
const at = { track: track.id, clip: clip.id };
|
|
1227
|
-
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") {
|
|
1228
1341
|
used.add(clip.asset);
|
|
1229
1342
|
const asset = assets.get(clip.asset);
|
|
1230
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.` });
|
|
@@ -1238,7 +1351,7 @@ function checkVideoProject(project) {
|
|
|
1238
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.` });
|
|
1239
1352
|
const next = clips[i + 1];
|
|
1240
1353
|
if (next && next.start < videoClipEnd(clip) - VIDEO_TIME_EPSILON)
|
|
1241
|
-
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.` });
|
|
1242
1355
|
if ((clip.fadeIn ?? 0) + (clip.fadeOut ?? 0) > clip.duration + VIDEO_TIME_EPSILON)
|
|
1243
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.` });
|
|
1244
1357
|
const anim = (clip.animateIn ? clip.animateIn.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0) + (clip.animateOut ? clip.animateOut.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0);
|
|
@@ -1273,8 +1386,8 @@ function checkVideoProject(project) {
|
|
|
1273
1386
|
continue;
|
|
1274
1387
|
}
|
|
1275
1388
|
if (clip.transition.type === "dip") continue;
|
|
1276
|
-
const outgoing = videoClipHandles(before, assets).after;
|
|
1277
|
-
const incoming = videoClipHandles(clip, assets).before;
|
|
1389
|
+
const outgoing = videoClipHandles(before, assets, sequences).after;
|
|
1390
|
+
const incoming = videoClipHandles(clip, assets, sequences).before;
|
|
1278
1391
|
const half = d / 2;
|
|
1279
1392
|
if (outgoing < half - VIDEO_TIME_EPSILON || incoming < half - VIDEO_TIME_EPSILON) {
|
|
1280
1393
|
const fits = 2 * Math.min(outgoing, incoming);
|
|
@@ -1287,7 +1400,11 @@ function checkVideoProject(project) {
|
|
|
1287
1400
|
}
|
|
1288
1401
|
}
|
|
1289
1402
|
}
|
|
1290
|
-
}
|
|
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.` });
|
|
1291
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.` });
|
|
1292
1409
|
const end = videoProjectDuration(project);
|
|
1293
1410
|
for (const marker of project.markers ?? [])
|
|
@@ -1383,6 +1500,8 @@ var Editor = class {
|
|
|
1383
1500
|
}
|
|
1384
1501
|
clip(id) {
|
|
1385
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).`);
|
|
1386
1505
|
if (!at) {
|
|
1387
1506
|
const ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));
|
|
1388
1507
|
refuse(`There is no clip ${namedWithSuggestion(id, ids)}${ids.length ? "" : "; the project has no clips yet"}.`);
|
|
@@ -1406,6 +1525,11 @@ var Editor = class {
|
|
|
1406
1525
|
}
|
|
1407
1526
|
kindFits(track, clip) {
|
|
1408
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
|
+
}
|
|
1409
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.`);
|
|
1410
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.`);
|
|
1411
1535
|
}
|
|
@@ -1419,7 +1543,7 @@ var Editor = class {
|
|
|
1419
1543
|
return 1e3 / this.p.fps;
|
|
1420
1544
|
}
|
|
1421
1545
|
ids() {
|
|
1422
|
-
return this.p
|
|
1546
|
+
return allVideoClipIds(this.p);
|
|
1423
1547
|
}
|
|
1424
1548
|
/**
|
|
1425
1549
|
* The tracks a ripple on `sources` moves, top to bottom: the sources, and every unlocked
|
|
@@ -1447,8 +1571,13 @@ var Editor = class {
|
|
|
1447
1571
|
range(from, to) {
|
|
1448
1572
|
if (!(to - from > VIDEO_TIME_EPSILON)) refuse(`"to" (${formatVideoSeconds(to)}) must be after "from" (${formatVideoSeconds(from)}).`);
|
|
1449
1573
|
}
|
|
1450
|
-
/** 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). */
|
|
1451
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
|
+
}
|
|
1452
1581
|
if (clip.type !== "media" || videoAssetMap(this.p).get(clip.asset)?.kind === "image") return;
|
|
1453
1582
|
clip.in = Math.max(0, (clip.in ?? 0) + delta * videoClipSpeed(clip));
|
|
1454
1583
|
if (clip.in < 1e-9) delete clip.in;
|
|
@@ -1476,7 +1605,7 @@ var Editor = class {
|
|
|
1476
1605
|
clip.duration = at - clip.start;
|
|
1477
1606
|
delete clip.fadeOut;
|
|
1478
1607
|
delete clip.animateOut;
|
|
1479
|
-
if (clip.type === "media") delete clip.fadeOutCurve;
|
|
1608
|
+
if (clip.type === "media" || clip.type === "sequence") delete clip.fadeOutCurve;
|
|
1480
1609
|
track.clips.push(right);
|
|
1481
1610
|
track.clips.sort((a, b) => a.start - b.start);
|
|
1482
1611
|
return right;
|
|
@@ -1598,7 +1727,7 @@ var Editor = class {
|
|
|
1598
1727
|
}
|
|
1599
1728
|
case "remove_asset": {
|
|
1600
1729
|
const asset = this.asset(e.asset);
|
|
1601
|
-
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));
|
|
1602
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.`);
|
|
1603
1732
|
this.p.assets = this.p.assets.filter((a) => a !== asset);
|
|
1604
1733
|
return this.outcomes.push({ op: e.op, asset: asset.id });
|
|
@@ -1640,7 +1769,7 @@ var Editor = class {
|
|
|
1640
1769
|
const mode = this.placement(e.mode, e.ripple);
|
|
1641
1770
|
const draft = { ...e.clip, type: e.clip.type ?? "media" };
|
|
1642
1771
|
this.kindFits(track, draft);
|
|
1643
|
-
const ids = this.
|
|
1772
|
+
const ids = this.ids();
|
|
1644
1773
|
if (draft.id !== void 0 && ids.includes(draft.id)) refuse(`There is already a clip "${draft.id}".`);
|
|
1645
1774
|
draft.id ??= freshId("c", ids);
|
|
1646
1775
|
if (draft.duration === void 0) {
|
|
@@ -1650,6 +1779,10 @@ var Editor = class {
|
|
|
1650
1779
|
else if (asset.duration === void 0) refuse(`Asset "${asset.id}" has no duration, so give the clip a "duration".`);
|
|
1651
1780
|
else draft.duration = (asset.duration - (draft.in ?? 0)) / (draft.speed ?? 1);
|
|
1652
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;
|
|
1653
1786
|
} else draft.duration = VIDEO_DEFAULT_TITLE_DURATION;
|
|
1654
1787
|
}
|
|
1655
1788
|
const end = sortedVideoClips(track).reduce((m, c) => Math.max(m, videoClipEnd(c)), 0);
|
|
@@ -1684,7 +1817,7 @@ var Editor = class {
|
|
|
1684
1817
|
if (e.edge === "start") {
|
|
1685
1818
|
const delta = e.to - clip.start;
|
|
1686
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)}.`);
|
|
1687
|
-
if (clip.type === "media" && !isRamped(clip)) {
|
|
1820
|
+
if (clip.type === "media" && !isRamped(clip) || clip.type === "sequence") {
|
|
1688
1821
|
const sourceIn = (clip.in ?? 0) + delta * speed;
|
|
1689
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)}).`);
|
|
1690
1823
|
clip.in = Math.max(0, sourceIn);
|
|
@@ -1718,6 +1851,11 @@ var Editor = class {
|
|
|
1718
1851
|
if (asset?.duration !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
|
|
1719
1852
|
refuse(`"${clip.id}" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (clip.in ?? 0)) / speed)}, where "${asset.id}" runs out.`);
|
|
1720
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
|
+
}
|
|
1721
1859
|
if (e.ripple) {
|
|
1722
1860
|
const tracks = this.rippleTracks([track]);
|
|
1723
1861
|
let created = [];
|
|
@@ -1792,6 +1930,7 @@ var Editor = class {
|
|
|
1792
1930
|
case "set_time_remap": {
|
|
1793
1931
|
const { track, clip } = this.clip(e.clip);
|
|
1794
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.`);
|
|
1795
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.`);
|
|
1796
1935
|
const asset = this.asset(clip.asset);
|
|
1797
1936
|
if (asset.kind === "image") refuse(`Clip "${clip.id}" is a still ("${asset.id}"), so it has no motion to ramp.`);
|
|
@@ -1849,16 +1988,16 @@ var Editor = class {
|
|
|
1849
1988
|
case "slip_clip": {
|
|
1850
1989
|
const { track, clip } = this.clip(e.clip);
|
|
1851
1990
|
this.unlocked(track);
|
|
1852
|
-
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
|
|
1853
|
-
const
|
|
1854
|
-
if (asset.kind === "image") refuse(`Clip "${clip.id}" is a still ("${asset
|
|
1855
|
-
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));
|
|
1856
1995
|
const { lead, tail } = videoClipOverhang(track, clip);
|
|
1857
1996
|
const delta = this.clamp(
|
|
1858
1997
|
e.delta,
|
|
1859
1998
|
lead - handles.before,
|
|
1860
1999
|
handles.after - tail,
|
|
1861
|
-
() => 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).`
|
|
1862
2001
|
);
|
|
1863
2002
|
this.moveIn(clip, delta);
|
|
1864
2003
|
return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, delta });
|
|
@@ -1876,8 +2015,8 @@ var Editor = class {
|
|
|
1876
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).`);
|
|
1877
2016
|
}
|
|
1878
2017
|
const assets = videoAssetMap(this.p);
|
|
1879
|
-
const lo = Math.max(-(L.duration - this.frame), -(videoClipHandles(R, assets).before - videoClipOverhang(track, R).lead));
|
|
1880
|
-
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);
|
|
1881
2020
|
const delta = this.clamp(
|
|
1882
2021
|
e.delta,
|
|
1883
2022
|
lo,
|
|
@@ -1903,8 +2042,8 @@ var Editor = class {
|
|
|
1903
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),
|
|
1904
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)
|
|
1905
2044
|
};
|
|
1906
|
-
const lo = -Math.min(room.earlier, after ? videoClipHandles(after, assets).before - videoClipOverhang(track, after).lead : Infinity);
|
|
1907
|
-
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);
|
|
1908
2047
|
const delta = this.clamp(
|
|
1909
2048
|
e.delta,
|
|
1910
2049
|
lo,
|
|
@@ -1946,7 +2085,7 @@ var Editor = class {
|
|
|
1946
2085
|
const { track, clip } = this.clip(e.clip);
|
|
1947
2086
|
this.unlocked(track);
|
|
1948
2087
|
if (!VIDEO_KEYFRAME_PROPERTIES.includes(e.property)) refuse(`"${e.property}" cannot be keyframed; use one of ${VIDEO_KEYFRAME_PROPERTIES.join(", ")}.`);
|
|
1949
|
-
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.`);
|
|
1950
2089
|
const limits = VIDEO_KEYFRAME_LIMITS[e.property];
|
|
1951
2090
|
const transform = VIDEO_TRANSFORM_PROPERTIES.includes(e.property);
|
|
1952
2091
|
const c = clip;
|
|
@@ -2097,13 +2236,13 @@ function videoFadeCurveGain(curve, x) {
|
|
|
2097
2236
|
}
|
|
2098
2237
|
function fadeGain(clip, time, sound = false) {
|
|
2099
2238
|
let g = 1;
|
|
2100
|
-
const shape = sound && clip.type === "media" ? clip : void 0;
|
|
2239
|
+
const shape = sound && (clip.type === "media" || clip.type === "sequence") ? clip : void 0;
|
|
2101
2240
|
if (clip.fadeIn) g *= videoFadeCurveGain(shape?.fadeInCurve, (time - clip.start) / clip.fadeIn);
|
|
2102
2241
|
if (clip.fadeOut) g *= videoFadeCurveGain(shape?.fadeOutCurve, (videoClipEnd(clip) - time) / clip.fadeOut);
|
|
2103
2242
|
return g;
|
|
2104
2243
|
}
|
|
2105
2244
|
function videoClipSoundGain(clip, clipTime) {
|
|
2106
|
-
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;
|
|
2107
2246
|
return volume * fadeGain(clip, clip.start + Math.min(Math.max(clipTime, 0), clip.duration), true);
|
|
2108
2247
|
}
|
|
2109
2248
|
function activeOnTrack(track, time) {
|
|
@@ -2123,11 +2262,15 @@ function activeOnTrack(track, time) {
|
|
|
2123
2262
|
}
|
|
2124
2263
|
var sourceTimeAt = (clip, time) => clip.type === "media" ? videoClipSourceTime(clip, time - clip.start) : 0;
|
|
2125
2264
|
function videoFrameAt(project, time) {
|
|
2126
|
-
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;
|
|
2127
2270
|
const layers = [];
|
|
2128
2271
|
const audio = [];
|
|
2129
|
-
for (let ti =
|
|
2130
|
-
const track =
|
|
2272
|
+
for (let ti = tracks.length - 1; ti >= 0; ti--) {
|
|
2273
|
+
const track = tracks[ti];
|
|
2131
2274
|
for (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {
|
|
2132
2275
|
if (incoming?.type === "dip" && incoming.progress < 0.5) continue;
|
|
2133
2276
|
if (outgoing?.type === "dip" && outgoing.progress >= 0.5) continue;
|
|
@@ -2136,15 +2279,26 @@ function videoFrameAt(project, time) {
|
|
|
2136
2279
|
const level = (t, transition, into) => videoFadeCurveGain(videoTransitionAudioCurve(transition), t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress);
|
|
2137
2280
|
const mix = (incoming ? level(incoming, incoming.transition, true) : 1) * (outgoing ? level(outgoing, outgoing.next.transition, false) : 1);
|
|
2138
2281
|
const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
|
|
2139
|
-
const sourceTime = sourceTimeAt(clip, time);
|
|
2282
|
+
const sourceTime = clip.type === "sequence" ? (clip.in ?? 0) + time - clip.start : sourceTimeAt(clip, time);
|
|
2140
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
|
+
}
|
|
2141
2290
|
const audible = videoAssetHasSound(asset);
|
|
2142
2291
|
if (audible && clip.type === "media" && !clip.muted && !track.muted) {
|
|
2143
2292
|
const gain = videoValueAt(clip.volume, clipTime, 1) * (track.volume ?? 1) * fadeGain(clip, inside, true) * mix;
|
|
2144
|
-
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 });
|
|
2145
2298
|
}
|
|
2146
2299
|
if (track.kind !== "video" || track.hidden) continue;
|
|
2147
2300
|
if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
|
|
2301
|
+
if (nested && !nested.layers.length) continue;
|
|
2148
2302
|
const shown = videoClipAnimated(clip, project);
|
|
2149
2303
|
let opacity = videoValueAt(shown.opacity, clipTime, 1) * fade;
|
|
2150
2304
|
let wipe;
|
|
@@ -2167,25 +2321,29 @@ function videoFrameAt(project, time) {
|
|
|
2167
2321
|
shift = videoTransitionEase(outgoing.progress);
|
|
2168
2322
|
}
|
|
2169
2323
|
const size = clip.type === "media" ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
|
|
2170
|
-
const fit = clip.fit ?? (clip.type === "media" ? "contain" : "fill");
|
|
2324
|
+
const fit = clip.fit ?? (clip.type === "media" || clip.type === "sequence" ? "contain" : "fill");
|
|
2171
2325
|
const placed = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, shown.transform, clipTime);
|
|
2172
2326
|
const across = shiftDirection === "left" || shiftDirection === "right";
|
|
2173
2327
|
const sign = shiftDirection === "left" || shiftDirection === "up" ? -1 : 1;
|
|
2174
2328
|
const rect = shift ? { ...placed, ...across ? { x: placed.x + sign * shift * project.width } : { y: placed.y + sign * shift * project.height } } : placed;
|
|
2175
|
-
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) };
|
|
2176
2330
|
if (wipe) layer.wipe = wipe;
|
|
2177
2331
|
if (iris) layer.iris = iris;
|
|
2178
2332
|
if (clip.blend && clip.blend !== "normal") layer.blend = clip.blend;
|
|
2179
2333
|
const grade = normalizeVideoGrade(clip.grade);
|
|
2180
2334
|
if (grade) layer.grade = grade;
|
|
2181
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 });
|
|
2182
2337
|
if (clip.type === "text") layer.text = videoTextStyle(clip);
|
|
2183
2338
|
if (clip.type === "color") layer.color = clip.color;
|
|
2184
2339
|
if (layer.opacity > 0) layers.push(layer);
|
|
2185
|
-
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 });
|
|
2186
2341
|
}
|
|
2187
2342
|
}
|
|
2188
|
-
return {
|
|
2343
|
+
return { layers, audio };
|
|
2344
|
+
}
|
|
2345
|
+
function videoFrameLeafLayers(layers) {
|
|
2346
|
+
return layers.flatMap((l) => l.layers ? videoFrameLeafLayers(l.layers) : [l]);
|
|
2189
2347
|
}
|
|
2190
2348
|
|
|
2191
2349
|
// src/video-editor/audio.ts
|
|
@@ -2946,6 +3104,7 @@ function videoWordAt(words, time) {
|
|
|
2946
3104
|
// src/video-editor/render.ts
|
|
2947
3105
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2948
3106
|
var n = (v) => String(Math.round(v * 100) / 100);
|
|
3107
|
+
var n4 = (v) => String(Math.round(v * 1e4) / 1e4);
|
|
2949
3108
|
var videoIrisRadius = (frame, progress) => progress * Math.hypot(frame.width / 2, frame.height / 2);
|
|
2950
3109
|
function videoWipeRect(rect, wipe) {
|
|
2951
3110
|
const { direction, progress } = wipe;
|
|
@@ -2958,13 +3117,16 @@ function videoWipeRect(rect, wipe) {
|
|
|
2958
3117
|
}
|
|
2959
3118
|
function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
2960
3119
|
const defs = [];
|
|
2961
|
-
const body = [];
|
|
2962
3120
|
const posters = new Map(assets.map((a) => [a.id, a.poster]));
|
|
2963
|
-
|
|
3121
|
+
const draw = (layers, prefix) => layers.map((layer, index) => {
|
|
3122
|
+
const i = `${prefix}${index}`;
|
|
2964
3123
|
const { rect } = layer;
|
|
2965
3124
|
const attrs = [];
|
|
3125
|
+
const style = [];
|
|
2966
3126
|
if (layer.opacity < 1) attrs.push(`opacity="${n(layer.opacity)}"`);
|
|
2967
|
-
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(";")}"`);
|
|
2968
3130
|
if (layer.grade) {
|
|
2969
3131
|
defs.push(videoGradeSvgFilter(`g${i}`, layer.grade, layer.grade.lut ? options.lutFor?.(layer.grade.lut.src) : null));
|
|
2970
3132
|
attrs.push(`filter="url(#g${i})"`);
|
|
@@ -2977,7 +3139,9 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
|
2977
3139
|
}
|
|
2978
3140
|
const box = `x="${n(rect.x)}" y="${n(rect.y)}" width="${n(rect.width)}" height="${n(rect.height)}"`;
|
|
2979
3141
|
let inner = "";
|
|
2980
|
-
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")}"/>`;
|
|
2981
3145
|
else if (layer.type === "text" && layer.text) {
|
|
2982
3146
|
const t = layer.text;
|
|
2983
3147
|
const lines = t.text.split("\n");
|
|
@@ -3005,9 +3169,10 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
|
3005
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>`);
|
|
3006
3170
|
item = `<g clip-path="url(#i${i})">${item}</g>`;
|
|
3007
3171
|
}
|
|
3008
|
-
|
|
3009
|
-
});
|
|
3010
|
-
|
|
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>`;
|
|
3011
3176
|
}
|
|
3012
3177
|
function videoProjectSvg(project, time, options = {}) {
|
|
3013
3178
|
return videoFrameToSvg(videoFrameAt(project, time), { fps: project.fps, ...options }, project.assets);
|
|
@@ -3091,13 +3256,36 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
|
|
|
3091
3256
|
ctx.globalAlpha = 1;
|
|
3092
3257
|
ctx.fillStyle = frame.background;
|
|
3093
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) {
|
|
3094
3263
|
const cw = Math.max(1, Math.round(frame.width * scale));
|
|
3095
3264
|
const ch = Math.max(1, Math.round(frame.height * scale));
|
|
3096
|
-
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
|
+
}
|
|
3097
3284
|
const setting = layer.grade?.lut;
|
|
3098
3285
|
const lut = setting ? options.lutFor?.(setting.src) : null;
|
|
3099
|
-
const scratch = lut ? options.scratch?.(cw, ch) : null;
|
|
3286
|
+
const scratch = lut ? options.scratch?.(cw, ch, 2 * depth + 1) : null;
|
|
3100
3287
|
ctx.save();
|
|
3288
|
+
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
3101
3289
|
if (lut && scratch) {
|
|
3102
3290
|
const s = scratch.ctx;
|
|
3103
3291
|
s.save();
|
|
@@ -3106,7 +3294,7 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
|
|
|
3106
3294
|
s.setTransform(scale, 0, 0, scale, 0, 0);
|
|
3107
3295
|
s.globalAlpha = 1;
|
|
3108
3296
|
s.globalCompositeOperation = "source-over";
|
|
3109
|
-
paintVideoLayer(s, frame, layer,
|
|
3297
|
+
paintVideoLayer(s, frame, layer, source, options);
|
|
3110
3298
|
s.restore();
|
|
3111
3299
|
const img = s.getImageData(0, 0, cw, ch);
|
|
3112
3300
|
applyVideoLutToPixels(lut, img.data, setting.strength ?? 1);
|
|
@@ -3118,11 +3306,10 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
|
|
|
3118
3306
|
} else {
|
|
3119
3307
|
ctx.globalAlpha = layer.opacity;
|
|
3120
3308
|
if (layer.blend && layer.blend !== "normal") ctx.globalCompositeOperation = layer.blend === "add" ? "lighter" : layer.blend;
|
|
3121
|
-
paintVideoLayer(ctx, frame, layer,
|
|
3309
|
+
paintVideoLayer(ctx, frame, layer, source, options);
|
|
3122
3310
|
}
|
|
3123
3311
|
ctx.restore();
|
|
3124
3312
|
}
|
|
3125
|
-
ctx.restore();
|
|
3126
3313
|
}
|
|
3127
3314
|
|
|
3128
3315
|
// src/video-editor/export.ts
|
|
@@ -3164,6 +3351,7 @@ function toVideoEdl(project, options = {}) {
|
|
|
3164
3351
|
if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${asset?.name ?? asset?.src ?? clip.asset}`);
|
|
3165
3352
|
if (clip.type === "text") lines.push(`* TITLE: ${clip.text.replace(/\n/g, " ")}`);
|
|
3166
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)`);
|
|
3167
3355
|
lines.push("");
|
|
3168
3356
|
}
|
|
3169
3357
|
return lines.join("\n");
|
|
@@ -3219,7 +3407,7 @@ var color = (c) => {
|
|
|
3219
3407
|
const h = hex[1].length === 3 ? [...hex[1]].map((x) => x + x).join("") : hex[1];
|
|
3220
3408
|
return `0x${h.slice(0, 6)}${h.length === 8 ? `@${(parseInt(h.slice(6), 16) / 255).toFixed(3)}` : ""}`;
|
|
3221
3409
|
};
|
|
3222
|
-
var
|
|
3410
|
+
var videoShellQuote = (s) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
3223
3411
|
function atempo(speed) {
|
|
3224
3412
|
const out = [];
|
|
3225
3413
|
let s = speed;
|
|
@@ -3243,6 +3431,7 @@ function toVideoLoudnessMeasure(project, options = {}) {
|
|
|
3243
3431
|
function buildFfmpeg(project, options, measure) {
|
|
3244
3432
|
const notes = [];
|
|
3245
3433
|
const assets = videoAssetMap(project);
|
|
3434
|
+
const sequences = videoSequenceMap(project);
|
|
3246
3435
|
const D = videoProjectDuration(project);
|
|
3247
3436
|
if (D <= 0) throw new Error("The project has no clips, so there is nothing to render.");
|
|
3248
3437
|
const W = even(project.width);
|
|
@@ -3250,8 +3439,6 @@ function buildFfmpeg(project, options, measure) {
|
|
|
3250
3439
|
const fps = project.fps;
|
|
3251
3440
|
const inputs = [];
|
|
3252
3441
|
const graph = measure ? [] : [`color=c=${color(project.background ?? "#000000")}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];
|
|
3253
|
-
const audio = [];
|
|
3254
|
-
let cur = "base";
|
|
3255
3442
|
let usesDrawtext = false;
|
|
3256
3443
|
const animated = /* @__PURE__ */ new Set();
|
|
3257
3444
|
const ramped = /* @__PURE__ */ new Set();
|
|
@@ -3275,7 +3462,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
3275
3462
|
const pts = videoTimeRemapBreakpoints(remap, Math.max(80, 2e3 / fps));
|
|
3276
3463
|
const first = pts[0];
|
|
3277
3464
|
const last = pts[pts.length - 1];
|
|
3278
|
-
const n2 =
|
|
3465
|
+
const n2 = ak;
|
|
3279
3466
|
const fmtA = "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo";
|
|
3280
3467
|
const silence = (ms) => `anullsrc=r=48000:cl=stereo,atrim=duration=${sec(ms)},${fmtA}`;
|
|
3281
3468
|
const pieces = [];
|
|
@@ -3324,180 +3511,228 @@ function buildFfmpeg(project, options, measure) {
|
|
|
3324
3511
|
} else inputs.push(["-ss", sec((clip.in ?? 0) - lead * speed), "-t", sec(span * speed), "-i", src]);
|
|
3325
3512
|
return inputs.length - 1;
|
|
3326
3513
|
};
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
const
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
const ai = index ?? input(clip, asset, lead, tail);
|
|
3344
|
-
const trackGain = track.volume ?? 1;
|
|
3345
|
-
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}`;
|
|
3346
|
-
let source2 = `[${ai}:a]`;
|
|
3347
|
-
if (clip.timeRemap) {
|
|
3348
|
-
source2 = `[r${audio.length}]`;
|
|
3349
|
-
graph.push(...rampedSound(clip.timeRemap, `${ai}:a`, source2, lead, clip.duration + tail));
|
|
3350
|
-
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;
|
|
3351
3530
|
}
|
|
3352
|
-
const
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
const
|
|
3381
|
-
|
|
3382
|
-
const
|
|
3383
|
-
const
|
|
3384
|
-
|
|
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" : ""})`);
|
|
3385
3691
|
};
|
|
3386
|
-
const
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
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.`);
|
|
3424
|
-
const file = ffmpegFilterValue(path);
|
|
3425
|
-
const into2 = `q${next}`;
|
|
3426
|
-
graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[${into2}]`);
|
|
3427
|
-
const lut3d = `lut3d=file=${file}:interp=tetrahedral`;
|
|
3428
|
-
if ((lut.strength ?? 1) >= 1) graph.push(`[${into2}]${lut3d}[r${next}]`);
|
|
3429
|
-
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}]`);
|
|
3430
|
-
source = `[r${next}]`;
|
|
3431
|
-
chain.length = 0;
|
|
3432
|
-
}
|
|
3433
|
-
chain.push("format=yuva420p");
|
|
3434
|
-
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)'`);
|
|
3435
|
-
else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
|
|
3436
|
-
if (clip.fadeIn) chain.push(`fade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}:alpha=1`);
|
|
3437
|
-
if (clip.fadeOut) chain.push(`fade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}:alpha=1`);
|
|
3438
|
-
if (lead && clip.transition.type === "crossfade") chain.push(`fade=t=in:st=0:d=${sec(2 * lead)}:alpha=1`);
|
|
3439
|
-
if (lead && clip.transition.type === "wipe") {
|
|
3440
|
-
const d = sec(2 * lead);
|
|
3441
|
-
const p = `min(1,T/${d})`;
|
|
3442
|
-
const dir = clip.transition.direction ?? "right";
|
|
3443
|
-
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}))`;
|
|
3444
|
-
chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
|
|
3445
|
-
}
|
|
3446
|
-
if (lead && clip.transition.type === "iris") {
|
|
3447
|
-
const r = videoIrisRadius({ width: W, height: H }, 1);
|
|
3448
|
-
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)'`);
|
|
3449
|
-
}
|
|
3450
|
-
const rotation = placed?.rotation;
|
|
3451
|
-
if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=black@0:ow='hypot(iw,ih)':oh=ow`);
|
|
3452
|
-
else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=black@0:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
|
|
3453
|
-
if (isVideoKeyframes(placed?.scale)) {
|
|
3454
|
-
const s = expr(placed.scale, onChain("t"), 1, "scale");
|
|
3455
|
-
chain.push(`scale=w='max(2,trunc(iw*(${s})/2)*2)':h='max(2,trunc(ih*(${s})/2)*2)':eval=frame`);
|
|
3456
|
-
}
|
|
3457
|
-
chain.push(`setpts=PTS+${sec(t0)}/TB`);
|
|
3458
|
-
const cx = rect.x + rect.width / 2;
|
|
3459
|
-
const cy = rect.y + rect.height / 2;
|
|
3460
|
-
const shifts = { x: [], y: [] };
|
|
3461
|
-
const shiftBy = (direction, from, d, entering) => {
|
|
3462
|
-
const dir = direction ?? "right";
|
|
3463
|
-
const axis = dir === "left" || dir === "right" ? "x" : "y";
|
|
3464
|
-
const size2 = axis === "x" ? W : H;
|
|
3465
|
-
const sign = dir === "left" || dir === "up" ? -1 : 1;
|
|
3466
|
-
const ease = `(1-cos(PI*clip((t-${sec(from)})/${sec(d)},0,1)))/2`;
|
|
3467
|
-
shifts[axis].push(`${sign * size2}*(${ease}${entering ? "-1" : ""})`);
|
|
3468
|
-
};
|
|
3469
|
-
const into = lead ? clip.transition.type : void 0;
|
|
3470
|
-
if (into === "push" || into === "slide") shiftBy(clip.transition.direction, clip.start - lead, 2 * lead, true);
|
|
3471
|
-
const after = tail ? sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition) : void 0;
|
|
3472
|
-
if (after?.transition.type === "push") shiftBy(after.transition.direction, end - tail, 2 * tail, false);
|
|
3473
|
-
const at = (axis, c) => {
|
|
3474
|
-
const v = placed?.[axis];
|
|
3475
|
-
const extra = shifts[axis].map((s) => `+${s}`).join("");
|
|
3476
|
-
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}'`;
|
|
3477
|
-
};
|
|
3478
|
-
graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
|
|
3479
|
-
if (clip.blend && clip.blend !== "normal") {
|
|
3480
|
-
graph.push(`color=c=black@0:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[t${next}]`);
|
|
3481
|
-
graph.push(`[t${next}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable},format=gbrap,split[f${next}][g${next}]`);
|
|
3482
|
-
graph.push(`[${cur}]format=gbrap,split[c${next}][d${next}]`);
|
|
3483
|
-
const layerFirst = clip.blend === "color-dodge" || clip.blend === "color-burn";
|
|
3484
|
-
graph.push(`${layerFirst ? `[f${next}][c${next}]` : `[c${next}][f${next}]`}blend=all_mode=${FFMPEG_BLEND[clip.blend]}[b${next}]`);
|
|
3485
|
-
graph.push(`[g${next}]alphaextract[m${next}]`);
|
|
3486
|
-
graph.push(`[b${next}][m${next}]alphamerge[x${next}]`);
|
|
3487
|
-
graph.push(`[d${next}][x${next}]overlay=format=gbrp,format=yuva420p[${next}]`);
|
|
3488
|
-
blended.add(clip.blend);
|
|
3489
|
-
} else graph.push(`[${cur}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable}[${next}]`);
|
|
3490
|
-
cur = next;
|
|
3491
|
-
const before = clip.transition ? videoClipBefore(track, clip) : null;
|
|
3492
|
-
if (before && clip.transition.type === "dip") {
|
|
3493
|
-
const d = clip.transition.duration;
|
|
3494
|
-
const dip = `d${k}`;
|
|
3495
|
-
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}]`);
|
|
3496
|
-
graph.push(`[${cur}][l${dip}]overlay=eof_action=pass:enable='between(t,${sec(clip.start - d / 2)},${sec(clip.start + d / 2)})'[${dip}]`);
|
|
3497
|
-
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
|
+
}
|
|
3498
3729
|
}
|
|
3499
3730
|
}
|
|
3500
|
-
|
|
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;
|
|
3501
3736
|
if (!measure) graph.push(`[${cur}]format=yuv420p[vout]`);
|
|
3502
3737
|
if (measure && !audio.length) throw new Error("Nothing on the timeline makes a sound, so there is no loudness to measure.");
|
|
3503
3738
|
const loudness = options.loudness || measure ? `,${loudnormFilter(options.loudness, measure)}` : "";
|
|
@@ -3505,7 +3740,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
3505
3740
|
if (options.loudness && !audio.length) notes.push("Nothing makes a sound, so there was no loudness to normalise.");
|
|
3506
3741
|
else if (options.loudness && !options.loudness.measured)
|
|
3507
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.");
|
|
3508
|
-
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)))
|
|
3509
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.');
|
|
3510
3745
|
if (animated.size)
|
|
3511
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)" : ""}.`);
|
|
@@ -3515,7 +3750,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
3515
3750
|
const filterComplex = graph.join(";");
|
|
3516
3751
|
if (measure) {
|
|
3517
3752
|
const args2 = ["-hide_banner", "-nostats", ...inputs.flat(), "-filter_complex", filterComplex, "-map", "[aout]", "-f", "null", "-"];
|
|
3518
|
-
return { args: args2, command: ["ffmpeg", ...args2].map(
|
|
3753
|
+
return { args: args2, command: ["ffmpeg", ...args2].map(videoShellQuote).join(" "), filterComplex, duration: D, notes };
|
|
3519
3754
|
}
|
|
3520
3755
|
const args = [
|
|
3521
3756
|
"-y",
|
|
@@ -3536,7 +3771,333 @@ function buildFfmpeg(project, options, measure) {
|
|
|
3536
3771
|
sec(D),
|
|
3537
3772
|
options.output ?? "output.mp4"
|
|
3538
3773
|
];
|
|
3539
|
-
return { args, command: ["ffmpeg", ...args].map(
|
|
3774
|
+
return { args, command: ["ffmpeg", ...args].map(videoShellQuote).join(" "), filterComplex, duration: D, notes };
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
// src/video-editor/proxy.ts
|
|
3778
|
+
var VIDEO_PROXY_SCALES = [0.5, 0.25];
|
|
3779
|
+
function videoAssetProxy(asset, scale) {
|
|
3780
|
+
if (!asset?.proxies?.length || !(scale > 0) || scale >= 1) return null;
|
|
3781
|
+
let best = null;
|
|
3782
|
+
for (const p of asset.proxies) if (p.scale >= scale - 1e-9 && p.scale < 1 && (!best || p.scale < best.scale)) best = p;
|
|
3783
|
+
return best;
|
|
3784
|
+
}
|
|
3785
|
+
var videoPreviewSrc = (asset, scale, fallback = asset?.src ?? "") => videoAssetProxy(asset, scale)?.src ?? fallback;
|
|
3786
|
+
function resolveVideoPreviewScale(scale, shown) {
|
|
3787
|
+
if (scale === void 0 || scale === "auto") return Math.min(1, Math.max(0, shown));
|
|
3788
|
+
if (scale === "full") return 1;
|
|
3789
|
+
return Math.min(1, Math.max(0, scale));
|
|
3790
|
+
}
|
|
3791
|
+
function videoProxyName(src, scale = 0.5) {
|
|
3792
|
+
const clean = src.replace(/[?#].*$/, "");
|
|
3793
|
+
const base = clean.replace(/\.[^./\\]+$/, "");
|
|
3794
|
+
return `${base}.proxy-${Math.round(scale * 100)}.mp4`;
|
|
3795
|
+
}
|
|
3796
|
+
function videoProxyFfmpeg(asset, options = {}) {
|
|
3797
|
+
if (asset.kind !== "video") throw new Error(`Asset "${asset.id}" is ${asset.kind === "image" ? "an image" : "audio"}; only video assets have proxies.`);
|
|
3798
|
+
const scale = options.scale ?? 0.5;
|
|
3799
|
+
if (!(scale > 0 && scale < 1)) throw new Error(`A proxy's scale must be between 0 and 1 (not including either), not ${scale}; 0.5 is half size.`);
|
|
3800
|
+
const gop = Math.max(1, Math.round(options.gop ?? 12));
|
|
3801
|
+
const output = options.output ?? videoProxyName(asset.src, scale);
|
|
3802
|
+
const src = options.resolve ? options.resolve(asset) : asset.src;
|
|
3803
|
+
const size = `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`;
|
|
3804
|
+
const args = [
|
|
3805
|
+
"-y",
|
|
3806
|
+
"-i",
|
|
3807
|
+
src,
|
|
3808
|
+
"-vf",
|
|
3809
|
+
size,
|
|
3810
|
+
"-c:v",
|
|
3811
|
+
"libx264",
|
|
3812
|
+
"-preset",
|
|
3813
|
+
"veryfast",
|
|
3814
|
+
"-tune",
|
|
3815
|
+
"fastdecode",
|
|
3816
|
+
"-crf",
|
|
3817
|
+
String(options.crf ?? 28),
|
|
3818
|
+
"-g",
|
|
3819
|
+
String(gop),
|
|
3820
|
+
"-keyint_min",
|
|
3821
|
+
String(gop),
|
|
3822
|
+
"-sc_threshold",
|
|
3823
|
+
"0",
|
|
3824
|
+
"-bf",
|
|
3825
|
+
"0",
|
|
3826
|
+
"-pix_fmt",
|
|
3827
|
+
"yuv420p",
|
|
3828
|
+
...asset.hasAudio === false ? ["-an"] : ["-c:a", "aac", "-b:a", "128k"],
|
|
3829
|
+
"-movflags",
|
|
3830
|
+
"+faststart",
|
|
3831
|
+
output
|
|
3832
|
+
];
|
|
3833
|
+
const proxy = { scale, src: output, ...asset.width && asset.height ? { width: Math.trunc(asset.width * scale / 2) * 2, height: Math.trunc(asset.height * scale / 2) * 2 } : {} };
|
|
3834
|
+
return { asset: asset.id, args, command: ["ffmpeg", ...args].map(videoShellQuote).join(" "), proxy };
|
|
3835
|
+
}
|
|
3836
|
+
function videoProxyPlan(project, options = {}) {
|
|
3837
|
+
const scale = options.scale ?? 0.5;
|
|
3838
|
+
const wanted = options.assets ? new Set(options.assets) : null;
|
|
3839
|
+
if (options.assets) for (const id of options.assets) {
|
|
3840
|
+
const a = project.assets.find((x) => x.id === id);
|
|
3841
|
+
if (!a) throw new Error(`There is no asset "${id}"; the assets are ${project.assets.map((x) => `"${x.id}"`).join(", ") || "none"}.`);
|
|
3842
|
+
if (a.kind !== "video") throw new Error(`Asset "${id}" is ${a.kind === "image" ? "an image" : "audio"}; only video assets have proxies.`);
|
|
3843
|
+
}
|
|
3844
|
+
return project.assets.filter((a) => a.kind === "video" && (!wanted || wanted.has(a.id)) && !a.proxies?.some((p) => Math.abs(p.scale - scale) < 1e-9)).map((a) => videoProxyFfmpeg(a, { ...options, scale }));
|
|
3845
|
+
}
|
|
3846
|
+
var VIDEO_PREVIEW_SCALE_OPTIONS = [
|
|
3847
|
+
{ value: "auto", label: "Auto", scale: "auto" },
|
|
3848
|
+
{ value: "full", label: "Full", scale: "full" },
|
|
3849
|
+
...VIDEO_PROXY_SCALES.map((s) => ({ value: String(s), label: s === 0.5 ? "\xBD" : s === 0.25 ? "\xBC" : `${s * 100}%`, scale: s }))
|
|
3850
|
+
];
|
|
3851
|
+
var parseVideoPreviewScale = (value) => value === "full" ? "full" : Number(value) > 0 && Number(value) <= 1 ? Number(value) : "auto";
|
|
3852
|
+
var videoProjectHasProxies = (project) => project.assets.some((a) => !!a.proxies?.length);
|
|
3853
|
+
|
|
3854
|
+
// src/video-editor/frame-cache.ts
|
|
3855
|
+
var videoSourceFrame = (sourceTime, fps) => Math.max(0, Math.floor(sourceTime * fps / 1e3 + 1e-6));
|
|
3856
|
+
var videoSourceFrameTime = (frame, fps) => (frame + 0.5) * 1e3 / fps;
|
|
3857
|
+
var videoFrameCacheKey = (src, frame) => `${frame}@${src}`;
|
|
3858
|
+
function videoFrameRequestsAt(project, time, srcFor = (l) => l.src) {
|
|
3859
|
+
const out = [];
|
|
3860
|
+
for (const layer of videoFrameLeafLayers(videoFrameAt(project, time).layers)) {
|
|
3861
|
+
if (layer.type !== "media" || layer.assetKind !== "video") continue;
|
|
3862
|
+
const src = srcFor(layer);
|
|
3863
|
+
if (!src) continue;
|
|
3864
|
+
const frame = videoSourceFrame(layer.sourceTime ?? 0, project.fps);
|
|
3865
|
+
out.push({ key: videoFrameCacheKey(src, frame), src, frame, sourceTime: videoSourceFrameTime(frame, project.fps), time });
|
|
3866
|
+
}
|
|
3867
|
+
return out;
|
|
3868
|
+
}
|
|
3869
|
+
function planVideoPrefetch(project, time, options = {}) {
|
|
3870
|
+
const window2 = options.window ?? 2e3;
|
|
3871
|
+
const dir = options.direction ?? 1;
|
|
3872
|
+
const step = 1e3 / project.fps;
|
|
3873
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3874
|
+
const out = [];
|
|
3875
|
+
for (let i = 0; i * step <= window2 + 1e-6; i++) {
|
|
3876
|
+
const t = time + dir * i * step;
|
|
3877
|
+
if (t < 0) break;
|
|
3878
|
+
for (const r of videoFrameRequestsAt(project, t, options.srcFor)) {
|
|
3879
|
+
if (seen.has(r.key)) continue;
|
|
3880
|
+
seen.add(r.key);
|
|
3881
|
+
out.push(r);
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
return out;
|
|
3885
|
+
}
|
|
3886
|
+
function videoCacheEvictions(entries, options) {
|
|
3887
|
+
let total = entries.reduce((n2, e) => n2 + e.bytes, 0);
|
|
3888
|
+
if (total <= options.maxBytes) return [];
|
|
3889
|
+
const dir = options.direction ?? 1;
|
|
3890
|
+
const behind = options.behind ?? 2;
|
|
3891
|
+
const cost = (e) => {
|
|
3892
|
+
const d = (e.time - options.playhead) * dir;
|
|
3893
|
+
return d >= 0 ? d : -d * behind;
|
|
3894
|
+
};
|
|
3895
|
+
const out = [];
|
|
3896
|
+
for (const e of entries.filter((x) => !options.protect?.has(x.key)).sort((a, b) => cost(b) - cost(a))) {
|
|
3897
|
+
if (total <= options.maxBytes) break;
|
|
3898
|
+
out.push(e.key);
|
|
3899
|
+
total -= e.bytes;
|
|
3900
|
+
}
|
|
3901
|
+
return out;
|
|
3902
|
+
}
|
|
3903
|
+
var defaultCapture = async (source) => {
|
|
3904
|
+
if (typeof createImageBitmap !== "function") return null;
|
|
3905
|
+
const image = await createImageBitmap(source);
|
|
3906
|
+
return { image, width: image.width, height: image.height };
|
|
3907
|
+
};
|
|
3908
|
+
var VideoFrameCache = class {
|
|
3909
|
+
constructor(options = {}) {
|
|
3910
|
+
this.options = options;
|
|
3911
|
+
this.maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
|
|
3912
|
+
this.prefetch = options.prefetch ?? 2e3;
|
|
3913
|
+
this.decode = options.decode !== void 0 ? options.decode : typeof document !== "undefined" ? createVideoElementDecoder() : null;
|
|
3914
|
+
}
|
|
3915
|
+
options;
|
|
3916
|
+
entries = /* @__PURE__ */ new Map();
|
|
3917
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
3918
|
+
queue = [];
|
|
3919
|
+
protect = /* @__PURE__ */ new Set();
|
|
3920
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3921
|
+
playhead = 0;
|
|
3922
|
+
direction = 1;
|
|
3923
|
+
hits = 0;
|
|
3924
|
+
misses = 0;
|
|
3925
|
+
bytes = 0;
|
|
3926
|
+
generation = 0;
|
|
3927
|
+
destroyed = false;
|
|
3928
|
+
srcFor;
|
|
3929
|
+
maxBytes;
|
|
3930
|
+
prefetch;
|
|
3931
|
+
decode;
|
|
3932
|
+
/** The cached frame of `src` showing at a source time (ms), on the project's `fps` grid. Counts a hit or a miss. */
|
|
3933
|
+
get(src, sourceTime, fps) {
|
|
3934
|
+
const entry = this.entries.get(videoFrameCacheKey(src, videoSourceFrame(sourceTime, fps)));
|
|
3935
|
+
if (entry) this.hits++;
|
|
3936
|
+
else this.misses++;
|
|
3937
|
+
return entry?.frame.image;
|
|
3938
|
+
}
|
|
3939
|
+
/** Whether a frame is cached, without counting it. */
|
|
3940
|
+
has(src, sourceTime, fps) {
|
|
3941
|
+
return this.entries.has(videoFrameCacheKey(src, videoSourceFrame(sourceTime, fps)));
|
|
3942
|
+
}
|
|
3943
|
+
/** Keeps a decoded frame (replacing any with the same key), then evicts to the budget. */
|
|
3944
|
+
put(request, frame) {
|
|
3945
|
+
if (this.destroyed) return void frame.image.close?.();
|
|
3946
|
+
const old = this.entries.get(request.key);
|
|
3947
|
+
if (old) this.drop(old);
|
|
3948
|
+
const bytes = Math.max(1, frame.width * frame.height * 4);
|
|
3949
|
+
this.entries.set(request.key, { key: request.key, time: request.time, bytes, frame });
|
|
3950
|
+
this.bytes += bytes;
|
|
3951
|
+
this.evict();
|
|
3952
|
+
this.emit();
|
|
3953
|
+
}
|
|
3954
|
+
/** Keeps what a ready media element shows now as the frame of `src` at `sourceTime` (async; does nothing if it is cached). */
|
|
3955
|
+
capture(src, sourceTime, fps, time, source) {
|
|
3956
|
+
const frame = videoSourceFrame(sourceTime, fps);
|
|
3957
|
+
const key = videoFrameCacheKey(src, frame);
|
|
3958
|
+
if (this.entries.has(key) || this.inFlight.has(key)) return;
|
|
3959
|
+
this.inFlight.add(key);
|
|
3960
|
+
void (this.options.capture ?? defaultCapture)(source).then((f) => {
|
|
3961
|
+
if (f) this.put({ key, time }, f);
|
|
3962
|
+
}).catch(() => {
|
|
3963
|
+
}).finally(() => this.inFlight.delete(key));
|
|
3964
|
+
}
|
|
3965
|
+
/**
|
|
3966
|
+
* Follows the playhead: protects the prefetch window, evicts to the budget, and queues the
|
|
3967
|
+
* window's missing frames for decoding (nearest first). `srcFor` gives the file a layer plays.
|
|
3968
|
+
*/
|
|
3969
|
+
update(project, time, options = {}) {
|
|
3970
|
+
if (this.destroyed) return;
|
|
3971
|
+
this.direction = options.direction ?? (time < this.playhead && !options.playing ? -1 : 1);
|
|
3972
|
+
this.playhead = time;
|
|
3973
|
+
this.srcFor = options.srcFor;
|
|
3974
|
+
const plan = this.prefetch > 0 ? planVideoPrefetch(project, time, { window: this.prefetch, direction: this.direction, srcFor: options.srcFor }) : videoFrameRequestsAt(project, time, options.srcFor);
|
|
3975
|
+
this.protect = new Set(plan.map((r) => r.key));
|
|
3976
|
+
for (const r of plan) {
|
|
3977
|
+
const e = this.entries.get(r.key);
|
|
3978
|
+
if (e) e.time = r.time;
|
|
3979
|
+
}
|
|
3980
|
+
this.evict();
|
|
3981
|
+
this.generation++;
|
|
3982
|
+
this.queue = this.decode ? plan.filter((r) => !this.entries.has(r.key) && !this.inFlight.has(r.key)) : [];
|
|
3983
|
+
this.pump();
|
|
3984
|
+
}
|
|
3985
|
+
/**
|
|
3986
|
+
* The stretches of the timeline whose video frames are all cached, between `from` and `to` (ms),
|
|
3987
|
+
* for the files the preview last played (its `srcFor`, e.g. proxies) unless `srcFor` is given.
|
|
3988
|
+
* `step` (ms, at least a frame) samples more coarsely.
|
|
3989
|
+
*/
|
|
3990
|
+
cachedRanges(project, options = {}) {
|
|
3991
|
+
const step = Math.max(1e3 / project.fps, options.step ?? 0);
|
|
3992
|
+
const from = Math.max(0, options.from ?? 0);
|
|
3993
|
+
const to = options.to ?? Math.max(...project.tracks.flatMap((t) => t.clips.map((c) => c.start + c.duration)), 0);
|
|
3994
|
+
const out = [];
|
|
3995
|
+
for (let t = from; t < to - 1e-6; t += step) {
|
|
3996
|
+
const requests = videoFrameRequestsAt(project, t, options.srcFor ?? this.srcFor);
|
|
3997
|
+
const cached = requests.length > 0 && requests.every((r) => this.entries.has(r.key));
|
|
3998
|
+
const last = out[out.length - 1];
|
|
3999
|
+
if (!cached) continue;
|
|
4000
|
+
const end = Math.min(to, t + step);
|
|
4001
|
+
if (last && Math.abs(last.end - t) < 1e-6) last.end = end;
|
|
4002
|
+
else out.push({ start: t, end });
|
|
4003
|
+
}
|
|
4004
|
+
return out.map((r) => ({ start: Math.round(r.start * 10) / 10, end: Math.round(r.end * 10) / 10 }));
|
|
4005
|
+
}
|
|
4006
|
+
stats() {
|
|
4007
|
+
return { frames: this.entries.size, bytes: this.bytes, maxBytes: this.maxBytes, hits: this.hits, misses: this.misses, pending: this.inFlight.size + this.queue.length, prefetch: this.prefetch };
|
|
4008
|
+
}
|
|
4009
|
+
/** Called when frames arrive or leave. */
|
|
4010
|
+
subscribe(listener) {
|
|
4011
|
+
this.listeners.add(listener);
|
|
4012
|
+
return () => {
|
|
4013
|
+
this.listeners.delete(listener);
|
|
4014
|
+
};
|
|
4015
|
+
}
|
|
4016
|
+
/** Drops every frame (closing them) and the queue. */
|
|
4017
|
+
clear() {
|
|
4018
|
+
for (const e of [...this.entries.values()]) this.drop(e);
|
|
4019
|
+
this.queue = [];
|
|
4020
|
+
this.generation++;
|
|
4021
|
+
this.emit();
|
|
4022
|
+
}
|
|
4023
|
+
/** Clears and stops decoding for good. */
|
|
4024
|
+
destroy() {
|
|
4025
|
+
this.clear();
|
|
4026
|
+
this.destroyed = true;
|
|
4027
|
+
this.listeners.clear();
|
|
4028
|
+
}
|
|
4029
|
+
pump() {
|
|
4030
|
+
const limit = Math.max(1, this.options.concurrency ?? 2);
|
|
4031
|
+
while (this.decode && this.inFlight.size < limit && this.queue.length) {
|
|
4032
|
+
const r = this.queue.shift();
|
|
4033
|
+
if (this.entries.has(r.key) || this.inFlight.has(r.key)) continue;
|
|
4034
|
+
if (this.bytes >= this.maxBytes && [...this.entries.keys()].every((k) => this.protect.has(k))) {
|
|
4035
|
+
this.queue = [];
|
|
4036
|
+
break;
|
|
4037
|
+
}
|
|
4038
|
+
const generation = this.generation;
|
|
4039
|
+
this.inFlight.add(r.key);
|
|
4040
|
+
this.decode(r.src, r.sourceTime).then(
|
|
4041
|
+
(frame) => {
|
|
4042
|
+
this.inFlight.delete(r.key);
|
|
4043
|
+
if (generation === this.generation || this.bytes + frame.width * frame.height * 4 <= this.maxBytes) this.put(r, frame);
|
|
4044
|
+
else frame.image.close?.();
|
|
4045
|
+
},
|
|
4046
|
+
() => this.inFlight.delete(r.key)
|
|
4047
|
+
).finally(() => this.pump());
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
evict() {
|
|
4051
|
+
const drop = videoCacheEvictions([...this.entries.values()], { playhead: this.playhead, maxBytes: this.maxBytes, direction: this.direction, behind: this.options.behind, protect: this.protect });
|
|
4052
|
+
for (const key of drop) this.drop(this.entries.get(key));
|
|
4053
|
+
}
|
|
4054
|
+
drop(e) {
|
|
4055
|
+
this.entries.delete(e.key);
|
|
4056
|
+
this.bytes -= e.bytes;
|
|
4057
|
+
e.frame.image.close?.();
|
|
4058
|
+
}
|
|
4059
|
+
emit() {
|
|
4060
|
+
for (const l of [...this.listeners]) l();
|
|
4061
|
+
}
|
|
4062
|
+
};
|
|
4063
|
+
function createVideoElementDecoder(options = {}) {
|
|
4064
|
+
const videos = /* @__PURE__ */ new Map();
|
|
4065
|
+
const make = options.createVideo ?? (() => {
|
|
4066
|
+
const el = document.createElement("video");
|
|
4067
|
+
el.muted = true;
|
|
4068
|
+
el.preload = "auto";
|
|
4069
|
+
el.crossOrigin = "anonymous";
|
|
4070
|
+
el.playsInline = true;
|
|
4071
|
+
return el;
|
|
4072
|
+
});
|
|
4073
|
+
const timeout = options.timeout ?? 5e3;
|
|
4074
|
+
const once = (el, type) => new Promise((resolve, reject) => {
|
|
4075
|
+
const timer = setTimeout(() => reject(new Error(`The video did not ${type} within ${timeout} ms.`)), timeout);
|
|
4076
|
+
el.addEventListener(type, () => (clearTimeout(timer), resolve()), { once: true });
|
|
4077
|
+
el.addEventListener("error", () => (clearTimeout(timer), reject(new Error("The video could not be loaded."))), { once: true });
|
|
4078
|
+
});
|
|
4079
|
+
return (src, sourceTime) => {
|
|
4080
|
+
let v = videos.get(src);
|
|
4081
|
+
if (!v) {
|
|
4082
|
+
const el2 = make();
|
|
4083
|
+
el2.src = src;
|
|
4084
|
+
v = { el: el2, chain: el2.readyState >= 1 ? Promise.resolve() : once(el2, "loadedmetadata") };
|
|
4085
|
+
videos.set(src, v);
|
|
4086
|
+
}
|
|
4087
|
+
const { el } = v;
|
|
4088
|
+
const next = v.chain.then(async () => {
|
|
4089
|
+
const presented = "requestVideoFrameCallback" in el ? new Promise((r) => el.requestVideoFrameCallback(() => r())) : null;
|
|
4090
|
+
const seeked = once(el, "seeked");
|
|
4091
|
+
el.currentTime = sourceTime / 1e3;
|
|
4092
|
+
await seeked;
|
|
4093
|
+
if (presented) await Promise.race([presented, new Promise((r) => setTimeout(r, 100))]);
|
|
4094
|
+
const image = await createImageBitmap(el);
|
|
4095
|
+
return { image, width: image.width, height: image.height };
|
|
4096
|
+
});
|
|
4097
|
+
v.chain = next.catch(() => {
|
|
4098
|
+
});
|
|
4099
|
+
return next;
|
|
4100
|
+
};
|
|
3540
4101
|
}
|
|
3541
4102
|
|
|
3542
4103
|
// src/video-editor/preview.ts
|
|
@@ -3550,6 +4111,9 @@ var VideoPreviewEngine = class {
|
|
|
3550
4111
|
this.lutCache = options.luts !== void 0 ? options.luts : source.luts ?? null;
|
|
3551
4112
|
this.unsubscribeLuts = this.lutCache ? this.lutCache.subscribe(() => this.redraw()) : () => {
|
|
3552
4113
|
};
|
|
4114
|
+
this.frameCache = options.frameCache !== void 0 ? options.frameCache : source.frameCache ?? null;
|
|
4115
|
+
this.unsubscribeFrames = this.frameCache ? this.frameCache.subscribe(() => this.redraw()) : () => {
|
|
4116
|
+
};
|
|
3553
4117
|
const pixels = options.pixels !== void 0 ? options.pixels : source.previewPixels;
|
|
3554
4118
|
this.detachPixels = pixels ? pixels.attach(() => this.readPixels()) : () => {
|
|
3555
4119
|
};
|
|
@@ -3572,8 +4136,13 @@ var VideoPreviewEngine = class {
|
|
|
3572
4136
|
drawn = null;
|
|
3573
4137
|
complete = true;
|
|
3574
4138
|
lutCache;
|
|
4139
|
+
frameCache;
|
|
4140
|
+
unsubscribeFrames;
|
|
4141
|
+
srcFor = (layer) => layer.src;
|
|
4142
|
+
playing = false;
|
|
4143
|
+
fps = 30;
|
|
3575
4144
|
unsubscribeLuts;
|
|
3576
|
-
|
|
4145
|
+
scratchCanvases = /* @__PURE__ */ new Map();
|
|
3577
4146
|
/** The canvas's pixels and the frame they show, or `null` when its context cannot be read. */
|
|
3578
4147
|
readPixels() {
|
|
3579
4148
|
const ctx = this.ctx;
|
|
@@ -3622,8 +4191,15 @@ var VideoPreviewEngine = class {
|
|
|
3622
4191
|
/** Brings the media elements in step with the editor and draws the frame. */
|
|
3623
4192
|
update() {
|
|
3624
4193
|
if (this.destroyed) return;
|
|
3625
|
-
const
|
|
4194
|
+
const state = this.source.getState();
|
|
4195
|
+
const { project, time, playing } = state;
|
|
3626
4196
|
const frame = videoFrameAt(project, time);
|
|
4197
|
+
const scale = resolveVideoPreviewScale(this.options.previewScale ?? state.previewScale, this.resolutionFor(project.width));
|
|
4198
|
+
const assets = new Map(project.assets.map((a) => [a.id, a]));
|
|
4199
|
+
this.srcFor = (layer) => videoPreviewSrc(assets.get(layer.asset ?? ""), scale, layer.src);
|
|
4200
|
+
this.playing = playing;
|
|
4201
|
+
this.fps = project.fps;
|
|
4202
|
+
this.frameCache?.update(project, time, { playing, srcFor: this.srcFor });
|
|
3627
4203
|
const tolerance = (this.options.tolerance ?? 200) / 1e3;
|
|
3628
4204
|
const active = /* @__PURE__ */ new Set();
|
|
3629
4205
|
const sync = (clip, src, kind, sourceTime, speed, gain) => {
|
|
@@ -3647,10 +4223,10 @@ var VideoPreviewEngine = class {
|
|
|
3647
4223
|
}
|
|
3648
4224
|
};
|
|
3649
4225
|
const sounding = new Map(frame.audio.map((a) => [a.clip, a]));
|
|
3650
|
-
for (const layer of frame.layers) {
|
|
4226
|
+
for (const layer of videoFrameLeafLayers(frame.layers)) {
|
|
3651
4227
|
if (layer.type !== "media" || layer.assetKind !== "video" || !layer.src) continue;
|
|
3652
4228
|
const sound = sounding.get(layer.clip);
|
|
3653
|
-
sync(layer.clip, layer
|
|
4229
|
+
sync(layer.clip, this.srcFor(layer), "video", layer.sourceTime ?? 0, sound?.speed ?? 1, sound ? sound.gain : null);
|
|
3654
4230
|
sounding.delete(layer.clip);
|
|
3655
4231
|
}
|
|
3656
4232
|
for (const a of sounding.values()) sync(a.clip, a.src, "audio", a.sourceTime, a.speed, a.gain);
|
|
@@ -3671,15 +4247,29 @@ var VideoPreviewEngine = class {
|
|
|
3671
4247
|
return img.complete ? img : null;
|
|
3672
4248
|
}
|
|
3673
4249
|
const entry = this.media.get(layer.clip);
|
|
3674
|
-
|
|
4250
|
+
const ready = !!entry && entry.media.readyState >= 2;
|
|
4251
|
+
const cache = this.frameCache;
|
|
4252
|
+
const src = entry?.src ?? this.srcFor(layer);
|
|
4253
|
+
const sourceTime = layer.sourceTime ?? 0;
|
|
4254
|
+
if (cache && src && (!this.playing || !ready || entry.media.seeking)) {
|
|
4255
|
+
const hit = cache.get(src, sourceTime, this.fps);
|
|
4256
|
+
if (hit) return hit;
|
|
4257
|
+
}
|
|
4258
|
+
if (!ready) return null;
|
|
3675
4259
|
if (entry.media.seeking) this.complete = false;
|
|
4260
|
+
else if (cache && !this.playing) cache.capture(entry.src, sourceTime, this.fps, this.source.getState().time, entry.media);
|
|
3676
4261
|
return entry.media;
|
|
3677
4262
|
};
|
|
4263
|
+
/** Canvas pixels per frame pixel: the option, else the displayed width at the device pixel ratio (at most 1). */
|
|
4264
|
+
resolutionFor(frameWidth) {
|
|
4265
|
+
if (this.options.resolution !== void 0) return this.options.resolution;
|
|
4266
|
+
const shown = this.canvas.clientWidth || frameWidth;
|
|
4267
|
+
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
|
4268
|
+
return Math.min(1, shown * dpr / frameWidth);
|
|
4269
|
+
}
|
|
3678
4270
|
draw(frame) {
|
|
3679
4271
|
if (!this.ctx) return;
|
|
3680
|
-
const
|
|
3681
|
-
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
|
3682
|
-
const resolution = this.options.resolution ?? Math.min(1, shown * dpr / frame.width);
|
|
4272
|
+
const resolution = this.resolutionFor(frame.width);
|
|
3683
4273
|
const w = Math.max(1, Math.round(frame.width * resolution));
|
|
3684
4274
|
const h = Math.max(1, Math.round(frame.height * resolution));
|
|
3685
4275
|
if (this.canvas.width !== w) this.canvas.width = w;
|
|
@@ -3701,16 +4291,18 @@ var VideoPreviewEngine = class {
|
|
|
3701
4291
|
if (!lut) this.complete = false;
|
|
3702
4292
|
return lut;
|
|
3703
4293
|
};
|
|
3704
|
-
scratch = (width, height) => {
|
|
3705
|
-
if (this.
|
|
3706
|
-
|
|
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);
|
|
3707
4298
|
else if (typeof document !== "undefined") {
|
|
3708
4299
|
const canvas2 = document.createElement("canvas");
|
|
3709
4300
|
const ctx = canvas2.getContext("2d", { willReadFrequently: true });
|
|
3710
|
-
|
|
3711
|
-
}
|
|
4301
|
+
made = ctx ? { canvas: canvas2, ctx } : null;
|
|
4302
|
+
}
|
|
4303
|
+
this.scratchCanvases.set(slot, made);
|
|
3712
4304
|
}
|
|
3713
|
-
const s = this.
|
|
4305
|
+
const s = this.scratchCanvases.get(slot);
|
|
3714
4306
|
if (!s) return null;
|
|
3715
4307
|
const c = s.canvas;
|
|
3716
4308
|
if (c.width !== width) c.width = width;
|
|
@@ -3738,6 +4330,7 @@ var VideoPreviewEngine = class {
|
|
|
3738
4330
|
this.destroyed = true;
|
|
3739
4331
|
this.detachPixels();
|
|
3740
4332
|
this.unsubscribeLuts();
|
|
4333
|
+
this.unsubscribeFrames();
|
|
3741
4334
|
this.filterHost?.remove();
|
|
3742
4335
|
this.filterHost = null;
|
|
3743
4336
|
this.filters.clear();
|
|
@@ -3757,10 +4350,13 @@ var VideoEditorController = class {
|
|
|
3757
4350
|
previewPixels = new VideoPreviewPixels();
|
|
3758
4351
|
/** Grades' LUTs, loaded on demand for the preview, SVG frames and agents. */
|
|
3759
4352
|
luts;
|
|
4353
|
+
/** The host's frame cache, if it gave one (the preview fills and reads it). */
|
|
4354
|
+
frameCache;
|
|
3760
4355
|
project;
|
|
3761
4356
|
issues;
|
|
3762
4357
|
selection = [];
|
|
3763
4358
|
readOnly;
|
|
4359
|
+
previewScale;
|
|
3764
4360
|
error = null;
|
|
3765
4361
|
revision = 0;
|
|
3766
4362
|
applying = false;
|
|
@@ -3772,9 +4368,11 @@ var VideoEditorController = class {
|
|
|
3772
4368
|
this.history = options.history ?? new HistoryManager();
|
|
3773
4369
|
this.audioPeaks = options.audioPeaks instanceof VideoAudioPeaksCache ? options.audioPeaks : new VideoAudioPeaksCache(options.audioPeaks);
|
|
3774
4370
|
this.luts = options.luts instanceof VideoLutCache ? options.luts : new VideoLutCache(options.luts);
|
|
4371
|
+
this.frameCache = options.frameCache ?? null;
|
|
3775
4372
|
this.project = options.project ? parseVideoProject(options.project) : createVideoProject();
|
|
3776
4373
|
this.issues = checkVideoProject(this.project);
|
|
3777
4374
|
this.readOnly = options.readOnly ?? false;
|
|
4375
|
+
this.previewScale = checkPreviewScale(options.previewScale ?? "auto");
|
|
3778
4376
|
this.onChange = options.onChange;
|
|
3779
4377
|
this.player = new AnimationPlayer({ clock: options.clock, duration: () => videoProjectDuration(this.project), loop: "none" });
|
|
3780
4378
|
this.state = this.computeState();
|
|
@@ -3824,6 +4422,7 @@ var VideoEditorController = class {
|
|
|
3824
4422
|
canRedo: h.canRedo,
|
|
3825
4423
|
undoLabel: h.undoName,
|
|
3826
4424
|
redoLabel: h.redoName,
|
|
4425
|
+
previewScale: this.previewScale,
|
|
3827
4426
|
revision: this.revision
|
|
3828
4427
|
};
|
|
3829
4428
|
}
|
|
@@ -3913,6 +4512,17 @@ var VideoEditorController = class {
|
|
|
3913
4512
|
this.readOnly = readOnly;
|
|
3914
4513
|
this.notify();
|
|
3915
4514
|
}
|
|
4515
|
+
/**
|
|
4516
|
+
* Sets how large a picture the preview plays: `full` (the originals), a fraction such as 0.5
|
|
4517
|
+
* (the smallest proxy at least that large, else the original) or `auto` (as large as the canvas
|
|
4518
|
+
* shows). A view setting, not part of the project: no undo step.
|
|
4519
|
+
*/
|
|
4520
|
+
setPreviewScale(scale) {
|
|
4521
|
+
const next = checkPreviewScale(scale);
|
|
4522
|
+
if (next === this.previewScale) return;
|
|
4523
|
+
this.previewScale = next;
|
|
4524
|
+
this.notify();
|
|
4525
|
+
}
|
|
3916
4526
|
/** Selects clips (unknown ids are dropped). `add` toggles them into the current selection. */
|
|
3917
4527
|
select(clips, options = {}) {
|
|
3918
4528
|
const known = clips?.filter((id) => findVideoClip(this.project, id)) ?? [];
|
|
@@ -3997,6 +4607,11 @@ var VideoEditorController = class {
|
|
|
3997
4607
|
return toVideoEdl(this.project, options);
|
|
3998
4608
|
}
|
|
3999
4609
|
};
|
|
4610
|
+
function checkPreviewScale(scale) {
|
|
4611
|
+
if (scale === "auto" || scale === "full") return scale;
|
|
4612
|
+
if (typeof scale !== "number" || !(scale > 0 && scale <= 1)) throw new Error(`A preview scale is "auto", "full" or a fraction above 0 up to 1 (0.5 is half size), not ${JSON.stringify(scale)}.`);
|
|
4613
|
+
return scale === 1 ? "full" : scale;
|
|
4614
|
+
}
|
|
4000
4615
|
|
|
4001
4616
|
// src/video-editor/ui.ts
|
|
4002
4617
|
var STEPS = [40, 100, 200, 500, 1e3, 2e3, 5e3, 1e4, 15e3, 3e4, 6e4, 12e4, 3e5, 6e5];
|
|
@@ -4058,14 +4673,16 @@ var VIDEO_FADE_CURVE_LABELS = {
|
|
|
4058
4673
|
};
|
|
4059
4674
|
function videoClipTint(clip, asset) {
|
|
4060
4675
|
if (clip.type === "text") return "#a855f7";
|
|
4676
|
+
if (clip.type === "sequence") return "#0d9488";
|
|
4061
4677
|
if (clip.type === "color") return clip.color;
|
|
4062
4678
|
if (!asset) return "#ef4444";
|
|
4063
4679
|
return asset.kind === "audio" ? "#10b981" : asset.kind === "image" ? "#f59e0b" : "#3b82f6";
|
|
4064
4680
|
}
|
|
4065
|
-
function videoClipLabel(clip, asset) {
|
|
4681
|
+
function videoClipLabel(clip, asset, sequence) {
|
|
4066
4682
|
if (clip.name) return clip.name;
|
|
4067
4683
|
if (clip.type === "text") return clip.text.split("\n")[0];
|
|
4068
4684
|
if (clip.type === "color") return clip.color;
|
|
4685
|
+
if (clip.type === "sequence") return `Compound: ${sequence?.name ?? clip.sequence}`;
|
|
4069
4686
|
const base = asset?.name ?? asset?.src.replace(/^.*[\\/]/, "") ?? clip.asset;
|
|
4070
4687
|
const speed = videoClipSpeed(clip);
|
|
4071
4688
|
return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
|
|
@@ -4159,7 +4776,7 @@ var KEY_LABELS = {
|
|
|
4159
4776
|
scale: { label: "Scale", step: 0.05 },
|
|
4160
4777
|
rotation: { label: "Rotation", step: 1 }
|
|
4161
4778
|
};
|
|
4162
|
-
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;
|
|
4163
4780
|
var playheadInClip = (clip, time) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));
|
|
4164
4781
|
function videoKeyframeRows(project, clipId, time) {
|
|
4165
4782
|
const at = findVideoClip(project, clipId);
|
|
@@ -4167,7 +4784,7 @@ function videoKeyframeRows(project, clipId, time) {
|
|
|
4167
4784
|
const { clip } = at;
|
|
4168
4785
|
const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
|
|
4169
4786
|
const visual = !(asset?.kind === "audio" || at.track.kind === "audio");
|
|
4170
|
-
const audible = clip.type === "media" && videoAssetHasSound(asset);
|
|
4787
|
+
const audible = clip.type === "media" && videoAssetHasSound(asset) || clip.type === "sequence";
|
|
4171
4788
|
const properties = [...visual ? ["opacity"] : [], ...audible ? ["volume"] : [], ...visual ? VIDEO_TRANSFORM_PROPERTIES : []];
|
|
4172
4789
|
const clipTime = playheadInClip(clip, time);
|
|
4173
4790
|
return properties.map((property) => {
|
|
@@ -4400,6 +5017,34 @@ var videoAssetOut = (a) => {
|
|
|
4400
5017
|
const { transcript, ...rest } = a;
|
|
4401
5018
|
return transcript ? { ...rest, transcriptWords: transcript.length } : rest;
|
|
4402
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
|
+
}
|
|
4403
5048
|
function describeVideoProject(project) {
|
|
4404
5049
|
return {
|
|
4405
5050
|
...project.name ? { name: project.name } : {},
|
|
@@ -4409,18 +5054,8 @@ function describeVideoProject(project) {
|
|
|
4409
5054
|
duration: videoProjectDuration(project),
|
|
4410
5055
|
...project.background ? { background: project.background } : {},
|
|
4411
5056
|
assets: project.assets.map(videoAssetOut),
|
|
4412
|
-
tracks: project.tracks
|
|
4413
|
-
|
|
4414
|
-
kind: t.kind,
|
|
4415
|
-
index,
|
|
4416
|
-
...t.name ? { name: t.name } : {},
|
|
4417
|
-
...t.muted ? { muted: true } : {},
|
|
4418
|
-
...t.hidden ? { hidden: true } : {},
|
|
4419
|
-
...t.locked ? { locked: true } : {},
|
|
4420
|
-
...t.syncLocked === false ? { syncLocked: false } : {},
|
|
4421
|
-
...t.volume !== void 0 ? { volume: t.volume } : {},
|
|
4422
|
-
clips: sortedVideoClips(t).map((c) => ({ ...c, end: videoClipEnd(c), ...c.type === "media" ? { source: videoClipSourceRange(c) } : {} }))
|
|
4423
|
-
})),
|
|
5057
|
+
tracks: describeVideoTracks(project.tracks),
|
|
5058
|
+
...project.sequences?.length ? { sequences: project.sequences.map((q) => describeVideoSequence(project, q, false)) } : {},
|
|
4424
5059
|
markers: project.markers ?? []
|
|
4425
5060
|
};
|
|
4426
5061
|
}
|
|
@@ -4442,7 +5077,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4442
5077
|
const clipOut = (p, id) => {
|
|
4443
5078
|
for (const t of p.tracks) {
|
|
4444
5079
|
const c = t.clips.find((x) => x.id === id);
|
|
4445
|
-
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) } : {} };
|
|
4446
5081
|
}
|
|
4447
5082
|
return null;
|
|
4448
5083
|
};
|
|
@@ -4460,19 +5095,19 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4460
5095
|
const getProject = {
|
|
4461
5096
|
name: "get_video_project",
|
|
4462
5097
|
title: "Read the video project",
|
|
4463
|
-
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.`,
|
|
4464
5099
|
inputSchema: object({}),
|
|
4465
5100
|
annotations: { title: "Read the video project", ...READ },
|
|
4466
5101
|
handler: () => {
|
|
4467
5102
|
const state = controller.getState();
|
|
4468
5103
|
const reason = readOnlyReason();
|
|
4469
|
-
return { ...describeVideoProject(state.project), playhead: state.time, selection: state.selection, issues: issueSummary(state.issues), editable: !reason, ...reason ? { readOnlyReason: reason } : {} };
|
|
5104
|
+
return { ...describeVideoProject(state.project), playhead: state.time, previewScale: state.previewScale, selection: state.selection, issues: issueSummary(state.issues), editable: !reason, ...reason ? { readOnlyReason: reason } : {} };
|
|
4470
5105
|
}
|
|
4471
5106
|
};
|
|
4472
5107
|
const getFrame = {
|
|
4473
5108
|
name: "get_video_frame",
|
|
4474
5109
|
title: "What plays at a moment",
|
|
4475
|
-
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".`,
|
|
4476
5111
|
inputSchema: object({ time: MS("Time from the start (default: the playhead)") }),
|
|
4477
5112
|
annotations: { title: "What plays at a moment", ...READ },
|
|
4478
5113
|
handler: ({ time }) => {
|
|
@@ -4480,6 +5115,23 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4480
5115
|
return { ...frame, timecode: formatVideoTimecode(frame.time, project().fps) };
|
|
4481
5116
|
}
|
|
4482
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
|
+
};
|
|
4483
5135
|
const findGaps = {
|
|
4484
5136
|
name: "find_video_gaps",
|
|
4485
5137
|
title: "Find gaps",
|
|
@@ -4634,7 +5286,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4634
5286
|
name: "update_video_asset",
|
|
4635
5287
|
title: "Change media details",
|
|
4636
5288
|
description: "Changes an asset's src, name, duration, size, sound or poster (e.g. relinking moved media). Clips that now run past its end are refused. Returns the asset.",
|
|
4637
|
-
inputSchema: object({ asset: ASSET_ID, set: object(ASSET_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "duration", "width", "height", "hasAudio", "poster", "transcript"] }, description: "Fields to clear (transcript removes its words)." } }, ["asset"]),
|
|
5289
|
+
inputSchema: object({ asset: ASSET_ID, set: object(ASSET_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "duration", "width", "height", "hasAudio", "poster", "transcript", "proxies"] }, description: "Fields to clear (transcript removes its words, proxies its proxies)." } }, ["asset"]),
|
|
4638
5290
|
annotations: { title: "Change media details", ...WRITE_IDEMPOTENT },
|
|
4639
5291
|
handler: ({ asset, set, remove }) => apply({ op: "update_asset", asset, set, remove }, "Change media", (r) => ({ asset: videoAssetOut(r.project.assets.find((a) => a.id === asset)) }))
|
|
4640
5292
|
};
|
|
@@ -4730,7 +5382,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4730
5382
|
const updateClip = {
|
|
4731
5383
|
name: "update_video_clip",
|
|
4732
5384
|
title: "Change a clip",
|
|
4733
|
-
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.`,
|
|
4734
5386
|
inputSchema: object(
|
|
4735
5387
|
{
|
|
4736
5388
|
clip: CLIP_ID,
|
|
@@ -4743,7 +5395,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4743
5395
|
handler: ({ clip, set, remove }) => {
|
|
4744
5396
|
const current = clipOut(project(), clip);
|
|
4745
5397
|
if (current && set) {
|
|
4746
|
-
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"]]);
|
|
4747
5399
|
const wrong = Object.keys(set).filter((k) => !allowed.has(k));
|
|
4748
5400
|
if (wrong.length) throw new Error(`${wrong.map((k) => `"${k}"`).join(", ")} cannot be set on a ${current.type} clip; it takes ${[...allowed].join(", ")}.`);
|
|
4749
5401
|
}
|
|
@@ -4832,6 +5484,79 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4832
5484
|
return apply(videoLutEdits(p, withLut, null), "Remove LUT", (r) => ({ clips: clips.map((id) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));
|
|
4833
5485
|
}
|
|
4834
5486
|
};
|
|
5487
|
+
const PROXY_SCALE = { type: "number", exclusiveMinimum: 0, exclusiveMaximum: 1, description: "Proxy size as a fraction of the original: 0.5 (half width and height, the default) or 0.25 for very large footage." };
|
|
5488
|
+
const planProxies = {
|
|
5489
|
+
name: "plan_video_proxies",
|
|
5490
|
+
title: "Plan proxy files",
|
|
5491
|
+
description: 'Returns the ffmpeg commands that make smaller proxy copies of video assets for smooth preview playback, without changing anything: one per video asset (or each of "assets") that has no proxy at that scale yet, each with { asset, args, command, proxy }. Proxies are scaled, H.264 with a keyframe every few frames so scrubbing is quick, and keep the sound. After the host runs a command, attach its "proxy" with set_video_proxies. The export always uses the originals.',
|
|
5492
|
+
inputSchema: object({
|
|
5493
|
+
scale: PROXY_SCALE,
|
|
5494
|
+
assets: { type: "array", items: ASSET_ID, minItems: 1, uniqueItems: true, description: "Only these video assets (default: every video asset without a proxy at this scale)." },
|
|
5495
|
+
gop: { type: "integer", minimum: 1, maximum: 300, description: "Frames between keyframes (default 12): fewer makes seeking cheaper and files larger." },
|
|
5496
|
+
crf: { type: "integer", minimum: 0, maximum: 51, description: "x264 quality, lower is better (default 28: proxies are for editing)." }
|
|
5497
|
+
}),
|
|
5498
|
+
annotations: { title: "Plan proxy files", ...READ },
|
|
5499
|
+
handler: ({ scale, assets, gop, crf }) => {
|
|
5500
|
+
const commands = videoProxyPlan(project(), { scale, assets, gop, crf });
|
|
5501
|
+
return { scale: scale ?? 0.5, commands, ...commands.length ? {} : { note: "Every video asset already has a proxy at this scale." } };
|
|
5502
|
+
}
|
|
5503
|
+
};
|
|
5504
|
+
const setProxies = {
|
|
5505
|
+
name: "set_video_proxies",
|
|
5506
|
+
title: "Attach proxies",
|
|
5507
|
+
description: "Sets a video asset's proxies, the smaller copies the preview plays instead of the original (one undo step); an empty list removes them. Each proxy must match the original's length and timing (plan_video_proxies makes such files). The export, EDL and source times always use the original. Returns the asset.",
|
|
5508
|
+
inputSchema: object(
|
|
5509
|
+
{
|
|
5510
|
+
asset: { ...ASSET_ID, description: "A video asset id from get_video_project." },
|
|
5511
|
+
proxies: {
|
|
5512
|
+
type: "array",
|
|
5513
|
+
maxItems: 8,
|
|
5514
|
+
description: "Its proxies, one per scale (empty removes them all).",
|
|
5515
|
+
items: object({ scale: PROXY_SCALE, src: { type: "string", minLength: 1, description: "Where the preview loads the proxy from (URL or path)." }, width: { type: "number", exclusiveMinimum: 0, description: "Its width in pixels." }, height: { type: "number", exclusiveMinimum: 0, description: "Its height in pixels." } }, ["scale", "src"], "One proxy.")
|
|
5516
|
+
}
|
|
5517
|
+
},
|
|
5518
|
+
["asset", "proxies"]
|
|
5519
|
+
),
|
|
5520
|
+
annotations: { title: "Attach proxies", ...WRITE_IDEMPOTENT },
|
|
5521
|
+
handler: ({ asset, proxies }) => {
|
|
5522
|
+
const a = project().assets.find((x) => x.id === asset);
|
|
5523
|
+
if (!a) throw new Error(`There is no asset "${asset}"; the assets are ${project().assets.map((x) => `"${x.id}"`).join(", ") || "none"}.`);
|
|
5524
|
+
if (a.kind !== "video") throw new Error(`Asset "${asset}" is ${a.kind === "image" ? "an image" : "audio"}; only video assets have proxies.`);
|
|
5525
|
+
const edit = proxies.length ? { op: "update_asset", asset, set: { proxies } } : { op: "update_asset", asset, remove: ["proxies"] };
|
|
5526
|
+
return apply(edit, proxies.length ? "Attach proxies" : "Remove proxies", (r) => ({ asset: videoAssetOut(r.project.assets.find((x) => x.id === asset)) }));
|
|
5527
|
+
}
|
|
5528
|
+
};
|
|
5529
|
+
const setPreviewScale = {
|
|
5530
|
+
name: "set_video_preview_scale",
|
|
5531
|
+
title: "Set the preview size",
|
|
5532
|
+
description: 'Sets how large a picture the preview plays, without changing the project (no undo step): "full" plays the originals, a fraction (0.5, 0.25) plays the smallest proxy at least that large (or the original where there is none), "auto" (the default) picks by the size the preview is shown at. Use a proxy scale when playback stutters on large footage. Returns the setting and, for each video asset, what the preview plays at a fixed scale.',
|
|
5533
|
+
inputSchema: object(
|
|
5534
|
+
{ scale: { anyOf: [{ type: "string", enum: ["auto", "full"] }, { type: "number", exclusiveMinimum: 0, maximum: 1 }], description: '"auto", "full", or a fraction above 0 up to 1 (0.5 is half size).' } },
|
|
5535
|
+
["scale"]
|
|
5536
|
+
),
|
|
5537
|
+
annotations: { title: "Set the preview size", ...WRITE_IDEMPOTENT },
|
|
5538
|
+
handler: ({ scale }) => {
|
|
5539
|
+
controller.setPreviewScale(scale);
|
|
5540
|
+
const s = controller.getState().previewScale;
|
|
5541
|
+
const fixed = s === "full" ? 1 : typeof s === "number" ? s : null;
|
|
5542
|
+
const plays = fixed === null ? void 0 : project().assets.filter((a) => a.kind === "video").map((a) => ({ asset: a.id, src: videoAssetProxy(a, fixed)?.src ?? a.src, proxy: !!videoAssetProxy(a, fixed) }));
|
|
5543
|
+
return { previewScale: s, ...plays ? { plays } : { note: "auto picks the smallest proxy at least as large as the preview canvas shows, so it depends on the preview's size." } };
|
|
5544
|
+
}
|
|
5545
|
+
};
|
|
5546
|
+
const previewCache = {
|
|
5547
|
+
name: "get_video_preview_cache",
|
|
5548
|
+
title: "Read the preview's frame cache",
|
|
5549
|
+
description: `Reports the preview's cache of decoded frames, without changing anything: whether the host has one, how many frames and bytes it holds against its budget, hits and misses, frames still decoding, how far ahead it prefetches (ms), and the stretches of the timeline between "from" and "to" whose frames are all cached (these play and scrub without waiting). Use it to tell whether stuttering is the cache filling, and before asking the person to review a passage. With no cache, says so.`,
|
|
5550
|
+
inputSchema: object({ from: MS("Start of the stretch to report cached ranges for (default 0)"), to: MS("End of it (default: the end of the project)") }),
|
|
5551
|
+
annotations: { title: "Read the preview's frame cache", ...READ },
|
|
5552
|
+
handler: ({ from, to }) => {
|
|
5553
|
+
const cache = controller.frameCache;
|
|
5554
|
+
if (!cache) return { enabled: false, reason: "The host has not given this editor a frame cache (VideoEditorController frameCache), so the preview decodes each frame as it plays." };
|
|
5555
|
+
if (from !== void 0 && to !== void 0 && !(to > from)) throw new Error(`"to" (${to} ms) must be after "from" (${from} ms).`);
|
|
5556
|
+
const ranges = cache.cachedRanges(project(), { from, to });
|
|
5557
|
+
return { enabled: true, ...cache.stats(), cachedRanges: ranges };
|
|
5558
|
+
}
|
|
5559
|
+
};
|
|
4835
5560
|
const TRANSCRIPT_TRACK = { ...TRACK_ID, description: "Track whose words to read (default: the first audio track with a transcribed clip, else the first video track with one)." };
|
|
4836
5561
|
const RANGE_OUT = (r) => ({ from: round(r.from), to: round(r.to), text: r.text });
|
|
4837
5562
|
const timelineWords = (track) => {
|
|
@@ -4944,6 +5669,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4944
5669
|
const at = findVideoClip(project(), id);
|
|
4945
5670
|
if (!at) throw new Error(`There is no clip "${id}"; get_video_project lists the clips.`);
|
|
4946
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.`);
|
|
4947
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.`);
|
|
4948
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.`);
|
|
4949
5675
|
return clip;
|
|
@@ -5179,6 +5905,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
5179
5905
|
const found = findVideoClip(p, args.clip);
|
|
5180
5906
|
if (!found) throw new Error(`There is no clip "${args.clip}"; get_video_project lists the clips.`);
|
|
5181
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).`);
|
|
5182
5909
|
if (c.type !== "media") throw new Error(`Clip "${c.id}" is a ${c.type} clip and has no sound; pass a media clip.`);
|
|
5183
5910
|
asset = p.assets.find((a) => a.id === c.asset);
|
|
5184
5911
|
const speed = c.speed ?? 1;
|
|
@@ -5273,8 +6000,8 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
5273
6000
|
return { time: t, timecode: formatVideoTimecode(t, p.fps), width: pixels.width, height: pixels.height, ...summarizeVideoScopes(pixels, { bins, columns }) };
|
|
5274
6001
|
}
|
|
5275
6002
|
};
|
|
5276
|
-
const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
|
|
5277
|
-
const writes = [setSettings, addAsset, updateAsset, removeAsset, 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];
|
|
6003
|
+
const reads = [getProject, listSequences, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, previewCache, planProxies, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek, setPreviewScale];
|
|
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];
|
|
5278
6005
|
return {
|
|
5279
6006
|
id: options.id ?? "video",
|
|
5280
6007
|
namespace: options.namespace,
|
|
@@ -5315,6 +6042,8 @@ export {
|
|
|
5315
6042
|
VIDEO_LUT_MAX_SIZE,
|
|
5316
6043
|
VIDEO_PEAK_BLOCK,
|
|
5317
6044
|
VIDEO_PLACEMENT_MODES,
|
|
6045
|
+
VIDEO_PREVIEW_SCALE_OPTIONS,
|
|
6046
|
+
VIDEO_PROXY_SCALES,
|
|
5318
6047
|
VIDEO_SCOPE_KINDS,
|
|
5319
6048
|
VIDEO_TIME_EPSILON,
|
|
5320
6049
|
VIDEO_TRANSFORM_PROPERTIES,
|
|
@@ -5324,10 +6053,12 @@ export {
|
|
|
5324
6053
|
VIDEO_WIPE_DIRECTIONS,
|
|
5325
6054
|
VideoAudioPeaksCache,
|
|
5326
6055
|
VideoEditorController,
|
|
6056
|
+
VideoFrameCache,
|
|
5327
6057
|
VideoLutCache,
|
|
5328
6058
|
VideoPreviewEngine,
|
|
5329
6059
|
VideoPreviewPixels,
|
|
5330
6060
|
allVideoClipIds,
|
|
6061
|
+
allVideoTracks,
|
|
5331
6062
|
applyVideoEdits,
|
|
5332
6063
|
applyVideoGrade,
|
|
5333
6064
|
applyVideoLut,
|
|
@@ -5336,15 +6067,19 @@ export {
|
|
|
5336
6067
|
checkVideoProject,
|
|
5337
6068
|
checkVideoTimeRemap,
|
|
5338
6069
|
createVideoEditorToolProvider,
|
|
6070
|
+
createVideoElementDecoder,
|
|
5339
6071
|
createVideoProject,
|
|
5340
6072
|
decodeVideoAudioPeaks,
|
|
5341
6073
|
describeVideoProject,
|
|
6074
|
+
describeVideoSequence,
|
|
5342
6075
|
drawVideoFrame,
|
|
5343
6076
|
ffmpegFilterValue,
|
|
5344
6077
|
findVideoAudioSilences,
|
|
5345
6078
|
findVideoClip,
|
|
5346
6079
|
findVideoFillers,
|
|
5347
6080
|
findVideoGaps,
|
|
6081
|
+
findVideoSequenceClip,
|
|
6082
|
+
findVideoSequenceCycle,
|
|
5348
6083
|
findVideoTranscriptPauses,
|
|
5349
6084
|
findVideoTranscriptText,
|
|
5350
6085
|
formatVideoSeconds,
|
|
@@ -5359,6 +6094,7 @@ export {
|
|
|
5359
6094
|
normalizeVideoWord,
|
|
5360
6095
|
parseVideoAnimatable,
|
|
5361
6096
|
parseVideoAsset,
|
|
6097
|
+
parseVideoAssetProxies,
|
|
5362
6098
|
parseVideoClip,
|
|
5363
6099
|
parseVideoClipAnimation,
|
|
5364
6100
|
parseVideoCubeLut,
|
|
@@ -5367,7 +6103,9 @@ export {
|
|
|
5367
6103
|
parseVideoKeyframes,
|
|
5368
6104
|
parseVideoLoudnessMeasurement,
|
|
5369
6105
|
parseVideoMarker,
|
|
6106
|
+
parseVideoPreviewScale,
|
|
5370
6107
|
parseVideoProject,
|
|
6108
|
+
parseVideoSequence,
|
|
5371
6109
|
parseVideoTimeRemap,
|
|
5372
6110
|
parseVideoTrack,
|
|
5373
6111
|
parseVideoTranscript,
|
|
@@ -5376,9 +6114,11 @@ export {
|
|
|
5376
6114
|
parseVideoTransition,
|
|
5377
6115
|
placeVideoRect,
|
|
5378
6116
|
planVideoClipDrag,
|
|
6117
|
+
planVideoPrefetch,
|
|
5379
6118
|
probeVideoMedia,
|
|
5380
6119
|
readVideoScopesPanel,
|
|
5381
6120
|
removeVideoKey,
|
|
6121
|
+
resolveVideoPreviewScale,
|
|
5382
6122
|
retimeVideoAnimatable,
|
|
5383
6123
|
setVideoKey,
|
|
5384
6124
|
snapToVideoFrame,
|
|
@@ -5394,10 +6134,12 @@ export {
|
|
|
5394
6134
|
videoAssetHasSound,
|
|
5395
6135
|
videoAssetKindOf,
|
|
5396
6136
|
videoAssetMap,
|
|
6137
|
+
videoAssetProxy,
|
|
5397
6138
|
videoAudioLevels,
|
|
5398
6139
|
videoAudioPeakRange,
|
|
5399
6140
|
videoAudioPeakSpans,
|
|
5400
6141
|
videoAudioPeaksDuration,
|
|
6142
|
+
videoCacheEvictions,
|
|
5401
6143
|
videoChroma,
|
|
5402
6144
|
videoClipAfter,
|
|
5403
6145
|
videoClipAnimated,
|
|
@@ -5428,7 +6170,10 @@ export {
|
|
|
5428
6170
|
videoExposureStats,
|
|
5429
6171
|
videoFadeCurveGain,
|
|
5430
6172
|
videoFrameAt,
|
|
6173
|
+
videoFrameCacheKey,
|
|
5431
6174
|
videoFrameDuration,
|
|
6175
|
+
videoFrameLeafLayers,
|
|
6176
|
+
videoFrameRequestsAt,
|
|
5432
6177
|
videoFrameToSvg,
|
|
5433
6178
|
videoFrames,
|
|
5434
6179
|
videoFreezeFrameEdit,
|
|
@@ -5462,13 +6207,24 @@ export {
|
|
|
5462
6207
|
videoLutToCube,
|
|
5463
6208
|
videoOverlap,
|
|
5464
6209
|
videoPeakDb,
|
|
6210
|
+
videoPreviewSrc,
|
|
5465
6211
|
videoProjectDuration,
|
|
6212
|
+
videoProjectHasProxies,
|
|
5466
6213
|
videoProjectSvg,
|
|
6214
|
+
videoProxyFfmpeg,
|
|
6215
|
+
videoProxyName,
|
|
6216
|
+
videoProxyPlan,
|
|
5467
6217
|
videoRemoveRangesEdits,
|
|
5468
6218
|
videoRulerTicks,
|
|
5469
6219
|
videoScopeImage,
|
|
6220
|
+
videoSequenceChildren,
|
|
6221
|
+
videoSequenceDuration,
|
|
6222
|
+
videoSequenceMap,
|
|
6223
|
+
videoShellQuote,
|
|
5470
6224
|
videoSlowMotionRamp,
|
|
5471
6225
|
videoSnapPoints,
|
|
6226
|
+
videoSourceFrame,
|
|
6227
|
+
videoSourceFrameTime,
|
|
5472
6228
|
videoSpeedRampEdit,
|
|
5473
6229
|
videoSpeedRampKeys,
|
|
5474
6230
|
videoTextStyle,
|