@pixerate/editor 0.85.0 → 0.86.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;
@@ -2344,6 +2513,7 @@ function parseVideoLoudnessMeasurement(output) {
2344
2513
  return m;
2345
2514
  }
2346
2515
  var sec = (ms) => String(Math.round(ms) / 1e3);
2516
+ var fmtMs = (v) => String(Math.round(v * 100) / 100);
2347
2517
  var FFMPEG_BLEND = {
2348
2518
  multiply: "multiply",
2349
2519
  screen: "screen",
@@ -2403,6 +2573,58 @@ function buildFfmpeg(project, options, measure) {
2403
2573
  let cur = "base";
2404
2574
  let usesDrawtext = false;
2405
2575
  const animated = /* @__PURE__ */ new Set();
2576
+ const ramped = /* @__PURE__ */ new Set();
2577
+ const rampedPicture = (remap, lead, end) => {
2578
+ const pts = videoTimeRemapBreakpoints(remap, 2e3 / fps);
2579
+ const first = pts[0];
2580
+ const last = pts[pts.length - 1];
2581
+ const s = `(T*1000+${fmtMs(first.v)})`;
2582
+ let e = fmtMs(last.t - first.t);
2583
+ for (let i = pts.length - 2; i >= 0; i--) {
2584
+ const a = pts[i];
2585
+ const b = pts[i + 1];
2586
+ if (b.v - a.v < 1e-6) continue;
2587
+ 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})`;
2588
+ }
2589
+ const before = lead + first.t;
2590
+ const after = Math.max(0, end - last.t);
2591
+ 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)}`];
2592
+ };
2593
+ const rampedSound = (remap, input2, output, lead, end) => {
2594
+ const pts = videoTimeRemapBreakpoints(remap, Math.max(80, 2e3 / fps));
2595
+ const first = pts[0];
2596
+ const last = pts[pts.length - 1];
2597
+ const n2 = audio.length;
2598
+ const fmtA = "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo";
2599
+ const silence = (ms) => `anullsrc=r=48000:cl=stereo,atrim=duration=${sec(ms)},${fmtA}`;
2600
+ const pieces = [];
2601
+ const out = [];
2602
+ const parts = [];
2603
+ if (lead + first.t > 0.5) parts.push(silence(lead + first.t));
2604
+ const played = [];
2605
+ for (let i = 0; i + 1 < pts.length; i++) {
2606
+ const dt = pts[i + 1].t - pts[i].t;
2607
+ if (dt < 0.5) continue;
2608
+ const speed = (pts[i + 1].v - pts[i].v) / dt;
2609
+ if (speed < 0.1) parts.push(silence(dt));
2610
+ else {
2611
+ played.push({ from: pts[i].v - first.v, to: pts[i + 1].v - first.v, speed });
2612
+ parts.push(`#${played.length - 1}`);
2613
+ }
2614
+ }
2615
+ if (end - last.t > 0.5) parts.push(silence(end - last.t));
2616
+ if (played.length) out.push(`[${input2}]asplit=${played.length}${played.map((_, k2) => `[r${n2}s${k2}]`).join("")}`);
2617
+ parts.forEach((p, k2) => {
2618
+ const label = `[r${n2}p${k2}]`;
2619
+ if (p.startsWith("#")) {
2620
+ const piece = played[Number(p.slice(1))];
2621
+ 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}`);
2622
+ } else out.push(`${p}${label}`);
2623
+ pieces.push(label);
2624
+ });
2625
+ out.push(`${pieces.join("")}concat=n=${pieces.length}:v=0:a=1${output}`);
2626
+ return out;
2627
+ };
2406
2628
  const blended = /* @__PURE__ */ new Set();
2407
2629
  let k = 0;
2408
2630
  const step = 2e3 / fps;
@@ -2415,7 +2637,10 @@ function buildFfmpeg(project, options, measure) {
2415
2637
  const src = options.resolve ? options.resolve(asset) : asset.src;
2416
2638
  const span = clip.duration + lead + tail;
2417
2639
  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]);
2640
+ else if (clip.timeRemap) {
2641
+ const { from, to } = videoTimeRemapSpan(clip.timeRemap);
2642
+ inputs.push(["-ss", sec((clip.in ?? 0) + from), "-t", sec(to - from + 1e3 / fps), "-i", src]);
2643
+ } else inputs.push(["-ss", sec((clip.in ?? 0) - lead * speed), "-t", sec(span * speed), "-i", src]);
2419
2644
  return inputs.length - 1;
2420
2645
  };
2421
2646
  for (const track of [...project.tracks].reverse()) {
@@ -2437,7 +2662,13 @@ function buildFfmpeg(project, options, measure) {
2437
2662
  const ai = index ?? input(clip, asset, lead, tail);
2438
2663
  const trackGain = track.volume ?? 1;
2439
2664
  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];
2665
+ let source2 = `[${ai}:a]`;
2666
+ if (clip.timeRemap) {
2667
+ source2 = `[r${audio.length}]`;
2668
+ graph.push(...rampedSound(clip.timeRemap, `${ai}:a`, source2, lead, clip.duration + tail));
2669
+ ramped.add("sound");
2670
+ }
2671
+ const chain2 = [...clip.timeRemap ? [] : ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip))], volume];
2441
2672
  const before2 = clip.transition ? videoClipBefore(track, clip) : null;
2442
2673
  const next2 = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition);
2443
2674
  const inCurve = clip.transition ? afadeCurve(videoTransitionAudioCurve(clip.transition)) : "";
@@ -2449,7 +2680,7 @@ function buildFfmpeg(project, options, measure) {
2449
2680
  if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}${afadeCurve(clip.fadeInCurve)}`);
2450
2681
  if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}${afadeCurve(clip.fadeOutCurve)}`);
2451
2682
  chain2.push(`adelay=${Math.round(t0)}:all=1`);
2452
- graph.push(`[${ai}:a]${chain2.join(",")}[a${audio.length}]`);
2683
+ graph.push(`${source2}${chain2.join(",")}[a${audio.length}]`);
2453
2684
  audio.push(`[a${audio.length}]`);
2454
2685
  }
2455
2686
  if (measure || track.kind !== "video" || track.hidden) continue;
@@ -2499,7 +2730,8 @@ function buildFfmpeg(project, options, measure) {
2499
2730
  if (clip.type === "color") source = `color=c=${color(clip.color)}:s=${even(rect.width)}x${even(rect.height)}:r=${fps}:d=${sec(span)}`;
2500
2731
  else {
2501
2732
  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)}`);
2733
+ if (clip.type === "media" && clip.timeRemap) chain.push(...rampedPicture(clip.timeRemap, lead, clip.duration + tail), `scale=${even(rect.width)}:${even(rect.height)}`);
2734
+ else chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
2503
2735
  }
2504
2736
  const grade = videoGradeFfmpegFilters(clip.grade);
2505
2737
  if (grade.length) chain.push("format=rgba", ...grade);
@@ -2583,6 +2815,7 @@ function buildFfmpeg(project, options, measure) {
2583
2815
  if (animated.size)
2584
2816
  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
2817
  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.");
2818
+ 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
2819
  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
2820
  const filterComplex = graph.join(";");
2588
2821
  if (measure) {
@@ -2682,13 +2915,14 @@ var VideoPreviewEngine = class {
2682
2915
  const { media } = this.entry(clip, src, kind);
2683
2916
  active.add(clip);
2684
2917
  const target = sourceTime / 1e3;
2685
- if (Math.abs(media.playbackRate - speed) > 1e-6) media.playbackRate = speed;
2918
+ const frozen = speed < 0.0625;
2919
+ if (!frozen && Math.abs(media.playbackRate - speed) > 1e-6) media.playbackRate = speed;
2686
2920
  if (gain === null) media.muted = true;
2687
2921
  else {
2688
2922
  media.muted = gain <= 0;
2689
2923
  media.volume = Math.min(1, Math.max(0, gain));
2690
2924
  }
2691
- if (playing) {
2925
+ if (playing && !frozen) {
2692
2926
  if (media.paused || Math.abs(media.currentTime - target) > tolerance) media.currentTime = target;
2693
2927
  if (media.paused) void Promise.resolve(media.play()).catch(() => {
2694
2928
  });
@@ -3671,6 +3905,83 @@ function createVideoEditorToolProvider(controller, options = {}) {
3671
3905
  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
3906
  }
3673
3907
  };
3908
+ const rampTarget = (id) => {
3909
+ const at = findVideoClip(project(), id);
3910
+ if (!at) throw new Error(`There is no clip "${id}"; get_video_project lists the clips.`);
3911
+ const { clip } = at;
3912
+ 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.`);
3913
+ 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.`);
3914
+ return clip;
3915
+ };
3916
+ const rampOut = (r, id) => {
3917
+ const outcome2 = r.outcomes[r.outcomes.length - 1];
3918
+ return { clip: clipOut(r.project, id), ...outcome2.rippled ? { rippled: outcome2.rippled } : {}, duration: videoProjectDuration(r.project) };
3919
+ };
3920
+ 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." };
3921
+ const setSpeedRamp = {
3922
+ name: "set_video_speed_ramp",
3923
+ title: "Ramp the speed",
3924
+ 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).`,
3925
+ inputSchema: object(
3926
+ {
3927
+ clip: CLIP_ID,
3928
+ segments: {
3929
+ type: "array",
3930
+ minItems: 1,
3931
+ maxItems: 100,
3932
+ description: "The stretches in order, from the clip's start.",
3933
+ items: object(
3934
+ {
3935
+ duration: { type: "number", exclusiveMinimum: 0, description: "Length of the stretch on the timeline, in ms." },
3936
+ 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." },
3937
+ 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." }
3938
+ },
3939
+ ["duration", "speed"],
3940
+ "One stretch."
3941
+ )
3942
+ },
3943
+ ripple: RIPPLE_RAMP
3944
+ },
3945
+ ["clip", "segments"]
3946
+ ),
3947
+ annotations: { title: "Ramp the speed", ...WRITE_IDEMPOTENT },
3948
+ handler: ({ clip, segments, ripple }) => {
3949
+ const edit = videoSpeedRampEdit(rampTarget(clip), segments, ripple ?? true);
3950
+ return apply(edit, "Speed ramp", (r) => ({ ...rampOut(r, clip), source: Math.round(videoSpeedRampKeys(segments).source) }));
3951
+ }
3952
+ };
3953
+ const addFreeze = {
3954
+ name: "add_video_freeze_frame",
3955
+ title: "Add a freeze frame",
3956
+ 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.",
3957
+ inputSchema: object(
3958
+ {
3959
+ clip: CLIP_ID,
3960
+ at: MS("Timeline time of the frame to hold (inside the clip)"),
3961
+ duration: { type: "number", exclusiveMinimum: 0, description: "How long to hold it, in ms." },
3962
+ ripple: RIPPLE_RAMP
3963
+ },
3964
+ ["clip", "at", "duration"]
3965
+ ),
3966
+ annotations: { title: "Add a freeze frame", ...WRITE },
3967
+ handler: ({ clip, at, duration, ripple }) => {
3968
+ const c = rampTarget(clip);
3969
+ const t = at - c.start;
3970
+ 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.`);
3971
+ return apply(videoFreezeFrameEdit(c, t, duration, ripple ?? true), "Freeze frame", (r) => rampOut(r, clip));
3972
+ }
3973
+ };
3974
+ const removeRamp = {
3975
+ name: "remove_video_speed_ramp",
3976
+ title: "Remove a speed ramp",
3977
+ 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.',
3978
+ inputSchema: object({ clip: CLIP_ID, duration: { type: "number", exclusiveMinimum: 0, description: "New length in ms (default: unchanged)." }, ripple: RIPPLE_RAMP }, ["clip"]),
3979
+ annotations: { title: "Remove a speed ramp", ...WRITE },
3980
+ handler: ({ clip, duration, ripple }) => {
3981
+ rampTarget(clip);
3982
+ return apply({ op: "set_time_remap", clip, keys: null, ...duration !== void 0 ? { duration } : {}, ripple: ripple ?? true }, "Remove speed ramp", (r) => rampOut(r, clip));
3983
+ }
3984
+ };
3674
3985
  const setTransition = {
3675
3986
  name: "set_video_transition",
3676
3987
  title: "Set a transition",
@@ -3781,7 +4092,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
3781
4092
  };
3782
4093
  const EDIT_OP = {
3783
4094
  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 }.`,
4095
+ 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
4096
  properties: { op: { type: "string", enum: VIDEO_EDIT_OPS.filter((o) => o !== "replace_project"), description: "Which edit." } },
3786
4097
  required: ["op"]
3787
4098
  };
@@ -3889,7 +4200,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
3889
4200
  }
3890
4201
  };
3891
4202
  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];
4203
+ 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
4204
  return {
3894
4205
  id: options.id ?? "video",
3895
4206
  namespace: options.namespace,
@@ -4163,6 +4474,7 @@ export {
4163
4474
  applyVideoGrade,
4164
4475
  buildVideoAudioPeaks,
4165
4476
  checkVideoProject,
4477
+ checkVideoTimeRemap,
4166
4478
  createVideoEditorToolProvider,
4167
4479
  createVideoProject,
4168
4480
  decodeVideoAudioPeaks,
@@ -4193,6 +4505,7 @@ export {
4193
4505
  parseVideoLoudnessMeasurement,
4194
4506
  parseVideoMarker,
4195
4507
  parseVideoProject,
4508
+ parseVideoTimeRemap,
4196
4509
  parseVideoTrack,
4197
4510
  parseVideoTranscript,
4198
4511
  parseVideoTranscriptWords,
@@ -4233,7 +4546,9 @@ export {
4233
4546
  videoClipShowsWaveform,
4234
4547
  videoClipSoundGain,
4235
4548
  videoClipSourceRange,
4549
+ videoClipSourceTime,
4236
4550
  videoClipSpeed,
4551
+ videoClipSpeedAt,
4237
4552
  videoClipTint,
4238
4553
  videoClipWaveform,
4239
4554
  videoClipWaveformPath,
@@ -4248,6 +4563,7 @@ export {
4248
4563
  videoFrameDuration,
4249
4564
  videoFrameToSvg,
4250
4565
  videoFrames,
4566
+ videoFreezeFrameEdit,
4251
4567
  videoGradeCssFilter,
4252
4568
  videoGradeFfmpegFilters,
4253
4569
  videoGradeGainsFilterId,
@@ -4272,8 +4588,13 @@ export {
4272
4588
  videoProjectSvg,
4273
4589
  videoRemoveRangesEdits,
4274
4590
  videoRulerTicks,
4591
+ videoSlowMotionRamp,
4275
4592
  videoSnapPoints,
4593
+ videoSpeedRampEdit,
4594
+ videoSpeedRampKeys,
4276
4595
  videoTextStyle,
4596
+ videoTimeRemapBreakpoints,
4597
+ videoTimeRemapSpan,
4277
4598
  videoTimecodeFrames,
4278
4599
  videoTimelineTranscript,
4279
4600
  videoTitleFrame,