@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.
@@ -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.in ?? 0) + (time - clip.start) * videoClipSpeed(clip) : 0;
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: videoClipSpeed(clip) });
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 inputs.push(["-ss", sec((clip.in ?? 0) - lead * speed), "-t", sec(span * speed), "-i", src]);
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
- const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), volume];
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(`[${ai}:a]${chain2.join(",")}[a${audio.length}]`);
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
- chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
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, canvas, options = {}) {
3222
+ constructor(source, canvas2, options = {}) {
2617
3223
  this.source = source;
2618
3224
  this.options = options;
2619
- this.canvas = canvas;
2620
- this.ctx = options.context !== void 0 ? options.context : canvas.getContext?.("2d") ?? null;
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
- if (Math.abs(media.playbackRate - speed) > 1e-6) media.playbackRate = speed;
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
- return entry && entry.media.readyState >= 2 ? entry.media : null;
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
- drawVideoFrame(this.ctx, frame, this.sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
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 reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
3892
- const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setTranscript, removeRanges, applyGradePreset, copyGrade, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
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,