@pixerate/editor 0.85.0 → 0.87.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 +879 -101
- package/dist/video-editor/index.cjs.map +1 -1
- package/dist/video-editor/index.d.cts +313 -1
- package/dist/video-editor/index.d.ts +313 -1
- package/dist/video-editor/index.js +800 -22
- package/dist/video-editor/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/pixerate-video-editor/SKILL.md +24 -1
|
@@ -340,6 +340,105 @@ function videoGradeFfmpegFilters(grade) {
|
|
|
340
340
|
});
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
// src/video-editor/remap.ts
|
|
344
|
+
function videoClipSourceTime(clip, clipTime) {
|
|
345
|
+
const base = clip.in ?? 0;
|
|
346
|
+
return clip.timeRemap ? base + videoValueAt(clip.timeRemap, clipTime, 0) : base + clipTime * (clip.speed ?? 1);
|
|
347
|
+
}
|
|
348
|
+
function videoClipSpeedAt(clip, clipTime) {
|
|
349
|
+
if (clip.type !== "media") return 1;
|
|
350
|
+
if (!clip.timeRemap) return clip.speed ?? 1;
|
|
351
|
+
const t = Math.min(Math.max(clipTime, 0), Math.max(0, clip.duration - 1));
|
|
352
|
+
return Math.max(0, videoClipSourceTime(clip, t + 1) - videoClipSourceTime(clip, t));
|
|
353
|
+
}
|
|
354
|
+
function videoSpeedRampKeys(segments) {
|
|
355
|
+
if (!segments.length) throw new Error("Give at least one stretch { duration, speed }.");
|
|
356
|
+
const keys = [];
|
|
357
|
+
let t = 0;
|
|
358
|
+
let v = 0;
|
|
359
|
+
segments.forEach((s, i) => {
|
|
360
|
+
if (!(s.duration > 0)) throw new Error(`Stretch ${i + 1} needs a duration above 0 ms.`);
|
|
361
|
+
for (const [name, x] of [["speed", s.speed], ["toSpeed", s.toSpeed]])
|
|
362
|
+
if (x !== void 0 && !(x >= 0 && x <= 20)) throw new Error(`Stretch ${i + 1}'s ${name} must be from 0 (a freeze) to 20; got ${x}.`);
|
|
363
|
+
const a = s.speed;
|
|
364
|
+
const b = s.toSpeed ?? s.speed;
|
|
365
|
+
const avg = (a + b) / 2;
|
|
366
|
+
let ease = "linear";
|
|
367
|
+
if (a !== b && avg > 0) {
|
|
368
|
+
ease = [1 / 3, 2 * a / (3 * (a + b)), 2 / 3, 1 - 2 * b / (3 * (a + b))].map((n2) => Math.round(n2 * 1e6) / 1e6);
|
|
369
|
+
}
|
|
370
|
+
keys.push({ t, v, ...ease === "linear" ? {} : { ease } });
|
|
371
|
+
t += s.duration;
|
|
372
|
+
v += avg * s.duration;
|
|
373
|
+
});
|
|
374
|
+
keys.push({ t, v });
|
|
375
|
+
return { keys: mergeLinear(keys), duration: t, source: v };
|
|
376
|
+
}
|
|
377
|
+
function mergeLinear(keys) {
|
|
378
|
+
const out = [keys[0]];
|
|
379
|
+
for (let i = 1; i < keys.length; i++) {
|
|
380
|
+
const prev = out[out.length - 1];
|
|
381
|
+
const k = keys[i];
|
|
382
|
+
const before = out.length > 1 ? out[out.length - 2] : null;
|
|
383
|
+
if (before && !before.ease && !prev.ease) {
|
|
384
|
+
const s1 = (prev.v - before.v) / (prev.t - before.t);
|
|
385
|
+
const s2 = (k.v - prev.v) / (k.t - prev.t);
|
|
386
|
+
if (Math.abs(s1 - s2) < 1e-9) {
|
|
387
|
+
out[out.length - 1] = k;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
out.push(k);
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
function checkVideoTimeRemap(remap, path = "timeRemap") {
|
|
396
|
+
const { keys } = remap;
|
|
397
|
+
if (!keys.length) throw new Error(`${path} needs at least one key.`);
|
|
398
|
+
keys.forEach((k, i) => {
|
|
399
|
+
if (k.v < 0) throw new Error(`${path}.keys[${i}].v must be at least 0 (ms after the in point).`);
|
|
400
|
+
const next = keys[i + 1];
|
|
401
|
+
if (!next) return;
|
|
402
|
+
if (next.v < k.v - 1e-6) throw new Error(`${path}.keys[${i + 1}] goes back in the source (${next.v} ms after ${k.v} ms); a ramp only plays forwards, so make each value at least the one before.`);
|
|
403
|
+
if (!videoEaseBezier(k.ease)) return;
|
|
404
|
+
let prev = k.v;
|
|
405
|
+
for (let n2 = 1; n2 <= 32; n2++) {
|
|
406
|
+
const v = videoValueAt(remap, k.t + (next.t - k.t) * n2 / 32);
|
|
407
|
+
if (v < prev - 1e-3) throw new Error(`${path}.keys[${i}].ease overshoots, which would play backwards; use a curve that only moves forwards (or linear).`);
|
|
408
|
+
prev = v;
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
function videoTimeRemapSpan(remap) {
|
|
413
|
+
return { from: remap.keys[0].v, to: remap.keys[remap.keys.length - 1].v };
|
|
414
|
+
}
|
|
415
|
+
function videoTimeRemapBreakpoints(remap, step) {
|
|
416
|
+
const { points, holds } = videoKeyframeBreakpoints(remap, step);
|
|
417
|
+
const out = [];
|
|
418
|
+
points.forEach(([t, v], i) => {
|
|
419
|
+
out.push({ t, v });
|
|
420
|
+
if (holds.has(i) && points[i + 1]) out.push({ t: points[i + 1][0], v });
|
|
421
|
+
});
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
function videoSpeedRampEdit(clip, segments, ripple = true) {
|
|
425
|
+
const ramp = videoSpeedRampKeys(segments);
|
|
426
|
+
const offset = videoClipSourceTime(clip, 0) - (clip.in ?? 0);
|
|
427
|
+
return { op: "set_time_remap", clip: clip.id, keys: ramp.keys.map((k) => ({ ...k, v: k.v + offset })), duration: ramp.duration, ripple };
|
|
428
|
+
}
|
|
429
|
+
function videoFreezeFrameEdit(clip, at, duration, ripple = true) {
|
|
430
|
+
if (at < 0 || at > clip.duration) throw new Error(`${Math.round(at)} ms is outside clip "${clip.id}" (0\u2013${clip.duration} ms of clip time).`);
|
|
431
|
+
const remap = clip.timeRemap ?? { keys: [{ t: 0, v: 0 }, { t: clip.duration, v: clip.duration * (clip.speed ?? 1) }] };
|
|
432
|
+
const [left, right] = splitVideoAnimatable(remap, at);
|
|
433
|
+
const keys = [...left.keys.slice(0, -1), { ...left.keys[left.keys.length - 1], ease: "linear" }, ...right.keys.map((k) => ({ ...k, t: k.t + at + duration }))];
|
|
434
|
+
return { op: "set_time_remap", clip: clip.id, keys, duration: clip.duration + duration, ripple };
|
|
435
|
+
}
|
|
436
|
+
var videoSlowMotionRamp = (duration) => [
|
|
437
|
+
{ duration: Math.max(1, duration / 2), speed: 1 },
|
|
438
|
+
{ duration: 500, speed: 1, toSpeed: 0.3 },
|
|
439
|
+
{ duration: 1500, speed: 0.3 }
|
|
440
|
+
];
|
|
441
|
+
|
|
343
442
|
// src/video-editor/parse.ts
|
|
344
443
|
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
345
444
|
var fail = (path, what) => {
|
|
@@ -408,6 +507,18 @@ function parseVideoTransform(v, path) {
|
|
|
408
507
|
rotation: parseVideoAnimatable(v.rotation, `${path}.rotation`, { optional: true })
|
|
409
508
|
});
|
|
410
509
|
}
|
|
510
|
+
function parseVideoTimeRemap(v, speed, path) {
|
|
511
|
+
if (v === void 0) return void 0;
|
|
512
|
+
const remap = parseVideoAnimatable(v, path, { optional: true, min: 0 });
|
|
513
|
+
if (typeof remap === "number" || remap === void 0) return fail(path, "must be keyframes { keys: [{ t, v, ease }] } (v: ms of source after the in point)");
|
|
514
|
+
if (speed !== void 0 && speed !== 1) fail(path, 'cannot be used with a speed; a ramp sets the speed itself (remove "speed")');
|
|
515
|
+
try {
|
|
516
|
+
checkVideoTimeRemap(remap, path);
|
|
517
|
+
} catch (e) {
|
|
518
|
+
throw new Error(e.message.replace(/\.?$/, "."));
|
|
519
|
+
}
|
|
520
|
+
return remap;
|
|
521
|
+
}
|
|
411
522
|
function parseVideoClipAnimation(v, path) {
|
|
412
523
|
if (v === void 0) return void 0;
|
|
413
524
|
if (!isObject(v)) return fail(path, "must be an object { effect, duration }");
|
|
@@ -494,6 +605,7 @@ function parseVideoClip(v, path = "clip") {
|
|
|
494
605
|
speed: num(v.speed, `${path}.speed`, { optional: true, min: 0.05, max: 20 }),
|
|
495
606
|
volume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
|
|
496
607
|
muted: bool(v.muted, `${path}.muted`),
|
|
608
|
+
timeRemap: parseVideoTimeRemap(v.timeRemap, v.speed, `${path}.timeRemap`),
|
|
497
609
|
fadeInCurve: oneOf(v.fadeInCurve, VIDEO_FADE_CURVES, `${path}.fadeInCurve`),
|
|
498
610
|
fadeOutCurve: oneOf(v.fadeOutCurve, VIDEO_FADE_CURVES, `${path}.fadeOutCurve`)
|
|
499
611
|
});
|
|
@@ -589,6 +701,7 @@ var videoClipSpeed = (clip) => clip.type === "media" ? clip.speed ?? 1 : 1;
|
|
|
589
701
|
function videoClipSourceRange(clip) {
|
|
590
702
|
if (clip.type !== "media") return null;
|
|
591
703
|
const start = clip.in ?? 0;
|
|
704
|
+
if (clip.timeRemap?.keys.length) return { in: start + clip.timeRemap.keys[0].v, out: start + clip.timeRemap.keys[clip.timeRemap.keys.length - 1].v };
|
|
592
705
|
return { in: start, out: start + clip.duration * videoClipSpeed(clip) };
|
|
593
706
|
}
|
|
594
707
|
var videoFrameDuration = (fps) => 1e3 / fps;
|
|
@@ -928,6 +1041,7 @@ var VIDEO_EDIT_OPS = [
|
|
|
928
1041
|
"extract_range",
|
|
929
1042
|
"split_at",
|
|
930
1043
|
"set_keyframes",
|
|
1044
|
+
"set_time_remap",
|
|
931
1045
|
"close_gaps",
|
|
932
1046
|
"add_marker",
|
|
933
1047
|
"update_marker",
|
|
@@ -955,11 +1069,14 @@ function mergeSpans(spans) {
|
|
|
955
1069
|
}
|
|
956
1070
|
return out;
|
|
957
1071
|
}
|
|
1072
|
+
var isRamped = (clip) => clip.type === "media" && !!clip.timeRemap;
|
|
958
1073
|
function clipAnimatables(clip) {
|
|
959
1074
|
const c = clip;
|
|
960
1075
|
const out = [
|
|
961
1076
|
{ get: () => c.opacity, set: (v) => c.opacity = v },
|
|
962
|
-
{ get: () => c.volume, set: (v) => c.volume = v }
|
|
1077
|
+
{ get: () => c.volume, set: (v) => c.volume = v },
|
|
1078
|
+
// A speed ramp is keys in clip time too, so trims and splits keep it on the same frames of source.
|
|
1079
|
+
{ get: () => c.timeRemap, set: (v) => c.timeRemap = v }
|
|
963
1080
|
];
|
|
964
1081
|
for (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => c.transform?.[p], set: (v) => c.transform && (c.transform[p] = v) });
|
|
965
1082
|
return out;
|
|
@@ -970,7 +1087,7 @@ function retimeClipKeys(clip, fn) {
|
|
|
970
1087
|
if (isVideoKeyframes(v)) a.set(fn(v));
|
|
971
1088
|
}
|
|
972
1089
|
}
|
|
973
|
-
var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
|
|
1090
|
+
var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "timeRemap", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
|
|
974
1091
|
var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
|
|
975
1092
|
var Editor = class {
|
|
976
1093
|
constructor(p) {
|
|
@@ -1057,7 +1174,7 @@ var Editor = class {
|
|
|
1057
1174
|
}
|
|
1058
1175
|
/** Moves a clip's head `delta` later in time (start, in point and keys); the caller sets start and duration. */
|
|
1059
1176
|
trimHead(clip, delta) {
|
|
1060
|
-
this.moveIn(clip, delta);
|
|
1177
|
+
if (!isRamped(clip)) this.moveIn(clip, delta);
|
|
1061
1178
|
retimeClipKeys(clip, (v) => retimeVideoAnimatable(v, -delta));
|
|
1062
1179
|
}
|
|
1063
1180
|
/** Cuts `clip` at `at` (inside it): the left part keeps the id, the right part is returned. */
|
|
@@ -1074,7 +1191,7 @@ var Editor = class {
|
|
|
1074
1191
|
if (isVideoKeyframes(l)) leftValues[i].set(l);
|
|
1075
1192
|
if (isVideoKeyframes(rr)) r.set(rr);
|
|
1076
1193
|
});
|
|
1077
|
-
this.moveIn(right, at - clip.start);
|
|
1194
|
+
if (!isRamped(right)) this.moveIn(right, at - clip.start);
|
|
1078
1195
|
clip.duration = at - clip.start;
|
|
1079
1196
|
delete clip.fadeOut;
|
|
1080
1197
|
delete clip.animateOut;
|
|
@@ -1286,7 +1403,7 @@ var Editor = class {
|
|
|
1286
1403
|
if (e.edge === "start") {
|
|
1287
1404
|
const delta = e.to - clip.start;
|
|
1288
1405
|
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)}.`);
|
|
1289
|
-
if (clip.type === "media") {
|
|
1406
|
+
if (clip.type === "media" && !isRamped(clip)) {
|
|
1290
1407
|
const sourceIn = (clip.in ?? 0) + delta * speed;
|
|
1291
1408
|
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)}).`);
|
|
1292
1409
|
clip.in = Math.max(0, sourceIn);
|
|
@@ -1314,7 +1431,7 @@ var Editor = class {
|
|
|
1314
1431
|
} else {
|
|
1315
1432
|
const duration = e.to - clip.start;
|
|
1316
1433
|
if (duration < minimum - VIDEO_TIME_EPSILON) refuse(`Trimming the end to ${formatVideoSeconds(e.to)} would leave "${clip.id}" shorter than a frame; it starts at ${formatVideoSeconds(clip.start)}.`);
|
|
1317
|
-
if (clip.type === "media") {
|
|
1434
|
+
if (clip.type === "media" && !isRamped(clip)) {
|
|
1318
1435
|
const asset = assets.get(clip.asset);
|
|
1319
1436
|
const out = (clip.in ?? 0) + duration * speed;
|
|
1320
1437
|
if (asset?.duration !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
|
|
@@ -1370,6 +1487,8 @@ var Editor = class {
|
|
|
1370
1487
|
for (const k of ["id", "type"]) if (k in set) refuse(`A clip's ${k} cannot be changed; delete it and add a new one.`);
|
|
1371
1488
|
for (const k of e.remove ?? []) if (!OPTIONAL_CLIP_FIELDS.includes(k)) refuse(`"${k}" cannot be removed; removable fields are ${OPTIONAL_CLIP_FIELDS.join(", ")}.`);
|
|
1372
1489
|
if ("speed" in set && (typeof set.speed !== "number" || !(set.speed > 0))) refuse('"speed" must be a number greater than 0 (1 = normal speed).');
|
|
1490
|
+
if ("speed" in set && isRamped(at.clip)) refuse(`Clip "${at.clip.id}" has a speed ramp, which sets its speed; change the ramp, or remove it first.`);
|
|
1491
|
+
if ("timeRemap" in set) refuse("Set a speed ramp with set_time_remap, which checks it against the media and moves later clips if asked.");
|
|
1373
1492
|
let stretch = 1;
|
|
1374
1493
|
if (at.clip.type === "media" && !("duration" in set)) {
|
|
1375
1494
|
const was = videoClipSpeed(at.clip);
|
|
@@ -1389,6 +1508,56 @@ var Editor = class {
|
|
|
1389
1508
|
at.track.clips.sort((a, b) => a.start - b.start);
|
|
1390
1509
|
return this.outcomes.push({ op: e.op, clip: clip.id, track: at.track.id });
|
|
1391
1510
|
}
|
|
1511
|
+
case "set_time_remap": {
|
|
1512
|
+
const { track, clip } = this.clip(e.clip);
|
|
1513
|
+
this.unlocked(track);
|
|
1514
|
+
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.`);
|
|
1515
|
+
const asset = this.asset(clip.asset);
|
|
1516
|
+
if (asset.kind === "image") refuse(`Clip "${clip.id}" is a still ("${asset.id}"), so it has no motion to ramp.`);
|
|
1517
|
+
const media = clip;
|
|
1518
|
+
const end = videoClipEnd(media);
|
|
1519
|
+
const duration = e.duration ?? media.duration;
|
|
1520
|
+
if (!(duration >= 1e3 / this.p.fps - VIDEO_TIME_EPSILON)) refuse(`"duration" must be at least a frame (${Math.ceil(1e3 / this.p.fps)} ms).`);
|
|
1521
|
+
if (e.keys === null) {
|
|
1522
|
+
if (!media.timeRemap) refuse(`Clip "${clip.id}" has no speed ramp to remove.`);
|
|
1523
|
+
const from = (media.in ?? 0) + media.timeRemap.keys[0].v;
|
|
1524
|
+
if (asset.duration !== void 0 && from + duration > asset.duration + VIDEO_TIME_EPSILON)
|
|
1525
|
+
refuse(`At normal speed from ${Math.round(from)} ms, "${clip.id}" would run past the end of "${asset.id}" (${asset.duration} ms); give a duration of at most ${Math.floor(asset.duration - from)} ms.`);
|
|
1526
|
+
delete media.timeRemap;
|
|
1527
|
+
media.in = from;
|
|
1528
|
+
if (media.in < 1e-9) delete media.in;
|
|
1529
|
+
} else {
|
|
1530
|
+
if (!Array.isArray(e.keys) || !e.keys.length) refuse('"keys" must be a list of at least one { t, v, ease } (or null to remove the ramp).');
|
|
1531
|
+
const remap = parseVideoTimeRemap({ keys: e.keys }, void 0, "keys");
|
|
1532
|
+
remap.keys = sortVideoKeys(remap.keys);
|
|
1533
|
+
checkVideoTimeRemap(remap, "keys");
|
|
1534
|
+
const span = videoTimeRemapSpan(remap);
|
|
1535
|
+
if (asset.duration !== void 0 && (media.in ?? 0) + span.to > asset.duration + VIDEO_TIME_EPSILON)
|
|
1536
|
+
refuse(`The ramp plays ${Math.round(span.to)} ms of source from the in point (${media.in ?? 0} ms), past the end of "${asset.id}" (${asset.duration} ms); it can use at most ${Math.floor(asset.duration - (media.in ?? 0))} ms.`);
|
|
1537
|
+
media.timeRemap = remap;
|
|
1538
|
+
delete media.speed;
|
|
1539
|
+
}
|
|
1540
|
+
const newEnd = media.start + duration;
|
|
1541
|
+
const outcome = { op: e.op, clip: clip.id, track: track.id };
|
|
1542
|
+
if (Math.abs(newEnd - end) > VIDEO_TIME_EPSILON) {
|
|
1543
|
+
if (e.ripple) {
|
|
1544
|
+
const tracks = this.rippleTracks([track]);
|
|
1545
|
+
if (newEnd < end) {
|
|
1546
|
+
media.duration = duration;
|
|
1547
|
+
this.close(tracks, newEnd, end);
|
|
1548
|
+
} else {
|
|
1549
|
+
const created = this.open(tracks, end, newEnd - end, /* @__PURE__ */ new Set([clip.id]));
|
|
1550
|
+
media.duration = duration;
|
|
1551
|
+
if (created.length) outcome.created = created;
|
|
1552
|
+
}
|
|
1553
|
+
outcome.rippled = tracks.map((t) => t.id);
|
|
1554
|
+
} else {
|
|
1555
|
+
if (newEnd > end) this.fits(track, end, newEnd - end, /* @__PURE__ */ new Set([clip.id]), `Lengthening "${clip.id}"`);
|
|
1556
|
+
media.duration = duration;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return this.outcomes.push(outcome);
|
|
1560
|
+
}
|
|
1392
1561
|
case "set_transition": {
|
|
1393
1562
|
const { track, clip } = this.clip(e.clip);
|
|
1394
1563
|
this.unlocked(track);
|
|
@@ -1671,7 +1840,7 @@ function activeOnTrack(track, time) {
|
|
|
1671
1840
|
}
|
|
1672
1841
|
return out;
|
|
1673
1842
|
}
|
|
1674
|
-
var sourceTimeAt = (clip, time) => clip.type === "media" ? (clip
|
|
1843
|
+
var sourceTimeAt = (clip, time) => clip.type === "media" ? videoClipSourceTime(clip, time - clip.start) : 0;
|
|
1675
1844
|
function videoFrameAt(project, time) {
|
|
1676
1845
|
const assets = videoAssetMap(project);
|
|
1677
1846
|
const layers = [];
|
|
@@ -1691,7 +1860,7 @@ function videoFrameAt(project, time) {
|
|
|
1691
1860
|
const audible = videoAssetHasSound(asset);
|
|
1692
1861
|
if (audible && clip.type === "media" && !clip.muted && !track.muted) {
|
|
1693
1862
|
const gain = videoValueAt(clip.volume, clipTime, 1) * (track.volume ?? 1) * fadeGain(clip, inside, true) * mix;
|
|
1694
|
-
audio.push({ clip: clip.id, track: track.id, asset: asset.id, src: asset.src, sourceTime, gain, speed:
|
|
1863
|
+
audio.push({ clip: clip.id, track: track.id, asset: asset.id, src: asset.src, sourceTime, gain, speed: videoClipSpeedAt(clip, clipTime) });
|
|
1695
1864
|
}
|
|
1696
1865
|
if (track.kind !== "video" || track.hidden) continue;
|
|
1697
1866
|
if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
|
|
@@ -1955,6 +2124,379 @@ var VideoAudioPeaksCache = class {
|
|
|
1955
2124
|
}
|
|
1956
2125
|
};
|
|
1957
2126
|
|
|
2127
|
+
// src/video-editor/scopes.ts
|
|
2128
|
+
var VIDEO_SCOPE_KINDS = ["histogram", "waveform", "vectorscope"];
|
|
2129
|
+
var videoLuma = (r, g, b) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
2130
|
+
function videoChroma(r, g, b) {
|
|
2131
|
+
const y = videoLuma(r, g, b);
|
|
2132
|
+
return [(b - y) / 1.8556, (r - y) / 1.5748];
|
|
2133
|
+
}
|
|
2134
|
+
function eachPixel(pixels, maxPixels, visit) {
|
|
2135
|
+
const { width, height, data } = pixels;
|
|
2136
|
+
if (!(width > 0 && height > 0) || data.length < width * height * 4) throw new Error(`The pixels are not ${width} \xD7 ${height} RGBA: there are ${data.length} values, and ${width * height * 4} are needed.`);
|
|
2137
|
+
const step = Math.max(1, Math.sqrt(width * height / Math.max(1, maxPixels ?? 262144)));
|
|
2138
|
+
let count = 0;
|
|
2139
|
+
for (let fy = step / 2; fy < height; fy += step) {
|
|
2140
|
+
const y = Math.floor(fy);
|
|
2141
|
+
for (let fx = step / 2; fx < width; fx += step) {
|
|
2142
|
+
const x = Math.floor(fx);
|
|
2143
|
+
const i = (y * width + x) * 4;
|
|
2144
|
+
visit(data[i], data[i + 1], data[i + 2], x, y);
|
|
2145
|
+
count++;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
return count;
|
|
2149
|
+
}
|
|
2150
|
+
var binOf = (v, bins) => Math.min(bins - 1, Math.floor((v + 1e-6) / 256 * bins));
|
|
2151
|
+
function videoHistogram(pixels, options = {}) {
|
|
2152
|
+
const bins = Math.max(1, Math.min(256, Math.round(options.bins ?? 256)));
|
|
2153
|
+
const out = { bins, r: Array(bins).fill(0), g: Array(bins).fill(0), b: Array(bins).fill(0), luma: Array(bins).fill(0), total: 0 };
|
|
2154
|
+
out.total = eachPixel(pixels, options.maxPixels, (r, g, b) => {
|
|
2155
|
+
out.r[binOf(r, bins)]++;
|
|
2156
|
+
out.g[binOf(g, bins)]++;
|
|
2157
|
+
out.b[binOf(b, bins)]++;
|
|
2158
|
+
out.luma[binOf(videoLuma(r, g, b), bins)]++;
|
|
2159
|
+
});
|
|
2160
|
+
return out;
|
|
2161
|
+
}
|
|
2162
|
+
function videoLumaWaveform(pixels, options = {}) {
|
|
2163
|
+
const columns = Math.max(1, Math.min(pixels.width, Math.round(options.columns ?? Math.min(pixels.width, 256))));
|
|
2164
|
+
const levels = Math.max(2, Math.min(256, Math.round(options.levels ?? 128)));
|
|
2165
|
+
const data = new Uint32Array(columns * levels);
|
|
2166
|
+
let peak = 0;
|
|
2167
|
+
eachPixel(pixels, options.maxPixels, (r, g, b, x) => {
|
|
2168
|
+
const c = Math.min(columns - 1, Math.floor(x / pixels.width * columns));
|
|
2169
|
+
const i = c * levels + binOf(videoLuma(r, g, b), levels);
|
|
2170
|
+
if (++data[i] > peak) peak = data[i];
|
|
2171
|
+
});
|
|
2172
|
+
return { columns, levels, data, peak };
|
|
2173
|
+
}
|
|
2174
|
+
var videoVectorscopePoint = (cb, cr, size) => [(cb + 0.5) * (size - 1), (0.5 - cr) * (size - 1)];
|
|
2175
|
+
function videoVectorscope(pixels, options = {}) {
|
|
2176
|
+
const size = Math.max(16, Math.min(512, Math.round(options.size ?? 128)));
|
|
2177
|
+
const data = new Uint32Array(size * size);
|
|
2178
|
+
let peak = 0;
|
|
2179
|
+
eachPixel(pixels, options.maxPixels, (r, g, b) => {
|
|
2180
|
+
const [cb, cr] = videoChroma(r / 255, g / 255, b / 255);
|
|
2181
|
+
const [x, y] = videoVectorscopePoint(cb, cr, size);
|
|
2182
|
+
const i = Math.round(y) * size + Math.round(x);
|
|
2183
|
+
if (++data[i] > peak) peak = data[i];
|
|
2184
|
+
});
|
|
2185
|
+
return { size, data, peak };
|
|
2186
|
+
}
|
|
2187
|
+
var round3 = (v) => Math.round(v * 1e3) / 1e3;
|
|
2188
|
+
var hex2 = (v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0");
|
|
2189
|
+
function videoExposureStats(pixels, options = {}) {
|
|
2190
|
+
const low = (options.shadowLevel ?? 0.02) * 255;
|
|
2191
|
+
const high = (options.highlightLevel ?? 0.98) * 255;
|
|
2192
|
+
const lumaBins = new Uint32Array(256);
|
|
2193
|
+
let sumY = 0;
|
|
2194
|
+
let sumR = 0;
|
|
2195
|
+
let sumG = 0;
|
|
2196
|
+
let sumB = 0;
|
|
2197
|
+
let sumSat = 0;
|
|
2198
|
+
let maxSat = 0;
|
|
2199
|
+
let shadows = 0;
|
|
2200
|
+
let highlights = 0;
|
|
2201
|
+
let cr = 0;
|
|
2202
|
+
let cg = 0;
|
|
2203
|
+
let cbl = 0;
|
|
2204
|
+
const total = eachPixel(pixels, options.maxPixels, (r, g, b) => {
|
|
2205
|
+
const y = videoLuma(r, g, b);
|
|
2206
|
+
lumaBins[Math.min(255, Math.round(y))]++;
|
|
2207
|
+
sumY += y;
|
|
2208
|
+
sumR += r;
|
|
2209
|
+
sumG += g;
|
|
2210
|
+
sumB += b;
|
|
2211
|
+
const [u, v] = videoChroma(r / 255, g / 255, b / 255);
|
|
2212
|
+
const sat = Math.min(1, Math.hypot(u, v) / 0.5);
|
|
2213
|
+
sumSat += sat;
|
|
2214
|
+
if (sat > maxSat) maxSat = sat;
|
|
2215
|
+
if (y <= low) shadows++;
|
|
2216
|
+
if (y >= high) highlights++;
|
|
2217
|
+
if (r >= high) cr++;
|
|
2218
|
+
if (g >= high) cg++;
|
|
2219
|
+
if (b >= high) cbl++;
|
|
2220
|
+
});
|
|
2221
|
+
const percentile = (p) => {
|
|
2222
|
+
const target = p * total;
|
|
2223
|
+
let seen = 0;
|
|
2224
|
+
for (let i = 0; i < 256; i++) {
|
|
2225
|
+
seen += lumaBins[i];
|
|
2226
|
+
if (seen >= target && seen > 0) return i / 255;
|
|
2227
|
+
}
|
|
2228
|
+
return 1;
|
|
2229
|
+
};
|
|
2230
|
+
let min = 0;
|
|
2231
|
+
while (min < 255 && !lumaBins[min]) min++;
|
|
2232
|
+
let max = 255;
|
|
2233
|
+
while (max > 0 && !lumaBins[max]) max--;
|
|
2234
|
+
return {
|
|
2235
|
+
pixels: total,
|
|
2236
|
+
luma: { mean: round3(sumY / total / 255), median: round3(percentile(0.5)), min: round3(min / 255), max: round3(max / 255), p1: round3(percentile(0.01)), p99: round3(percentile(0.99)) },
|
|
2237
|
+
clipped: { shadows: round3(shadows / total), highlights: round3(highlights / total), r: round3(cr / total), g: round3(cg / total), b: round3(cbl / total) },
|
|
2238
|
+
saturation: { mean: round3(sumSat / total), max: round3(maxSat) },
|
|
2239
|
+
average: `#${hex2(sumR / total)}${hex2(sumG / total)}${hex2(sumB / total)}`
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
function videoExposureNotes(stats) {
|
|
2243
|
+
const notes = [];
|
|
2244
|
+
const pct = (v2) => `${Math.round(v2 * 1e3) / 10}%`;
|
|
2245
|
+
if (stats.clipped.shadows >= 0.05) notes.push(`${pct(stats.clipped.shadows)} of the picture is crushed to black; raise exposure or lower contrast.`);
|
|
2246
|
+
if (stats.clipped.highlights >= 0.02) notes.push(`${pct(stats.clipped.highlights)} of the picture is blown to white; lower exposure.`);
|
|
2247
|
+
else {
|
|
2248
|
+
const channels = ["r", "g", "b"].filter((k) => stats.clipped[k] >= 0.05);
|
|
2249
|
+
if (channels.length) notes.push(`The ${channels.map((k) => ({ r: "red", g: "green", b: "blue" })[k]).join(" and ")} channel${channels.length > 1 ? "s clip" : " clips"} in ${pct(Math.max(...channels.map((k) => stats.clipped[k])))} of the picture; lower exposure or saturation.`);
|
|
2250
|
+
}
|
|
2251
|
+
if (stats.luma.mean < 0.2 && stats.clipped.shadows < 0.05) notes.push(`The picture is dark (mean luma ${stats.luma.mean}); raise exposure if it is not meant to be.`);
|
|
2252
|
+
if (stats.luma.mean > 0.75 && stats.clipped.highlights < 0.02) notes.push(`The picture is bright (mean luma ${stats.luma.mean}); lower exposure if it is not meant to be.`);
|
|
2253
|
+
if (stats.luma.p99 - stats.luma.p1 < 0.3 && stats.pixels > 1) notes.push(`The picture is flat (most of it lies between luma ${stats.luma.p1} and ${stats.luma.p99}); raise contrast.`);
|
|
2254
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(stats.average.slice(i, i + 2), 16) / 255);
|
|
2255
|
+
const [u, v] = videoChroma(r, g, b);
|
|
2256
|
+
if (Math.hypot(u, v) > 0.04 && stats.saturation.mean < 0.35) {
|
|
2257
|
+
const warm = v - u;
|
|
2258
|
+
notes.push(`The picture has a ${Math.abs(warm) > Math.abs(v + u) * 0.5 ? warm > 0 ? "warm (orange)" : "cool (blue)" : v > 0 ? "magenta" : "green"} cast (average ${stats.average}); adjust temperature and tint.`);
|
|
2259
|
+
}
|
|
2260
|
+
return notes;
|
|
2261
|
+
}
|
|
2262
|
+
var videoVectorscopeAngle = (cb, cr) => (Math.atan2(cr, cb) * 180 / Math.PI % 360 + 360) % 360;
|
|
2263
|
+
function summarizeVideoScopes(pixels, options = {}) {
|
|
2264
|
+
const stats = videoExposureStats(pixels, options);
|
|
2265
|
+
const bins = Math.max(2, Math.min(64, Math.round(options.bins ?? 16)));
|
|
2266
|
+
const h = videoHistogram(pixels, { ...options, bins });
|
|
2267
|
+
const share = (a) => a.map((v) => round3(v / h.total));
|
|
2268
|
+
const columns = Math.max(1, Math.min(32, Math.round(options.columns ?? 8)));
|
|
2269
|
+
const w = videoLumaWaveform(pixels, { ...options, columns, levels: 256 });
|
|
2270
|
+
const waveform = Array.from({ length: w.columns }, (_, c) => {
|
|
2271
|
+
const counts = w.data.subarray(c * 256, c * 256 + 256);
|
|
2272
|
+
const n2 = counts.reduce((a, b) => a + b, 0);
|
|
2273
|
+
let lo = 0;
|
|
2274
|
+
while (lo < 255 && !counts[lo]) lo++;
|
|
2275
|
+
let hi = 255;
|
|
2276
|
+
while (hi > 0 && !counts[hi]) hi--;
|
|
2277
|
+
let seen = 0;
|
|
2278
|
+
let median = 0;
|
|
2279
|
+
for (let i = 0; i < 256; i++) {
|
|
2280
|
+
seen += counts[i];
|
|
2281
|
+
if (seen >= n2 / 2 && n2) {
|
|
2282
|
+
median = i;
|
|
2283
|
+
break;
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
return { start: round3(c / w.columns), end: round3((c + 1) / w.columns), min: round3(lo / 255), median: round3(median / 255), max: round3(hi / 255) };
|
|
2287
|
+
}).filter((_, c) => w.data.subarray(c * 256, c * 256 + 256).some((v) => v > 0));
|
|
2288
|
+
const chroma = [];
|
|
2289
|
+
eachPixel(pixels, Math.min(options.maxPixels ?? 65536, 65536), (r, g, b) => {
|
|
2290
|
+
const [u, v] = videoChroma(r / 255, g / 255, b / 255);
|
|
2291
|
+
chroma.push([u, v, Math.hypot(u, v)]);
|
|
2292
|
+
});
|
|
2293
|
+
chroma.sort((a, b) => b[2] - a[2]);
|
|
2294
|
+
const top = chroma.slice(0, Math.max(1, Math.ceil(chroma.length / 10)));
|
|
2295
|
+
const mu = top.reduce((s, c) => s + c[0], 0) / top.length;
|
|
2296
|
+
const mv = top.reduce((s, c) => s + c[1], 0) / top.length;
|
|
2297
|
+
const strength = Math.min(1, top.reduce((s, c) => s + c[2], 0) / top.length / 0.5);
|
|
2298
|
+
const coherence = Math.hypot(mu, mv) / Math.max(1e-9, top.reduce((s, c) => s + c[2], 0) / top.length);
|
|
2299
|
+
return {
|
|
2300
|
+
stats,
|
|
2301
|
+
notes: videoExposureNotes(stats),
|
|
2302
|
+
histogram: { bins, luma: share(h.luma), r: share(h.r), g: share(h.g), b: share(h.b) },
|
|
2303
|
+
waveform,
|
|
2304
|
+
vectorscope: { hue: strength < 0.02 ? null : Math.round(videoVectorscopeAngle(mu, mv)), strength: round3(strength), spread: round3(1 - coherence) }
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
2307
|
+
var VIDEO_VECTORSCOPE_TARGETS = [
|
|
2308
|
+
["R", [0.75, 0, 0]],
|
|
2309
|
+
["Mg", [0.75, 0, 0.75]],
|
|
2310
|
+
["B", [0, 0, 0.75]],
|
|
2311
|
+
["Cy", [0, 0.75, 0.75]],
|
|
2312
|
+
["G", [0, 0.75, 0]],
|
|
2313
|
+
["Yl", [0.75, 0.75, 0]]
|
|
2314
|
+
].map(([name, rgb]) => {
|
|
2315
|
+
const [cb, cr] = videoChroma(rgb[0], rgb[1], rgb[2]);
|
|
2316
|
+
return { name, rgb: [...rgb], cb, cr };
|
|
2317
|
+
});
|
|
2318
|
+
function canvas(width, height) {
|
|
2319
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
2320
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
2321
|
+
data[i] = 12;
|
|
2322
|
+
data[i + 1] = 14;
|
|
2323
|
+
data[i + 2] = 18;
|
|
2324
|
+
data[i + 3] = 255;
|
|
2325
|
+
}
|
|
2326
|
+
return { width, height, data };
|
|
2327
|
+
}
|
|
2328
|
+
function plot(img, x, y, rgb, alpha = 1, add = false) {
|
|
2329
|
+
const xi = Math.round(x);
|
|
2330
|
+
const yi = Math.round(y);
|
|
2331
|
+
if (xi < 0 || yi < 0 || xi >= img.width || yi >= img.height) return;
|
|
2332
|
+
const i = (yi * img.width + xi) * 4;
|
|
2333
|
+
for (let k = 0; k < 3; k++) img.data[i + k] = add ? img.data[i + k] + rgb[k] * alpha : img.data[i + k] * (1 - alpha) + rgb[k] * alpha;
|
|
2334
|
+
}
|
|
2335
|
+
var GRID = [70, 76, 88];
|
|
2336
|
+
var glow = (count, peak) => count ? 0.25 + 0.75 * (Math.log1p(count) / Math.log1p(Math.max(1, peak))) : 0;
|
|
2337
|
+
function videoHistogramImage(h, width = 256, height = 128) {
|
|
2338
|
+
const img = canvas(width, height);
|
|
2339
|
+
for (const q of [0.25, 0.5, 0.75]) for (let y = 0; y < height; y++) plot(img, q * (width - 1), y, GRID);
|
|
2340
|
+
const inner = (a) => a.length > 2 ? a.slice(1, -1) : a;
|
|
2341
|
+
const peak = Math.max(1, ...inner(h.r), ...inner(h.g), ...inner(h.b), ...inner(h.luma));
|
|
2342
|
+
const height01 = (v) => Math.min(1, Math.sqrt(v / peak));
|
|
2343
|
+
for (let x = 0; x < width; x++) {
|
|
2344
|
+
const bin = Math.min(h.bins - 1, Math.floor(x / width * h.bins));
|
|
2345
|
+
const channels = [
|
|
2346
|
+
[h.r, [200, 40, 40]],
|
|
2347
|
+
[h.g, [40, 180, 40]],
|
|
2348
|
+
[h.b, [50, 80, 220]]
|
|
2349
|
+
];
|
|
2350
|
+
for (const [a, rgb] of channels) {
|
|
2351
|
+
const top = height - height01(a[bin]) * height;
|
|
2352
|
+
for (let y = Math.ceil(top); y < height; y++) plot(img, x, y, rgb, 0.8, true);
|
|
2353
|
+
}
|
|
2354
|
+
plot(img, x, height - 1 - height01(h.luma[bin]) * (height - 1), [255, 255, 255]);
|
|
2355
|
+
}
|
|
2356
|
+
return img;
|
|
2357
|
+
}
|
|
2358
|
+
function videoWaveformImage(w, width = 256, height = 128) {
|
|
2359
|
+
const img = canvas(width, height);
|
|
2360
|
+
for (const q of [0, 0.25, 0.5, 0.75, 1]) for (let x = 0; x < width; x++) plot(img, x, (1 - q) * (height - 1), GRID);
|
|
2361
|
+
for (let x = 0; x < width; x++) {
|
|
2362
|
+
const c = Math.min(w.columns - 1, Math.floor(x / width * w.columns));
|
|
2363
|
+
for (let y = 0; y < height; y++) {
|
|
2364
|
+
const level = Math.min(w.levels - 1, Math.floor((height - 1 - y) / height * w.levels));
|
|
2365
|
+
const a = glow(w.data[c * w.levels + level], w.peak);
|
|
2366
|
+
if (a) plot(img, x, y, [120, 255, 140], a);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
return img;
|
|
2370
|
+
}
|
|
2371
|
+
function videoVectorscopeImage(v, size = v.size) {
|
|
2372
|
+
const img = canvas(size, size);
|
|
2373
|
+
const c = (size - 1) / 2;
|
|
2374
|
+
for (let a = 0; a < 720; a++) {
|
|
2375
|
+
const t = a / 720 * 2 * Math.PI;
|
|
2376
|
+
plot(img, c + Math.cos(t) * c, c - Math.sin(t) * c, GRID);
|
|
2377
|
+
}
|
|
2378
|
+
const skin = 123 * Math.PI / 180;
|
|
2379
|
+
for (let r = 0; r < c; r++) plot(img, c + Math.cos(skin) * r, c - Math.sin(skin) * r, [140, 110, 90]);
|
|
2380
|
+
for (const t of VIDEO_VECTORSCOPE_TARGETS) {
|
|
2381
|
+
const [x, y] = videoVectorscopePoint(t.cb, t.cr, size);
|
|
2382
|
+
const rgb = t.rgb.map((k) => 80 + k * 200);
|
|
2383
|
+
for (let k = -3; k <= 3; k++) {
|
|
2384
|
+
plot(img, x + k, y - 3, rgb);
|
|
2385
|
+
plot(img, x + k, y + 3, rgb);
|
|
2386
|
+
plot(img, x - 3, y + k, rgb);
|
|
2387
|
+
plot(img, x + 3, y + k, rgb);
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
for (let y = 0; y < size; y++)
|
|
2391
|
+
for (let x = 0; x < size; x++) {
|
|
2392
|
+
const sy = Math.min(v.size - 1, Math.floor(y / size * v.size));
|
|
2393
|
+
const sx = Math.min(v.size - 1, Math.floor(x / size * v.size));
|
|
2394
|
+
const a = glow(v.data[sy * v.size + sx], v.peak);
|
|
2395
|
+
if (!a) continue;
|
|
2396
|
+
const cb = sx / (v.size - 1) - 0.5;
|
|
2397
|
+
const cr = 0.5 - sy / (v.size - 1);
|
|
2398
|
+
const r = Math.min(1, Math.max(0, 0.6 + 1.5748 * cr));
|
|
2399
|
+
const g = Math.min(1, Math.max(0, 0.6 - 0.1873 * cb - 0.4681 * cr));
|
|
2400
|
+
const b = Math.min(1, Math.max(0, 0.6 + 1.8556 * cb));
|
|
2401
|
+
plot(img, x, y, [r * 255, g * 255, b * 255], a);
|
|
2402
|
+
}
|
|
2403
|
+
return img;
|
|
2404
|
+
}
|
|
2405
|
+
function videoScopeImage(kind, pixels, width = 256, height = 128, options = {}) {
|
|
2406
|
+
if (kind === "histogram") return videoHistogramImage(videoHistogram(pixels, { ...options, bins: Math.min(256, width) }), width, height);
|
|
2407
|
+
if (kind === "waveform") return videoWaveformImage(videoLumaWaveform(pixels, { ...options, columns: Math.min(pixels.width, width), levels: Math.min(256, height) }), width, height);
|
|
2408
|
+
const size = Math.min(width, height);
|
|
2409
|
+
return videoVectorscopeImage(videoVectorscope(pixels, { ...options, size }), size);
|
|
2410
|
+
}
|
|
2411
|
+
var VideoPreviewPixels = class {
|
|
2412
|
+
reader = null;
|
|
2413
|
+
drawnAt = null;
|
|
2414
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2415
|
+
/** Gives the canvas to read (the latest preview wins). Returns a function that takes it away again. */
|
|
2416
|
+
attach(reader) {
|
|
2417
|
+
this.reader = reader;
|
|
2418
|
+
this.drawnAt = null;
|
|
2419
|
+
return () => {
|
|
2420
|
+
if (this.reader !== reader) return;
|
|
2421
|
+
this.reader = null;
|
|
2422
|
+
this.drawnAt = null;
|
|
2423
|
+
this.emit();
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
/** Whether a preview is attached. */
|
|
2427
|
+
get attached() {
|
|
2428
|
+
return this.reader !== null;
|
|
2429
|
+
}
|
|
2430
|
+
/** The preview drew the frame at `time`; `complete` is false while a picture was missing or stale. */
|
|
2431
|
+
drawn(time, complete) {
|
|
2432
|
+
this.drawnAt = { time, complete };
|
|
2433
|
+
this.emit();
|
|
2434
|
+
}
|
|
2435
|
+
/** The last frame drawn, with its pixels; `null` without a preview. Throws when the canvas cannot be read. */
|
|
2436
|
+
read() {
|
|
2437
|
+
if (!this.reader) return null;
|
|
2438
|
+
try {
|
|
2439
|
+
return this.reader();
|
|
2440
|
+
} catch (e) {
|
|
2441
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
2442
|
+
throw new Error(`The preview's pixels cannot be read (${reason}). Media from another origin must be served with CORS headers (Access-Control-Allow-Origin) for its pixels to be measured.`);
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
/** Called after each frame the preview draws. */
|
|
2446
|
+
subscribe(listener) {
|
|
2447
|
+
this.listeners.add(listener);
|
|
2448
|
+
return () => {
|
|
2449
|
+
this.listeners.delete(listener);
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
/**
|
|
2453
|
+
* The frame at `time` once it is complete: now, if the last frame drawn was it, else the next
|
|
2454
|
+
* complete drawing of it. Fails after `timeout` ms (default 5000).
|
|
2455
|
+
*/
|
|
2456
|
+
next(time, timeout = 5e3) {
|
|
2457
|
+
const matches = () => this.drawnAt && this.drawnAt.complete && Math.abs(this.drawnAt.time - time) < 0.5;
|
|
2458
|
+
return new Promise((resolve, reject) => {
|
|
2459
|
+
const done = (fn) => {
|
|
2460
|
+
clearTimeout(timer);
|
|
2461
|
+
off();
|
|
2462
|
+
fn();
|
|
2463
|
+
};
|
|
2464
|
+
const check = () => {
|
|
2465
|
+
if (!this.reader) return done(() => reject(new Error("The preview was closed before it drew the frame.")));
|
|
2466
|
+
if (!matches()) return;
|
|
2467
|
+
try {
|
|
2468
|
+
const snap = this.read();
|
|
2469
|
+
done(() => snap ? resolve(snap) : reject(new Error("The preview has no pixels to read.")));
|
|
2470
|
+
} catch (e) {
|
|
2471
|
+
done(() => reject(e));
|
|
2472
|
+
}
|
|
2473
|
+
};
|
|
2474
|
+
const off = this.subscribe(check);
|
|
2475
|
+
const timer = setTimeout(() => done(() => reject(new Error(`The preview did not finish drawing ${Math.round(time)} ms within ${timeout} ms; its media may still be loading or may have failed to load. Try again, or check the asset sources.`))), timeout);
|
|
2476
|
+
check();
|
|
2477
|
+
});
|
|
2478
|
+
}
|
|
2479
|
+
emit() {
|
|
2480
|
+
for (const l of [...this.listeners]) l();
|
|
2481
|
+
}
|
|
2482
|
+
};
|
|
2483
|
+
function readVideoScopesPanel(feed, kinds = VIDEO_SCOPE_KINDS, options = {}) {
|
|
2484
|
+
let snap;
|
|
2485
|
+
try {
|
|
2486
|
+
snap = feed.read();
|
|
2487
|
+
} catch (e) {
|
|
2488
|
+
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
|
2489
|
+
}
|
|
2490
|
+
if (!snap) return { ok: false, message: feed.attached ? "The preview has not drawn a frame yet." : "Show the preview to see its scopes." };
|
|
2491
|
+
const width = options.width ?? 256;
|
|
2492
|
+
const height = options.height ?? 128;
|
|
2493
|
+
const opts = { maxPixels: options.maxPixels ?? 65536 };
|
|
2494
|
+
const images = {};
|
|
2495
|
+
for (const kind of kinds) images[kind] = videoScopeImage(kind, snap.pixels, width, height, opts);
|
|
2496
|
+
const stats = videoExposureStats(snap.pixels, opts);
|
|
2497
|
+
return { ok: true, time: snap.time, complete: snap.complete, images, stats, notes: videoExposureNotes(stats) };
|
|
2498
|
+
}
|
|
2499
|
+
|
|
1958
2500
|
// src/video-editor/transcript.ts
|
|
1959
2501
|
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1960
2502
|
function cueTime(s) {
|
|
@@ -2344,6 +2886,7 @@ function parseVideoLoudnessMeasurement(output) {
|
|
|
2344
2886
|
return m;
|
|
2345
2887
|
}
|
|
2346
2888
|
var sec = (ms) => String(Math.round(ms) / 1e3);
|
|
2889
|
+
var fmtMs = (v) => String(Math.round(v * 100) / 100);
|
|
2347
2890
|
var FFMPEG_BLEND = {
|
|
2348
2891
|
multiply: "multiply",
|
|
2349
2892
|
screen: "screen",
|
|
@@ -2403,6 +2946,58 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2403
2946
|
let cur = "base";
|
|
2404
2947
|
let usesDrawtext = false;
|
|
2405
2948
|
const animated = /* @__PURE__ */ new Set();
|
|
2949
|
+
const ramped = /* @__PURE__ */ new Set();
|
|
2950
|
+
const rampedPicture = (remap, lead, end) => {
|
|
2951
|
+
const pts = videoTimeRemapBreakpoints(remap, 2e3 / fps);
|
|
2952
|
+
const first = pts[0];
|
|
2953
|
+
const last = pts[pts.length - 1];
|
|
2954
|
+
const s = `(T*1000+${fmtMs(first.v)})`;
|
|
2955
|
+
let e = fmtMs(last.t - first.t);
|
|
2956
|
+
for (let i = pts.length - 2; i >= 0; i--) {
|
|
2957
|
+
const a = pts[i];
|
|
2958
|
+
const b = pts[i + 1];
|
|
2959
|
+
if (b.v - a.v < 1e-6) continue;
|
|
2960
|
+
e = `if(lt(${s},${fmtMs(b.v)}),${fmtMs(a.t - first.t)}+(${s}-${fmtMs(a.v)})*${fmtMs((b.t - a.t) / (b.v - a.v))},${e})`;
|
|
2961
|
+
}
|
|
2962
|
+
const before = lead + first.t;
|
|
2963
|
+
const after = Math.max(0, end - last.t);
|
|
2964
|
+
return ["setpts=PTS-STARTPTS", `setpts='(${e})/1000/TB'`, `fps=${fps}`, ...before > 0.5 || after > 0.5 ? [`tpad=start_mode=clone:start_duration=${sec(before)}:stop_mode=clone:stop_duration=${sec(after + 1e3 / fps)}`] : [`tpad=stop_mode=clone:stop_duration=${sec(1e3 / fps)}`], `trim=duration=${sec(lead + end)}`];
|
|
2965
|
+
};
|
|
2966
|
+
const rampedSound = (remap, input2, output, lead, end) => {
|
|
2967
|
+
const pts = videoTimeRemapBreakpoints(remap, Math.max(80, 2e3 / fps));
|
|
2968
|
+
const first = pts[0];
|
|
2969
|
+
const last = pts[pts.length - 1];
|
|
2970
|
+
const n2 = audio.length;
|
|
2971
|
+
const fmtA = "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo";
|
|
2972
|
+
const silence = (ms) => `anullsrc=r=48000:cl=stereo,atrim=duration=${sec(ms)},${fmtA}`;
|
|
2973
|
+
const pieces = [];
|
|
2974
|
+
const out = [];
|
|
2975
|
+
const parts = [];
|
|
2976
|
+
if (lead + first.t > 0.5) parts.push(silence(lead + first.t));
|
|
2977
|
+
const played = [];
|
|
2978
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
2979
|
+
const dt = pts[i + 1].t - pts[i].t;
|
|
2980
|
+
if (dt < 0.5) continue;
|
|
2981
|
+
const speed = (pts[i + 1].v - pts[i].v) / dt;
|
|
2982
|
+
if (speed < 0.1) parts.push(silence(dt));
|
|
2983
|
+
else {
|
|
2984
|
+
played.push({ from: pts[i].v - first.v, to: pts[i + 1].v - first.v, speed });
|
|
2985
|
+
parts.push(`#${played.length - 1}`);
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
if (end - last.t > 0.5) parts.push(silence(end - last.t));
|
|
2989
|
+
if (played.length) out.push(`[${input2}]asplit=${played.length}${played.map((_, k2) => `[r${n2}s${k2}]`).join("")}`);
|
|
2990
|
+
parts.forEach((p, k2) => {
|
|
2991
|
+
const label = `[r${n2}p${k2}]`;
|
|
2992
|
+
if (p.startsWith("#")) {
|
|
2993
|
+
const piece = played[Number(p.slice(1))];
|
|
2994
|
+
out.push(`[r${n2}s${p.slice(1)}]atrim=start=${sec(piece.from)}:end=${sec(piece.to)},asetpts=PTS-STARTPTS,${atempo(piece.speed).join(",") || "anull"},${fmtA}${label}`);
|
|
2995
|
+
} else out.push(`${p}${label}`);
|
|
2996
|
+
pieces.push(label);
|
|
2997
|
+
});
|
|
2998
|
+
out.push(`${pieces.join("")}concat=n=${pieces.length}:v=0:a=1${output}`);
|
|
2999
|
+
return out;
|
|
3000
|
+
};
|
|
2406
3001
|
const blended = /* @__PURE__ */ new Set();
|
|
2407
3002
|
let k = 0;
|
|
2408
3003
|
const step = 2e3 / fps;
|
|
@@ -2415,7 +3010,10 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2415
3010
|
const src = options.resolve ? options.resolve(asset) : asset.src;
|
|
2416
3011
|
const span = clip.duration + lead + tail;
|
|
2417
3012
|
if (asset.kind === "image") inputs.push(["-loop", "1", "-framerate", String(fps), "-t", sec(span), "-i", src]);
|
|
2418
|
-
else
|
|
3013
|
+
else if (clip.timeRemap) {
|
|
3014
|
+
const { from, to } = videoTimeRemapSpan(clip.timeRemap);
|
|
3015
|
+
inputs.push(["-ss", sec((clip.in ?? 0) + from), "-t", sec(to - from + 1e3 / fps), "-i", src]);
|
|
3016
|
+
} else inputs.push(["-ss", sec((clip.in ?? 0) - lead * speed), "-t", sec(span * speed), "-i", src]);
|
|
2419
3017
|
return inputs.length - 1;
|
|
2420
3018
|
};
|
|
2421
3019
|
for (const track of [...project.tracks].reverse()) {
|
|
@@ -2437,7 +3035,13 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2437
3035
|
const ai = index ?? input(clip, asset, lead, tail);
|
|
2438
3036
|
const trackGain = track.volume ?? 1;
|
|
2439
3037
|
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}`;
|
|
2440
|
-
|
|
3038
|
+
let source2 = `[${ai}:a]`;
|
|
3039
|
+
if (clip.timeRemap) {
|
|
3040
|
+
source2 = `[r${audio.length}]`;
|
|
3041
|
+
graph.push(...rampedSound(clip.timeRemap, `${ai}:a`, source2, lead, clip.duration + tail));
|
|
3042
|
+
ramped.add("sound");
|
|
3043
|
+
}
|
|
3044
|
+
const chain2 = [...clip.timeRemap ? [] : ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip))], volume];
|
|
2441
3045
|
const before2 = clip.transition ? videoClipBefore(track, clip) : null;
|
|
2442
3046
|
const next2 = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition);
|
|
2443
3047
|
const inCurve = clip.transition ? afadeCurve(videoTransitionAudioCurve(clip.transition)) : "";
|
|
@@ -2449,7 +3053,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2449
3053
|
if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}${afadeCurve(clip.fadeInCurve)}`);
|
|
2450
3054
|
if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}${afadeCurve(clip.fadeOutCurve)}`);
|
|
2451
3055
|
chain2.push(`adelay=${Math.round(t0)}:all=1`);
|
|
2452
|
-
graph.push(
|
|
3056
|
+
graph.push(`${source2}${chain2.join(",")}[a${audio.length}]`);
|
|
2453
3057
|
audio.push(`[a${audio.length}]`);
|
|
2454
3058
|
}
|
|
2455
3059
|
if (measure || track.kind !== "video" || track.hidden) continue;
|
|
@@ -2499,7 +3103,8 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2499
3103
|
if (clip.type === "color") source = `color=c=${color(clip.color)}:s=${even(rect.width)}x${even(rect.height)}:r=${fps}:d=${sec(span)}`;
|
|
2500
3104
|
else {
|
|
2501
3105
|
source = `[${index}:v]`;
|
|
2502
|
-
|
|
3106
|
+
if (clip.type === "media" && clip.timeRemap) chain.push(...rampedPicture(clip.timeRemap, lead, clip.duration + tail), `scale=${even(rect.width)}:${even(rect.height)}`);
|
|
3107
|
+
else chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
|
|
2503
3108
|
}
|
|
2504
3109
|
const grade = videoGradeFfmpegFilters(clip.grade);
|
|
2505
3110
|
if (grade.length) chain.push("format=rgba", ...grade);
|
|
@@ -2583,6 +3188,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2583
3188
|
if (animated.size)
|
|
2584
3189
|
notes.push(`Keyframed ${[...animated].sort().join(", ")} ${animated.size > 1 ? "are" : "is"} rendered as straight lines between keys, with eased segments sampled every two frames${animated.has("scale") ? "; animated scale needs ffmpeg 5 or later (scale with eval=frame)" : ""}.`);
|
|
2585
3190
|
if (blended.has("soft-light")) notes.push("ffmpeg's soft-light blend uses a slightly different formula from the browser's, so the render can differ a little from the preview.");
|
|
3191
|
+
if (ramped.has("sound")) notes.push("Sound through a speed ramp plays each stretch at its own speed, so a gliding change is heard in small steps (about every 80 ms), and freezes are silent.");
|
|
2586
3192
|
if (usesDrawtext) notes.push("Titles use drawtext, which needs an ffmpeg built with libfreetype (Homebrew's is not: check `ffmpeg -filters | grep drawtext`); pass titleImage to render them as images instead.");
|
|
2587
3193
|
const filterComplex = graph.join(";");
|
|
2588
3194
|
if (measure) {
|
|
@@ -2613,12 +3219,16 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2613
3219
|
|
|
2614
3220
|
// src/video-editor/preview.ts
|
|
2615
3221
|
var VideoPreviewEngine = class {
|
|
2616
|
-
constructor(source,
|
|
3222
|
+
constructor(source, canvas2, options = {}) {
|
|
2617
3223
|
this.source = source;
|
|
2618
3224
|
this.options = options;
|
|
2619
|
-
this.canvas =
|
|
2620
|
-
this.ctx = options.context !== void 0 ? options.context :
|
|
3225
|
+
this.canvas = canvas2;
|
|
3226
|
+
this.ctx = options.context !== void 0 ? options.context : canvas2.getContext?.("2d") ?? null;
|
|
2621
3227
|
this.unsubscribe = source.subscribe(() => this.update());
|
|
3228
|
+
const pixels = options.pixels !== void 0 ? options.pixels : source.previewPixels;
|
|
3229
|
+
this.detachPixels = pixels ? pixels.attach(() => this.readPixels()) : () => {
|
|
3230
|
+
};
|
|
3231
|
+
this.pixelsTarget = pixels ?? null;
|
|
2622
3232
|
this.update();
|
|
2623
3233
|
}
|
|
2624
3234
|
source;
|
|
@@ -2632,6 +3242,16 @@ var VideoPreviewEngine = class {
|
|
|
2632
3242
|
destroyed = false;
|
|
2633
3243
|
filterHost = null;
|
|
2634
3244
|
filters = /* @__PURE__ */ new Set();
|
|
3245
|
+
detachPixels;
|
|
3246
|
+
pixelsTarget;
|
|
3247
|
+
drawn = null;
|
|
3248
|
+
complete = true;
|
|
3249
|
+
/** The canvas's pixels and the frame they show, or `null` when its context cannot be read. */
|
|
3250
|
+
readPixels() {
|
|
3251
|
+
const ctx = this.ctx;
|
|
3252
|
+
if (!ctx?.getImageData || !this.drawn) return null;
|
|
3253
|
+
return { ...this.drawn, pixels: ctx.getImageData(0, 0, this.canvas.width, this.canvas.height) };
|
|
3254
|
+
}
|
|
2635
3255
|
createMedia(kind) {
|
|
2636
3256
|
if (this.options.createMedia) return this.options.createMedia(kind);
|
|
2637
3257
|
const el = document.createElement(kind);
|
|
@@ -2682,13 +3302,14 @@ var VideoPreviewEngine = class {
|
|
|
2682
3302
|
const { media } = this.entry(clip, src, kind);
|
|
2683
3303
|
active.add(clip);
|
|
2684
3304
|
const target = sourceTime / 1e3;
|
|
2685
|
-
|
|
3305
|
+
const frozen = speed < 0.0625;
|
|
3306
|
+
if (!frozen && Math.abs(media.playbackRate - speed) > 1e-6) media.playbackRate = speed;
|
|
2686
3307
|
if (gain === null) media.muted = true;
|
|
2687
3308
|
else {
|
|
2688
3309
|
media.muted = gain <= 0;
|
|
2689
3310
|
media.volume = Math.min(1, Math.max(0, gain));
|
|
2690
3311
|
}
|
|
2691
|
-
if (playing) {
|
|
3312
|
+
if (playing && !frozen) {
|
|
2692
3313
|
if (media.paused || Math.abs(media.currentTime - target) > tolerance) media.currentTime = target;
|
|
2693
3314
|
if (media.paused) void Promise.resolve(media.play()).catch(() => {
|
|
2694
3315
|
});
|
|
@@ -2722,7 +3343,9 @@ var VideoPreviewEngine = class {
|
|
|
2722
3343
|
return img.complete ? img : null;
|
|
2723
3344
|
}
|
|
2724
3345
|
const entry = this.media.get(layer.clip);
|
|
2725
|
-
|
|
3346
|
+
if (!entry || entry.media.readyState < 2) return null;
|
|
3347
|
+
if (entry.media.seeking) this.complete = false;
|
|
3348
|
+
return entry.media;
|
|
2726
3349
|
};
|
|
2727
3350
|
draw(frame) {
|
|
2728
3351
|
if (!this.ctx) return;
|
|
@@ -2733,8 +3356,16 @@ var VideoPreviewEngine = class {
|
|
|
2733
3356
|
const h = Math.max(1, Math.round(frame.height * resolution));
|
|
2734
3357
|
if (this.canvas.width !== w) this.canvas.width = w;
|
|
2735
3358
|
if (this.canvas.height !== h) this.canvas.height = h;
|
|
2736
|
-
|
|
3359
|
+
this.complete = true;
|
|
3360
|
+
const sourceFor = (layer) => {
|
|
3361
|
+
const source = this.sourceFor(layer);
|
|
3362
|
+
if (!source) this.complete = false;
|
|
3363
|
+
return source;
|
|
3364
|
+
};
|
|
3365
|
+
drawVideoFrame(this.ctx, frame, sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
|
|
3366
|
+
this.drawn = { time: frame.time, complete: this.complete };
|
|
2737
3367
|
this.options.onFrame?.(frame);
|
|
3368
|
+
this.pixelsTarget?.drawn(frame.time, this.complete);
|
|
2738
3369
|
}
|
|
2739
3370
|
defineSvgFilter = (id, markup) => {
|
|
2740
3371
|
if (this.options.defineSvgFilter === null) return;
|
|
@@ -2755,6 +3386,7 @@ var VideoPreviewEngine = class {
|
|
|
2755
3386
|
/** Stops following the editor and releases every media element. */
|
|
2756
3387
|
destroy() {
|
|
2757
3388
|
this.destroyed = true;
|
|
3389
|
+
this.detachPixels();
|
|
2758
3390
|
this.filterHost?.remove();
|
|
2759
3391
|
this.filterHost = null;
|
|
2760
3392
|
this.filters.clear();
|
|
@@ -2770,6 +3402,8 @@ var VideoEditorController = class {
|
|
|
2770
3402
|
player;
|
|
2771
3403
|
/** Sound levels per asset, loaded on demand for waveforms and agents. */
|
|
2772
3404
|
audioPeaks;
|
|
3405
|
+
/** The pixels the preview last drew, for scopes and `get_video_scopes` (a `VideoPreviewEngine` fills it). */
|
|
3406
|
+
previewPixels = new VideoPreviewPixels();
|
|
2773
3407
|
project;
|
|
2774
3408
|
issues;
|
|
2775
3409
|
selection = [];
|
|
@@ -3671,6 +4305,83 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
3671
4305
|
return apply(videoRemoveRangesEdits(merged, tracks), merged.length > 1 ? `Cut ${merged.length} stretches` : "Cut stretch", (r) => ({ removed: merged.length, time: round(before - videoProjectDuration(r.project)), duration: videoProjectDuration(r.project) }));
|
|
3672
4306
|
}
|
|
3673
4307
|
};
|
|
4308
|
+
const rampTarget = (id) => {
|
|
4309
|
+
const at = findVideoClip(project(), id);
|
|
4310
|
+
if (!at) throw new Error(`There is no clip "${id}"; get_video_project lists the clips.`);
|
|
4311
|
+
const { clip } = at;
|
|
4312
|
+
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.`);
|
|
4313
|
+
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.`);
|
|
4314
|
+
return clip;
|
|
4315
|
+
};
|
|
4316
|
+
const rampOut = (r, id) => {
|
|
4317
|
+
const outcome2 = r.outcomes[r.outcomes.length - 1];
|
|
4318
|
+
return { clip: clipOut(r.project, id), ...outcome2.rippled ? { rippled: outcome2.rippled } : {}, duration: videoProjectDuration(r.project) };
|
|
4319
|
+
};
|
|
4320
|
+
const RIPPLE_RAMP = { type: "boolean", description: "Move later material on every sync-locked track by the change in length (default true); false refuses if the clip would overlap the next one." };
|
|
4321
|
+
const setSpeedRamp = {
|
|
4322
|
+
name: "set_video_speed_ramp",
|
|
4323
|
+
title: "Ramp the speed",
|
|
4324
|
+
description: `Plays a video or audio clip through stretches of speed, from its first frame: e.g. [{ duration: 1500, speed: 1 }, { duration: 500, speed: 1, toSpeed: 0.25 }, { duration: 2000, speed: 0.25 }] for a slow-motion ramp. "toSpeed" glides evenly to a new speed over the stretch; speed 0 is a freeze frame. The clip's length becomes the stretches' total (one undo step). Replaces any ramp or speed it had. Returns the clip (with its timeRemap keys and source range).`,
|
|
4325
|
+
inputSchema: object(
|
|
4326
|
+
{
|
|
4327
|
+
clip: CLIP_ID,
|
|
4328
|
+
segments: {
|
|
4329
|
+
type: "array",
|
|
4330
|
+
minItems: 1,
|
|
4331
|
+
maxItems: 100,
|
|
4332
|
+
description: "The stretches in order, from the clip's start.",
|
|
4333
|
+
items: object(
|
|
4334
|
+
{
|
|
4335
|
+
duration: { type: "number", exclusiveMinimum: 0, description: "Length of the stretch on the timeline, in ms." },
|
|
4336
|
+
speed: { type: "number", minimum: 0, maximum: 20, description: "Speed at its start: 1 normal, 0.5 half speed (slow motion), 2 double, 0 a freeze frame." },
|
|
4337
|
+
toSpeed: { type: "number", minimum: 0, maximum: 20, description: "Glide evenly to this speed by the stretch's end (default: stay at speed). A ramp between two speeds." }
|
|
4338
|
+
},
|
|
4339
|
+
["duration", "speed"],
|
|
4340
|
+
"One stretch."
|
|
4341
|
+
)
|
|
4342
|
+
},
|
|
4343
|
+
ripple: RIPPLE_RAMP
|
|
4344
|
+
},
|
|
4345
|
+
["clip", "segments"]
|
|
4346
|
+
),
|
|
4347
|
+
annotations: { title: "Ramp the speed", ...WRITE_IDEMPOTENT },
|
|
4348
|
+
handler: ({ clip, segments, ripple }) => {
|
|
4349
|
+
const edit = videoSpeedRampEdit(rampTarget(clip), segments, ripple ?? true);
|
|
4350
|
+
return apply(edit, "Speed ramp", (r) => ({ ...rampOut(r, clip), source: Math.round(videoSpeedRampKeys(segments).source) }));
|
|
4351
|
+
}
|
|
4352
|
+
};
|
|
4353
|
+
const addFreeze = {
|
|
4354
|
+
name: "add_video_freeze_frame",
|
|
4355
|
+
title: "Add a freeze frame",
|
|
4356
|
+
description: "Holds the frame a video clip shows at a timeline time for a while, then plays on (one undo step): the clip gets longer by the hold, and with ripple (default) everything after it on sync-locked tracks moves along. Works on clips with or without a speed ramp. Returns the clip.",
|
|
4357
|
+
inputSchema: object(
|
|
4358
|
+
{
|
|
4359
|
+
clip: CLIP_ID,
|
|
4360
|
+
at: MS("Timeline time of the frame to hold (inside the clip)"),
|
|
4361
|
+
duration: { type: "number", exclusiveMinimum: 0, description: "How long to hold it, in ms." },
|
|
4362
|
+
ripple: RIPPLE_RAMP
|
|
4363
|
+
},
|
|
4364
|
+
["clip", "at", "duration"]
|
|
4365
|
+
),
|
|
4366
|
+
annotations: { title: "Add a freeze frame", ...WRITE },
|
|
4367
|
+
handler: ({ clip, at, duration, ripple }) => {
|
|
4368
|
+
const c = rampTarget(clip);
|
|
4369
|
+
const t = at - c.start;
|
|
4370
|
+
if (t < 0 || t > c.duration) throw new Error(`${formatVideoSeconds(at)} is outside clip "${clip}" (${formatVideoSeconds(c.start)}\u2013${formatVideoSeconds(videoClipEnd(c))}); pick a time inside it.`);
|
|
4371
|
+
return apply(videoFreezeFrameEdit(c, t, duration, ripple ?? true), "Freeze frame", (r) => rampOut(r, clip));
|
|
4372
|
+
}
|
|
4373
|
+
};
|
|
4374
|
+
const removeRamp = {
|
|
4375
|
+
name: "remove_video_speed_ramp",
|
|
4376
|
+
title: "Remove a speed ramp",
|
|
4377
|
+
description: 'Puts a ramped clip back to normal speed from the moment its ramp started (one undo step), keeping its length unless "duration" is given. Returns the clip.',
|
|
4378
|
+
inputSchema: object({ clip: CLIP_ID, duration: { type: "number", exclusiveMinimum: 0, description: "New length in ms (default: unchanged)." }, ripple: RIPPLE_RAMP }, ["clip"]),
|
|
4379
|
+
annotations: { title: "Remove a speed ramp", ...WRITE },
|
|
4380
|
+
handler: ({ clip, duration, ripple }) => {
|
|
4381
|
+
rampTarget(clip);
|
|
4382
|
+
return apply({ op: "set_time_remap", clip, keys: null, ...duration !== void 0 ? { duration } : {}, ripple: ripple ?? true }, "Remove speed ramp", (r) => rampOut(r, clip));
|
|
4383
|
+
}
|
|
4384
|
+
};
|
|
3674
4385
|
const setTransition = {
|
|
3675
4386
|
name: "set_video_transition",
|
|
3676
4387
|
title: "Set a transition",
|
|
@@ -3781,7 +4492,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
3781
4492
|
};
|
|
3782
4493
|
const EDIT_OP = {
|
|
3783
4494
|
type: "object",
|
|
3784
|
-
description: `One edit: { "op": one of ${VIDEO_EDIT_OPS.filter((o) => o !== "replace_project").join(", ")}, \u2026its fields }. Fields match the single tools: add_clip { track, clip, mode, ripple }, move_clip { clip, start, track, mode }, trim_clip { clip, edge, to, ripple }, split_clip { clip, at, id }, delete_clips { clips, ripple }, update_clip { clip, set, remove }, set_transition { clip, transition }, set_keyframes { clip, property, keys (null removes) }, slip_clip { clip, delta }, roll_edit { left, right, delta }, slide_clip { clip, delta }, lift_range { from, to, tracks }, extract_range { from, to, tracks }, split_at { at, tracks }, close_gaps { track, from }, add_asset { asset }, update_asset { asset, set, remove }, remove_asset { asset }, add_track { track: { kind, id, \u2026 }, at }, update_track { track, set, remove }, remove_track { track }, move_track { track, to }, add_marker { marker: { time, label } }, update_marker { marker, set }, remove_marker { marker }, set_settings { name, width, height, fps, background }.`,
|
|
4495
|
+
description: `One edit: { "op": one of ${VIDEO_EDIT_OPS.filter((o) => o !== "replace_project").join(", ")}, \u2026its fields }. Fields match the single tools: add_clip { track, clip, mode, ripple }, move_clip { clip, start, track, mode }, trim_clip { clip, edge, to, ripple }, split_clip { clip, at, id }, delete_clips { clips, ripple }, update_clip { clip, set, remove }, set_transition { clip, transition }, set_keyframes { clip, property, keys (null removes) }, set_time_remap { clip, keys (clip ms \u2192 source ms after the in point; null removes), duration, ripple }, slip_clip { clip, delta }, roll_edit { left, right, delta }, slide_clip { clip, delta }, lift_range { from, to, tracks }, extract_range { from, to, tracks }, split_at { at, tracks }, close_gaps { track, from }, add_asset { asset }, update_asset { asset, set, remove }, remove_asset { asset }, add_track { track: { kind, id, \u2026 }, at }, update_track { track, set, remove }, remove_track { track }, move_track { track, to }, add_marker { marker: { time, label } }, update_marker { marker, set }, remove_marker { marker }, set_settings { name, width, height, fps, background }.`,
|
|
3785
4496
|
properties: { op: { type: "string", enum: VIDEO_EDIT_OPS.filter((o) => o !== "replace_project"), description: "Which edit." } },
|
|
3786
4497
|
required: ["op"]
|
|
3787
4498
|
};
|
|
@@ -3888,8 +4599,47 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
3888
4599
|
return { asset: r.asset.id, from: r.from, to: r.to, silences, total: round(silences.reduce((n2, x) => n2 + x.end - x.start, 0)) };
|
|
3889
4600
|
}
|
|
3890
4601
|
};
|
|
3891
|
-
const
|
|
3892
|
-
|
|
4602
|
+
const pixelsAt = async (time) => {
|
|
4603
|
+
if (options.framePixels) {
|
|
4604
|
+
const px = await options.framePixels(time);
|
|
4605
|
+
if (!px) throw new Error(`The host could not render the frame at ${Math.round(time)} ms; use render_video_frame to look at it instead.`);
|
|
4606
|
+
return px;
|
|
4607
|
+
}
|
|
4608
|
+
const feed = controller.previewPixels;
|
|
4609
|
+
if (!feed.attached) throw new Error("No preview is showing this project, so there are no pixels to measure. The host must show a <VideoPreview> for this editor (or pass framePixels to the tool provider); until then use get_video_frame and render_video_frame.");
|
|
4610
|
+
const state = controller.getState();
|
|
4611
|
+
if (Math.abs(time - state.time) < 0.5) return (await feed.next(state.time, options.framePixelsTimeout)).pixels;
|
|
4612
|
+
const { time: before, playing } = state;
|
|
4613
|
+
if (playing) controller.pause();
|
|
4614
|
+
controller.seek(time);
|
|
4615
|
+
try {
|
|
4616
|
+
return (await feed.next(controller.getState().time, options.framePixelsTimeout)).pixels;
|
|
4617
|
+
} finally {
|
|
4618
|
+
controller.seek(before);
|
|
4619
|
+
if (playing) controller.play();
|
|
4620
|
+
}
|
|
4621
|
+
};
|
|
4622
|
+
const getScopes = {
|
|
4623
|
+
name: "get_video_scopes",
|
|
4624
|
+
title: "Measure a frame with scopes",
|
|
4625
|
+
description: "Measures the rendered picture at a moment as colour scopes would, without changing the project: luma (0 = black, 1 = white) mean, median, min, max and 1st / 99th percentiles; the share of pixels crushed to black or blown to white (and per channel); saturation; the average colour (a cast shows as a tint); a coarse luma and RGB histogram; the luma spread of vertical strips left to right (a waveform); the main hue and strength on a vectorscope; and notes on what to fix. Use it before and after grading (update_video_clip grade, apply_video_grade_preset) to check exposure and colour. Needs the pixels the host draws: fails with a reason when no preview is showing.",
|
|
4626
|
+
inputSchema: object({
|
|
4627
|
+
time: MS("Time from the start to measure (default: the playhead). Another time is shown briefly and the playhead put back"),
|
|
4628
|
+
bins: { type: "integer", minimum: 2, maximum: 64, description: "Histogram steps, darkest first (default 16)." },
|
|
4629
|
+
columns: { type: "integer", minimum: 1, maximum: 32, description: "Waveform strips across the picture, left to right (default 8)." }
|
|
4630
|
+
}),
|
|
4631
|
+
annotations: { title: "Measure a frame with scopes", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
4632
|
+
handler: async ({ time, bins, columns }) => {
|
|
4633
|
+
const p = project();
|
|
4634
|
+
const end = videoProjectDuration(p);
|
|
4635
|
+
const t = time ?? controller.getState().time;
|
|
4636
|
+
if (time !== void 0 && time > end) throw new Error(`Time ${time} ms is past the end of the project (${end} ms).`);
|
|
4637
|
+
const pixels = await pixelsAt(t);
|
|
4638
|
+
return { time: t, timecode: formatVideoTimecode(t, p.fps), width: pixels.width, height: pixels.height, ...summarizeVideoScopes(pixels, { bins, columns }) };
|
|
4639
|
+
}
|
|
4640
|
+
};
|
|
4641
|
+
const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
|
|
4642
|
+
const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setSpeedRamp, addFreeze, removeRamp, setTranscript, removeRanges, applyGradePreset, copyGrade, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
|
|
3893
4643
|
return {
|
|
3894
4644
|
id: options.id ?? "video",
|
|
3895
4645
|
namespace: options.namespace,
|
|
@@ -4150,19 +4900,23 @@ export {
|
|
|
4150
4900
|
VIDEO_KEYFRAME_PROPERTIES,
|
|
4151
4901
|
VIDEO_PEAK_BLOCK,
|
|
4152
4902
|
VIDEO_PLACEMENT_MODES,
|
|
4903
|
+
VIDEO_SCOPE_KINDS,
|
|
4153
4904
|
VIDEO_TIME_EPSILON,
|
|
4154
4905
|
VIDEO_TRANSFORM_PROPERTIES,
|
|
4155
4906
|
VIDEO_TRANSITION_LABELS,
|
|
4156
4907
|
VIDEO_TRANSITION_TYPES,
|
|
4908
|
+
VIDEO_VECTORSCOPE_TARGETS,
|
|
4157
4909
|
VIDEO_WIPE_DIRECTIONS,
|
|
4158
4910
|
VideoAudioPeaksCache,
|
|
4159
4911
|
VideoEditorController,
|
|
4160
4912
|
VideoPreviewEngine,
|
|
4913
|
+
VideoPreviewPixels,
|
|
4161
4914
|
allVideoClipIds,
|
|
4162
4915
|
applyVideoEdits,
|
|
4163
4916
|
applyVideoGrade,
|
|
4164
4917
|
buildVideoAudioPeaks,
|
|
4165
4918
|
checkVideoProject,
|
|
4919
|
+
checkVideoTimeRemap,
|
|
4166
4920
|
createVideoEditorToolProvider,
|
|
4167
4921
|
createVideoProject,
|
|
4168
4922
|
decodeVideoAudioPeaks,
|
|
@@ -4193,6 +4947,7 @@ export {
|
|
|
4193
4947
|
parseVideoLoudnessMeasurement,
|
|
4194
4948
|
parseVideoMarker,
|
|
4195
4949
|
parseVideoProject,
|
|
4950
|
+
parseVideoTimeRemap,
|
|
4196
4951
|
parseVideoTrack,
|
|
4197
4952
|
parseVideoTranscript,
|
|
4198
4953
|
parseVideoTranscriptWords,
|
|
@@ -4201,6 +4956,7 @@ export {
|
|
|
4201
4956
|
placeVideoRect,
|
|
4202
4957
|
planVideoClipDrag,
|
|
4203
4958
|
probeVideoMedia,
|
|
4959
|
+
readVideoScopesPanel,
|
|
4204
4960
|
removeVideoKey,
|
|
4205
4961
|
retimeVideoAnimatable,
|
|
4206
4962
|
setVideoKey,
|
|
@@ -4209,6 +4965,7 @@ export {
|
|
|
4209
4965
|
sortVideoKeys,
|
|
4210
4966
|
sortedVideoClips,
|
|
4211
4967
|
splitVideoAnimatable,
|
|
4968
|
+
summarizeVideoScopes,
|
|
4212
4969
|
toVideoEdl,
|
|
4213
4970
|
toVideoFfmpeg,
|
|
4214
4971
|
toVideoLoudnessMeasure,
|
|
@@ -4220,6 +4977,7 @@ export {
|
|
|
4220
4977
|
videoAudioPeakRange,
|
|
4221
4978
|
videoAudioPeakSpans,
|
|
4222
4979
|
videoAudioPeaksDuration,
|
|
4980
|
+
videoChroma,
|
|
4223
4981
|
videoClipAfter,
|
|
4224
4982
|
videoClipAnimated,
|
|
4225
4983
|
videoClipAnimationProperties,
|
|
@@ -4233,7 +4991,9 @@ export {
|
|
|
4233
4991
|
videoClipShowsWaveform,
|
|
4234
4992
|
videoClipSoundGain,
|
|
4235
4993
|
videoClipSourceRange,
|
|
4994
|
+
videoClipSourceTime,
|
|
4236
4995
|
videoClipSpeed,
|
|
4996
|
+
videoClipSpeedAt,
|
|
4237
4997
|
videoClipTint,
|
|
4238
4998
|
videoClipWaveform,
|
|
4239
4999
|
videoClipWaveformPath,
|
|
@@ -4243,11 +5003,14 @@ export {
|
|
|
4243
5003
|
videoDropTracks,
|
|
4244
5004
|
videoEaseBezier,
|
|
4245
5005
|
videoEaseProgress,
|
|
5006
|
+
videoExposureNotes,
|
|
5007
|
+
videoExposureStats,
|
|
4246
5008
|
videoFadeCurveGain,
|
|
4247
5009
|
videoFrameAt,
|
|
4248
5010
|
videoFrameDuration,
|
|
4249
5011
|
videoFrameToSvg,
|
|
4250
5012
|
videoFrames,
|
|
5013
|
+
videoFreezeFrameEdit,
|
|
4251
5014
|
videoGradeCssFilter,
|
|
4252
5015
|
videoGradeFfmpegFilters,
|
|
4253
5016
|
videoGradeGainsFilterId,
|
|
@@ -4257,6 +5020,8 @@ export {
|
|
|
4257
5020
|
videoGradeSteps,
|
|
4258
5021
|
videoGradeSvgDefs,
|
|
4259
5022
|
videoGradeSvgFilter,
|
|
5023
|
+
videoHistogram,
|
|
5024
|
+
videoHistogramImage,
|
|
4260
5025
|
videoHourFrames,
|
|
4261
5026
|
videoIrisRadius,
|
|
4262
5027
|
videoIssueKey,
|
|
@@ -4266,14 +5031,22 @@ export {
|
|
|
4266
5031
|
videoKeyframeToggleEdit,
|
|
4267
5032
|
videoKeyframeValueEdit,
|
|
4268
5033
|
videoKeysOf,
|
|
5034
|
+
videoLuma,
|
|
5035
|
+
videoLumaWaveform,
|
|
4269
5036
|
videoOverlap,
|
|
4270
5037
|
videoPeakDb,
|
|
4271
5038
|
videoProjectDuration,
|
|
4272
5039
|
videoProjectSvg,
|
|
4273
5040
|
videoRemoveRangesEdits,
|
|
4274
5041
|
videoRulerTicks,
|
|
5042
|
+
videoScopeImage,
|
|
5043
|
+
videoSlowMotionRamp,
|
|
4275
5044
|
videoSnapPoints,
|
|
5045
|
+
videoSpeedRampEdit,
|
|
5046
|
+
videoSpeedRampKeys,
|
|
4276
5047
|
videoTextStyle,
|
|
5048
|
+
videoTimeRemapBreakpoints,
|
|
5049
|
+
videoTimeRemapSpan,
|
|
4277
5050
|
videoTimecodeFrames,
|
|
4278
5051
|
videoTimelineTranscript,
|
|
4279
5052
|
videoTitleFrame,
|
|
@@ -4285,6 +5058,11 @@ export {
|
|
|
4285
5058
|
videoTransitionEase,
|
|
4286
5059
|
videoTransitionHasDirection,
|
|
4287
5060
|
videoValueAt,
|
|
5061
|
+
videoVectorscope,
|
|
5062
|
+
videoVectorscopeAngle,
|
|
5063
|
+
videoVectorscopeImage,
|
|
5064
|
+
videoVectorscopePoint,
|
|
5065
|
+
videoWaveformImage,
|
|
4288
5066
|
videoWaveformPath,
|
|
4289
5067
|
videoWipeRect,
|
|
4290
5068
|
videoWordAt,
|