@pixerate/editor 0.84.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 }");
@@ -435,6 +546,18 @@ function parseVideoTransition(v, path) {
435
546
  audioCurve: oneOf(v.audioCurve, VIDEO_FADE_CURVES, `${path}.audioCurve`)
436
547
  });
437
548
  }
549
+ function parseVideoTranscriptWords(v, path) {
550
+ if (v === void 0) return void 0;
551
+ if (!Array.isArray(v)) return fail(path, "must be an array of { word, start, end } (ms in the source)");
552
+ const words = v.map((w, i) => {
553
+ if (!isObject(w)) return fail(`${path}[${i}]`, "must be an object { word, start, end }");
554
+ const word = str(w.word, `${path}[${i}].word`);
555
+ const start = num(w.start, `${path}[${i}].start`, { min: 0 });
556
+ const end = num(w.end, `${path}[${i}].end`, { min: start });
557
+ return { word, start, end };
558
+ });
559
+ return words.sort((a, b) => a.start - b.start);
560
+ }
438
561
  function parseVideoAsset(v, path = "asset") {
439
562
  if (!isObject(v)) return fail(path, "must be an object");
440
563
  const kind = oneOf(v.kind, ["video", "audio", "image"], `${path}.kind`, false);
@@ -447,8 +570,10 @@ function parseVideoAsset(v, path = "asset") {
447
570
  width: num(v.width, `${path}.width`, { optional: true, positive: true }),
448
571
  height: num(v.height, `${path}.height`, { optional: true, positive: true }),
449
572
  hasAudio: bool(v.hasAudio, `${path}.hasAudio`),
450
- poster: str(v.poster, `${path}.poster`, true)
573
+ poster: str(v.poster, `${path}.poster`, true),
574
+ transcript: parseVideoTranscriptWords(v.transcript, `${path}.transcript`)
451
575
  });
576
+ if (kind === "image" && asset.transcript) fail(`${path}.transcript`, "is only for video and audio assets");
452
577
  if (kind === "image" && asset.duration !== void 0) fail(`${path}.duration`, "is only for video and audio assets");
453
578
  return asset;
454
579
  }
@@ -480,6 +605,7 @@ function parseVideoClip(v, path = "clip") {
480
605
  speed: num(v.speed, `${path}.speed`, { optional: true, min: 0.05, max: 20 }),
481
606
  volume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
482
607
  muted: bool(v.muted, `${path}.muted`),
608
+ timeRemap: parseVideoTimeRemap(v.timeRemap, v.speed, `${path}.timeRemap`),
483
609
  fadeInCurve: oneOf(v.fadeInCurve, VIDEO_FADE_CURVES, `${path}.fadeInCurve`),
484
610
  fadeOutCurve: oneOf(v.fadeOutCurve, VIDEO_FADE_CURVES, `${path}.fadeOutCurve`)
485
611
  });
@@ -575,6 +701,7 @@ var videoClipSpeed = (clip) => clip.type === "media" ? clip.speed ?? 1 : 1;
575
701
  function videoClipSourceRange(clip) {
576
702
  if (clip.type !== "media") return null;
577
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 };
578
705
  return { in: start, out: start + clip.duration * videoClipSpeed(clip) };
579
706
  }
580
707
  var videoFrameDuration = (fps) => 1e3 / fps;
@@ -914,6 +1041,7 @@ var VIDEO_EDIT_OPS = [
914
1041
  "extract_range",
915
1042
  "split_at",
916
1043
  "set_keyframes",
1044
+ "set_time_remap",
917
1045
  "close_gaps",
918
1046
  "add_marker",
919
1047
  "update_marker",
@@ -941,11 +1069,14 @@ function mergeSpans(spans) {
941
1069
  }
942
1070
  return out;
943
1071
  }
1072
+ var isRamped = (clip) => clip.type === "media" && !!clip.timeRemap;
944
1073
  function clipAnimatables(clip) {
945
1074
  const c = clip;
946
1075
  const out = [
947
1076
  { get: () => c.opacity, set: (v) => c.opacity = v },
948
- { 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 }
949
1080
  ];
950
1081
  for (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => c.transform?.[p], set: (v) => c.transform && (c.transform[p] = v) });
951
1082
  return out;
@@ -956,7 +1087,7 @@ function retimeClipKeys(clip, fn) {
956
1087
  if (isVideoKeyframes(v)) a.set(fn(v));
957
1088
  }
958
1089
  }
959
- 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"];
960
1091
  var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
961
1092
  var Editor = class {
962
1093
  constructor(p) {
@@ -1043,7 +1174,7 @@ var Editor = class {
1043
1174
  }
1044
1175
  /** Moves a clip's head `delta` later in time (start, in point and keys); the caller sets start and duration. */
1045
1176
  trimHead(clip, delta) {
1046
- this.moveIn(clip, delta);
1177
+ if (!isRamped(clip)) this.moveIn(clip, delta);
1047
1178
  retimeClipKeys(clip, (v) => retimeVideoAnimatable(v, -delta));
1048
1179
  }
1049
1180
  /** Cuts `clip` at `at` (inside it): the left part keeps the id, the right part is returned. */
@@ -1060,7 +1191,7 @@ var Editor = class {
1060
1191
  if (isVideoKeyframes(l)) leftValues[i].set(l);
1061
1192
  if (isVideoKeyframes(rr)) r.set(rr);
1062
1193
  });
1063
- this.moveIn(right, at - clip.start);
1194
+ if (!isRamped(right)) this.moveIn(right, at - clip.start);
1064
1195
  clip.duration = at - clip.start;
1065
1196
  delete clip.fadeOut;
1066
1197
  delete clip.animateOut;
@@ -1272,7 +1403,7 @@ var Editor = class {
1272
1403
  if (e.edge === "start") {
1273
1404
  const delta = e.to - clip.start;
1274
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)}.`);
1275
- if (clip.type === "media") {
1406
+ if (clip.type === "media" && !isRamped(clip)) {
1276
1407
  const sourceIn = (clip.in ?? 0) + delta * speed;
1277
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)}).`);
1278
1409
  clip.in = Math.max(0, sourceIn);
@@ -1300,7 +1431,7 @@ var Editor = class {
1300
1431
  } else {
1301
1432
  const duration = e.to - clip.start;
1302
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)}.`);
1303
- if (clip.type === "media") {
1434
+ if (clip.type === "media" && !isRamped(clip)) {
1304
1435
  const asset = assets.get(clip.asset);
1305
1436
  const out = (clip.in ?? 0) + duration * speed;
1306
1437
  if (asset?.duration !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
@@ -1356,6 +1487,8 @@ var Editor = class {
1356
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.`);
1357
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(", ")}.`);
1358
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.");
1359
1492
  let stretch = 1;
1360
1493
  if (at.clip.type === "media" && !("duration" in set)) {
1361
1494
  const was = videoClipSpeed(at.clip);
@@ -1375,6 +1508,56 @@ var Editor = class {
1375
1508
  at.track.clips.sort((a, b) => a.start - b.start);
1376
1509
  return this.outcomes.push({ op: e.op, clip: clip.id, track: at.track.id });
1377
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
+ }
1378
1561
  case "set_transition": {
1379
1562
  const { track, clip } = this.clip(e.clip);
1380
1563
  this.unlocked(track);
@@ -1657,7 +1840,7 @@ function activeOnTrack(track, time) {
1657
1840
  }
1658
1841
  return out;
1659
1842
  }
1660
- 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;
1661
1844
  function videoFrameAt(project, time) {
1662
1845
  const assets = videoAssetMap(project);
1663
1846
  const layers = [];
@@ -1677,7 +1860,7 @@ function videoFrameAt(project, time) {
1677
1860
  const audible = videoAssetHasSound(asset);
1678
1861
  if (audible && clip.type === "media" && !clip.muted && !track.muted) {
1679
1862
  const gain = videoValueAt(clip.volume, clipTime, 1) * (track.volume ?? 1) * fadeGain(clip, inside, true) * mix;
1680
- 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) });
1681
1864
  }
1682
1865
  if (track.kind !== "video" || track.hidden) continue;
1683
1866
  if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
@@ -1941,6 +2124,171 @@ var VideoAudioPeaksCache = class {
1941
2124
  }
1942
2125
  };
1943
2126
 
2127
+ // src/video-editor/transcript.ts
2128
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2129
+ function cueTime(s) {
2130
+ const m = s.trim().match(/^(?:(\d+):)?(\d{1,2}):(\d{1,2})[.,](\d{1,3})$/);
2131
+ if (!m) throw new Error(`"${s.trim()}" is not a cue time (HH:MM:SS,mmm).`);
2132
+ return ((Number(m[1] ?? 0) * 60 + Number(m[2])) * 60 + Number(m[3])) * 1e3 + Number(m[4].padEnd(3, "0"));
2133
+ }
2134
+ function cueWords(text, start, end) {
2135
+ const words = text.replace(/<[^>]+>/g, "").split(/\s+/).filter(Boolean);
2136
+ const step = (end - start) / Math.max(1, words.length);
2137
+ return words.map((word, i) => ({ word, start: Math.round(start + i * step), end: Math.round(start + (i + 1) * step) }));
2138
+ }
2139
+ function parseVideoTranscript(input) {
2140
+ let data = input;
2141
+ if (typeof input === "string") {
2142
+ const text = input.replace(/^/, "").trim();
2143
+ if (text.startsWith("{") || text.startsWith("[")) {
2144
+ try {
2145
+ data = JSON.parse(text);
2146
+ } catch {
2147
+ throw new Error("The transcript looks like JSON but is not valid JSON.");
2148
+ }
2149
+ } else {
2150
+ const words2 = [];
2151
+ for (const block of text.replace(/\r\n?/g, "\n").split(/\n{2,}/)) {
2152
+ const lines = block.split("\n");
2153
+ const at = lines.findIndex((l) => l.includes("-->"));
2154
+ if (at < 0) continue;
2155
+ const [a, b] = lines[at].split("-->");
2156
+ words2.push(...cueWords(lines.slice(at + 1).join(" "), cueTime(a), cueTime(b.trim().split(/\s+/)[0])));
2157
+ }
2158
+ if (!words2.length) throw new Error("No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.");
2159
+ return { words: words2, approximate: true };
2160
+ }
2161
+ }
2162
+ const seconds = (v) => typeof v === "number" && Number.isFinite(v) ? Math.round(v * 1e3) : NaN;
2163
+ const fromList = (list, toMs) => list.map((w, i) => {
2164
+ if (!isRecord(w)) throw new Error(`Word ${i + 1} is not an object { word, start, end }.`);
2165
+ const word = String(w.word ?? w.text ?? "").trim();
2166
+ const start = toMs(w.start);
2167
+ const end = toMs(w.end);
2168
+ if (!word || !Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Word ${i + 1} needs a word and numeric start and end.`);
2169
+ return { word, start, end: Math.max(start, end) };
2170
+ });
2171
+ let words = [];
2172
+ if (Array.isArray(data)) words = fromList(data, (v) => typeof v === "number" ? v : NaN);
2173
+ else if (isRecord(data) && Array.isArray(data.words)) words = fromList(data.words, seconds);
2174
+ else if (isRecord(data) && Array.isArray(data.segments)) {
2175
+ const segments = data.segments.filter(isRecord);
2176
+ if (segments.some((s) => Array.isArray(s.words))) words = fromList(segments.flatMap((s) => Array.isArray(s.words) ? s.words : []), seconds);
2177
+ else {
2178
+ for (const s of segments) words.push(...cueWords(String(s.text ?? ""), seconds(s.start), seconds(s.end)));
2179
+ if (!words.length) throw new Error("No words found in the segments.");
2180
+ return { words, approximate: true };
2181
+ }
2182
+ }
2183
+ if (!words.length) throw new Error("No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.");
2184
+ return { words: words.sort((a, b) => a.start - b.start), approximate: false };
2185
+ }
2186
+ function videoTranscriptTrack(project, track) {
2187
+ if (track !== void 0) {
2188
+ if (!project.tracks.some((t) => t.id === track)) throw new Error(`There is no track "${track}"; the tracks are ${project.tracks.map((t) => `"${t.id}"`).join(", ")}.`);
2189
+ return track;
2190
+ }
2191
+ const assets = videoAssetMap(project);
2192
+ const transcribed = (t) => t.clips.some((c) => c.type === "media" && assets.get(c.asset)?.transcript?.length);
2193
+ return (project.tracks.find((t) => t.kind === "audio" && transcribed(t)) ?? project.tracks.find(transcribed))?.id ?? null;
2194
+ }
2195
+ function videoTimelineTranscript(project, options = {}) {
2196
+ const id = videoTranscriptTrack(project, options.track);
2197
+ if (!id) return [];
2198
+ const track = project.tracks.find((t) => t.id === id);
2199
+ const assets = videoAssetMap(project);
2200
+ const out = [];
2201
+ for (const clip of sortedVideoClips(track)) {
2202
+ if (clip.type !== "media") continue;
2203
+ const words = assets.get(clip.asset)?.transcript;
2204
+ if (!words?.length) continue;
2205
+ const speed = videoClipSpeed(clip);
2206
+ const from = clip.in ?? 0;
2207
+ const to = from + clip.duration * speed;
2208
+ const toTimeline = (s) => Math.min(videoClipEnd(clip), Math.max(clip.start, clip.start + (s - from) / speed));
2209
+ words.forEach((w, index) => {
2210
+ const mid = (w.start + w.end) / 2;
2211
+ if (mid < from || mid >= to) return;
2212
+ out.push({ word: w.word, start: toTimeline(w.start), end: toTimeline(w.end), clip: clip.id, track: track.id, index });
2213
+ });
2214
+ }
2215
+ return out;
2216
+ }
2217
+ var normalizeVideoWord = (word) => word.toLowerCase().replace(/^[^\p{L}\p{N}']+|[^\p{L}\p{N}']+$/gu, "");
2218
+ var rangeOf = (words, i, n2) => ({ from: words[i].start, to: words[i + n2 - 1].end, text: words.slice(i, i + n2).map((w) => w.word).join(" ") });
2219
+ function findVideoTranscriptText(words, query) {
2220
+ const q = query.split(/\s+/).map(normalizeVideoWord).filter(Boolean);
2221
+ if (!q.length) throw new Error("Search for at least one word.");
2222
+ const norm = words.map((w) => normalizeVideoWord(w.word));
2223
+ const out = [];
2224
+ for (let i = 0; i + q.length <= norm.length; i++) if (q.every((x, k) => norm[i + k] === x)) out.push(rangeOf(words, i, q.length));
2225
+ return out;
2226
+ }
2227
+ var VIDEO_DEFAULT_FILLERS = ["um", "umm", "uh", "uhh", "uhm", "erm", "er", "ah", "hmm", "mm", "you know", "i mean"];
2228
+ function findVideoFillers(words, fillers = VIDEO_DEFAULT_FILLERS) {
2229
+ const phrases = fillers.map((f) => f.split(/\s+/).map(normalizeVideoWord).filter(Boolean)).filter((p) => p.length).sort((a, b) => b.length - a.length);
2230
+ const norm = words.map((w) => normalizeVideoWord(w.word));
2231
+ const out = [];
2232
+ for (let i = 0; i < norm.length; ) {
2233
+ const hit = phrases.find((p) => i + p.length <= norm.length && p.every((x, k) => norm[i + k] === x));
2234
+ if (hit) {
2235
+ out.push(rangeOf(words, i, hit.length));
2236
+ i += hit.length;
2237
+ } else i++;
2238
+ }
2239
+ return out;
2240
+ }
2241
+ function findVideoTranscriptPauses(words, options = {}) {
2242
+ const min = options.min ?? 700;
2243
+ const keep = options.keep ?? 150;
2244
+ const out = [];
2245
+ for (let i = 1; i < words.length; i++) {
2246
+ const a = words[i - 1];
2247
+ const b = words[i];
2248
+ if (a.clip !== b.clip || b.start - a.end < min) continue;
2249
+ let from = a.end + keep;
2250
+ let to = b.start - keep;
2251
+ if (options.fps) {
2252
+ const f = 1e3 / options.fps;
2253
+ from = snapToVideoFrame(Math.ceil(from / f - 1e-9) * f, options.fps);
2254
+ to = snapToVideoFrame(Math.floor(to / f + 1e-9) * f, options.fps);
2255
+ }
2256
+ if (to - from > VIDEO_TIME_EPSILON) out.push({ from, to, text: `(pause after "${a.word}")` });
2257
+ }
2258
+ return out;
2259
+ }
2260
+ function mergeVideoRanges(ranges) {
2261
+ const sorted = ranges.filter((r) => r.to - r.from > VIDEO_TIME_EPSILON).map((r) => ({ from: r.from, to: r.to })).sort((a, b) => a.from - b.from);
2262
+ const out = [];
2263
+ for (const r of sorted) {
2264
+ const last = out[out.length - 1];
2265
+ if (last && r.from <= last.to + VIDEO_TIME_EPSILON) last.to = Math.max(last.to, r.to);
2266
+ else out.push(r);
2267
+ }
2268
+ return out;
2269
+ }
2270
+ function videoRemoveRangesEdits(ranges, tracks) {
2271
+ return mergeVideoRanges(ranges).reverse().map((r) => ({ op: "extract_range", from: r.from, to: r.to, ...tracks ? { tracks } : {} }));
2272
+ }
2273
+ function videoWordsRange(words, a, b) {
2274
+ const [i, j] = a <= b ? [a, b] : [b, a];
2275
+ return { from: words[i].start, to: words[j].end };
2276
+ }
2277
+ function videoTranscriptEdit(assetId, text) {
2278
+ return { op: "update_asset", asset: assetId, set: { transcript: parseVideoTranscript(text).words } };
2279
+ }
2280
+ function videoWordAt(words, time) {
2281
+ let lo = 0;
2282
+ let hi = words.length - 1;
2283
+ while (lo <= hi) {
2284
+ const mid = lo + hi >> 1;
2285
+ if (words[mid].end <= time) lo = mid + 1;
2286
+ else if (words[mid].start > time) hi = mid - 1;
2287
+ else return mid;
2288
+ }
2289
+ return -1;
2290
+ }
2291
+
1944
2292
  // src/video-editor/render.ts
1945
2293
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1946
2294
  var n = (v) => String(Math.round(v * 100) / 100);
@@ -2165,6 +2513,7 @@ function parseVideoLoudnessMeasurement(output) {
2165
2513
  return m;
2166
2514
  }
2167
2515
  var sec = (ms) => String(Math.round(ms) / 1e3);
2516
+ var fmtMs = (v) => String(Math.round(v * 100) / 100);
2168
2517
  var FFMPEG_BLEND = {
2169
2518
  multiply: "multiply",
2170
2519
  screen: "screen",
@@ -2224,6 +2573,58 @@ function buildFfmpeg(project, options, measure) {
2224
2573
  let cur = "base";
2225
2574
  let usesDrawtext = false;
2226
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
+ };
2227
2628
  const blended = /* @__PURE__ */ new Set();
2228
2629
  let k = 0;
2229
2630
  const step = 2e3 / fps;
@@ -2236,7 +2637,10 @@ function buildFfmpeg(project, options, measure) {
2236
2637
  const src = options.resolve ? options.resolve(asset) : asset.src;
2237
2638
  const span = clip.duration + lead + tail;
2238
2639
  if (asset.kind === "image") inputs.push(["-loop", "1", "-framerate", String(fps), "-t", sec(span), "-i", src]);
2239
- 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]);
2240
2644
  return inputs.length - 1;
2241
2645
  };
2242
2646
  for (const track of [...project.tracks].reverse()) {
@@ -2258,7 +2662,13 @@ function buildFfmpeg(project, options, measure) {
2258
2662
  const ai = index ?? input(clip, asset, lead, tail);
2259
2663
  const trackGain = track.volume ?? 1;
2260
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}`;
2261
- 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];
2262
2672
  const before2 = clip.transition ? videoClipBefore(track, clip) : null;
2263
2673
  const next2 = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition);
2264
2674
  const inCurve = clip.transition ? afadeCurve(videoTransitionAudioCurve(clip.transition)) : "";
@@ -2270,7 +2680,7 @@ function buildFfmpeg(project, options, measure) {
2270
2680
  if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}${afadeCurve(clip.fadeInCurve)}`);
2271
2681
  if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}${afadeCurve(clip.fadeOutCurve)}`);
2272
2682
  chain2.push(`adelay=${Math.round(t0)}:all=1`);
2273
- graph.push(`[${ai}:a]${chain2.join(",")}[a${audio.length}]`);
2683
+ graph.push(`${source2}${chain2.join(",")}[a${audio.length}]`);
2274
2684
  audio.push(`[a${audio.length}]`);
2275
2685
  }
2276
2686
  if (measure || track.kind !== "video" || track.hidden) continue;
@@ -2320,7 +2730,8 @@ function buildFfmpeg(project, options, measure) {
2320
2730
  if (clip.type === "color") source = `color=c=${color(clip.color)}:s=${even(rect.width)}x${even(rect.height)}:r=${fps}:d=${sec(span)}`;
2321
2731
  else {
2322
2732
  source = `[${index}:v]`;
2323
- 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)}`);
2324
2735
  }
2325
2736
  const grade = videoGradeFfmpegFilters(clip.grade);
2326
2737
  if (grade.length) chain.push("format=rgba", ...grade);
@@ -2404,6 +2815,7 @@ function buildFfmpeg(project, options, measure) {
2404
2815
  if (animated.size)
2405
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)" : ""}.`);
2406
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.");
2407
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.");
2408
2820
  const filterComplex = graph.join(";");
2409
2821
  if (measure) {
@@ -2503,13 +2915,14 @@ var VideoPreviewEngine = class {
2503
2915
  const { media } = this.entry(clip, src, kind);
2504
2916
  active.add(clip);
2505
2917
  const target = sourceTime / 1e3;
2506
- 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;
2507
2920
  if (gain === null) media.muted = true;
2508
2921
  else {
2509
2922
  media.muted = gain <= 0;
2510
2923
  media.volume = Math.min(1, Math.max(0, gain));
2511
2924
  }
2512
- if (playing) {
2925
+ if (playing && !frozen) {
2513
2926
  if (media.paused || Math.abs(media.currentTime - target) > tolerance) media.currentTime = target;
2514
2927
  if (media.paused) void Promise.resolve(media.play()).catch(() => {
2515
2928
  });
@@ -2991,6 +3404,10 @@ var issueSummary = (issues) => ({
2991
3404
  notes: issues.filter((i) => i.severity === "note").length,
2992
3405
  lines: issues.map((i) => `${i.severity}: ${i.message}`)
2993
3406
  });
3407
+ var videoAssetOut = (a) => {
3408
+ const { transcript, ...rest } = a;
3409
+ return transcript ? { ...rest, transcriptWords: transcript.length } : rest;
3410
+ };
2994
3411
  function describeVideoProject(project) {
2995
3412
  return {
2996
3413
  ...project.name ? { name: project.name } : {},
@@ -2999,7 +3416,7 @@ function describeVideoProject(project) {
2999
3416
  fps: project.fps,
3000
3417
  duration: videoProjectDuration(project),
3001
3418
  ...project.background ? { background: project.background } : {},
3002
- assets: project.assets,
3419
+ assets: project.assets.map(videoAssetOut),
3003
3420
  tracks: project.tracks.map((t, index) => ({
3004
3421
  id: t.id,
3005
3422
  kind: t.kind,
@@ -3221,9 +3638,9 @@ function createVideoEditorToolProvider(controller, options = {}) {
3221
3638
  name: "update_video_asset",
3222
3639
  title: "Change media details",
3223
3640
  description: "Changes an asset's src, name, duration, size, sound or poster (e.g. relinking moved media). Clips that now run past its end are refused. Returns the asset.",
3224
- inputSchema: object({ asset: ASSET_ID, set: object(ASSET_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "duration", "width", "height", "hasAudio", "poster"] }, description: "Fields to clear." } }, ["asset"]),
3641
+ inputSchema: object({ asset: ASSET_ID, set: object(ASSET_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "duration", "width", "height", "hasAudio", "poster", "transcript"] }, description: "Fields to clear (transcript removes its words)." } }, ["asset"]),
3225
3642
  annotations: { title: "Change media details", ...WRITE_IDEMPOTENT },
3226
- handler: ({ asset, set, remove }) => apply({ op: "update_asset", asset, set, remove }, "Change media", (r) => ({ asset: r.project.assets.find((a) => a.id === asset) }))
3643
+ handler: ({ asset, set, remove }) => apply({ op: "update_asset", asset, set, remove }, "Change media", (r) => ({ asset: videoAssetOut(r.project.assets.find((a) => a.id === asset)) }))
3227
3644
  };
3228
3645
  const removeAsset = {
3229
3646
  name: "remove_video_asset",
@@ -3380,6 +3797,191 @@ function createVideoEditorToolProvider(controller, options = {}) {
3380
3797
  return apply(edits, "Copy grade", () => ({ grade: grade ?? null, clips: to }));
3381
3798
  }
3382
3799
  };
3800
+ const TRANSCRIPT_TRACK = { ...TRACK_ID, description: "Track whose words to read (default: the first audio track with a transcribed clip, else the first video track with one)." };
3801
+ const RANGE_OUT = (r) => ({ from: round(r.from), to: round(r.to), text: r.text });
3802
+ const timelineWords = (track) => {
3803
+ const words = videoTimelineTranscript(project(), { track });
3804
+ if (!words.length) throw new Error(track ? `No clip on track "${track}" plays a transcribed asset; attach one with set_video_transcript.` : "No clip plays a transcribed asset; attach a transcript to the media with set_video_transcript first.");
3805
+ return words;
3806
+ };
3807
+ const setTranscript = {
3808
+ name: "set_video_transcript",
3809
+ title: "Attach a transcript",
3810
+ description: 'Attaches what is said in a video or audio asset, word by word, so the edit can be cut by text (one undo step). Pass "text" (Whisper-style JSON with word timings, SubRip or WebVTT, as the file says it) or "words" ([{ word, start, end }] in ms of the source). Captions have no word timings, so their words are spread across each cue (approximate). Replaces any transcript the asset had. Returns the word count, whether timings are approximate, and the first and last words.',
3811
+ inputSchema: object(
3812
+ {
3813
+ asset: { ...ASSET_ID, description: "A video or audio asset." },
3814
+ text: { type: "string", minLength: 1, description: "The transcript file's contents: Whisper-style JSON (times in seconds), .srt or .vtt. Pass this or words." },
3815
+ words: { type: "array", minItems: 1, items: object({ word: { type: "string", minLength: 1, description: "The word as said." }, start: { type: "number", minimum: 0, description: "Start in ms of the source." }, end: { type: "number", minimum: 0, description: "End in ms of the source." } }, ["word", "start", "end"], "One word."), description: "Words with source times in ms. Pass this or text." }
3816
+ },
3817
+ ["asset"]
3818
+ ),
3819
+ annotations: { title: "Attach a transcript", ...WRITE_IDEMPOTENT },
3820
+ handler: ({ asset, text, words }) => {
3821
+ if (text === void 0 === (words === void 0)) throw new Error(`Pass either "text" (a transcript file's contents) or "words", not both.`);
3822
+ const a = project().assets.find((x) => x.id === asset);
3823
+ if (!a) throw new Error(`There is no asset "${asset}"; the assets are ${project().assets.map((x) => `"${x.id}"`).join(", ") || "none"}.`);
3824
+ if (a.kind === "image") throw new Error(`Asset "${asset}" is an image and has no sound to transcribe.`);
3825
+ const parsed = text !== void 0 ? parseVideoTranscript(text) : { words, approximate: false };
3826
+ const list = parsed.words;
3827
+ return apply({ op: "update_asset", asset, set: { transcript: list } }, "Attach transcript", () => ({
3828
+ asset,
3829
+ words: list.length,
3830
+ approximate: parsed.approximate,
3831
+ first: list[0],
3832
+ last: list[list.length - 1],
3833
+ ...a.duration !== void 0 && list[list.length - 1].end > a.duration + VIDEO_TIME_EPSILON ? { warning: `The transcript runs to ${list[list.length - 1].end} ms, past the asset's ${a.duration} ms: is it for this asset?` } : {}
3834
+ }));
3835
+ }
3836
+ };
3837
+ const getTranscript = {
3838
+ name: "get_video_transcript",
3839
+ title: "Read the transcript",
3840
+ description: "Returns what the timeline says, in order, from the transcripts of the media its clips play on one track: the text, and each word with its timeline start and end (ms) and the clip that plays it, so ranges can go straight to remove_video_ranges or split_video_at. Words cut out of the edit are not included.",
3841
+ inputSchema: object({
3842
+ track: TRANSCRIPT_TRACK,
3843
+ from: MS("Only words from this timeline time (default 0)"),
3844
+ to: MS("Only words up to this timeline time (default the end)"),
3845
+ limit: { type: "integer", minimum: 1, maximum: 5e3, description: 'Most words to return (default 1000); "more" says if there are others.' }
3846
+ }),
3847
+ annotations: { title: "Read the transcript", ...READ },
3848
+ handler: ({ track, from, to, limit }) => {
3849
+ const all = timelineWords(track).filter((w) => w.end > (from ?? 0) && w.start < (to ?? Infinity));
3850
+ const words = all.slice(0, limit ?? 1e3);
3851
+ return { track: words[0]?.track ?? videoTranscriptTrack(project(), track), text: words.map((w) => w.word).join(" "), words: words.map((w) => ({ word: w.word, start: round(w.start), end: round(w.end), clip: w.clip })), more: all.length > words.length };
3852
+ }
3853
+ };
3854
+ const findText = {
3855
+ name: "find_video_transcript_text",
3856
+ title: "Find words in the transcript",
3857
+ description: "Lists every place a word or phrase is said on the timeline (case and punctuation ignored) as { from, to, text } ranges in timeline ms, ready for remove_video_ranges, split_video_at or seek_video.",
3858
+ inputSchema: object({ query: { type: "string", minLength: 1, description: "The word or phrase." }, track: TRANSCRIPT_TRACK }, ["query"]),
3859
+ annotations: { title: "Find words in the transcript", ...READ },
3860
+ handler: ({ query, track }) => ({ matches: findVideoTranscriptText(timelineWords(track), query).map(RANGE_OUT) })
3861
+ };
3862
+ const findFillers = {
3863
+ name: "find_video_fillers",
3864
+ title: "Find filler words",
3865
+ description: `Lists filler words and phrases on the timeline as { from, to, text } ranges in timeline ms, longest phrase first so "you know" is found whole. Default fillers: ${VIDEO_DEFAULT_FILLERS.join(", ")} ("like" and "so" are left out as too often meant). Check them, then pass the ones to cut to remove_video_ranges.`,
3866
+ inputSchema: object({ track: TRANSCRIPT_TRACK, fillers: { type: "array", minItems: 1, items: { type: "string", minLength: 1 }, description: "Words or phrases to treat as fillers instead of the defaults." } }),
3867
+ annotations: { title: "Find filler words", ...READ },
3868
+ handler: ({ track, fillers }) => {
3869
+ const found = findVideoFillers(timelineWords(track), fillers);
3870
+ return { fillers: found.map(RANGE_OUT), total: round(found.reduce((n2, r) => n2 + r.to - r.from, 0)) };
3871
+ }
3872
+ };
3873
+ const findPauses = {
3874
+ name: "find_video_pauses",
3875
+ title: "Find pauses between words",
3876
+ description: 'Lists the gaps between consecutive words on the timeline (within one clip) longer than "min", less "keep" next to each word so speech is not clipped, snapped inward to frames, as { from, to } ranges in timeline ms ready for remove_video_ranges. Uses word timings; find_video_silences measures the sound instead.',
3877
+ inputSchema: object({
3878
+ track: TRANSCRIPT_TRACK,
3879
+ min: { type: "number", exclusiveMinimum: 0, description: "Shortest gap that counts, in ms (default 700)." },
3880
+ keep: { type: "number", minimum: 0, description: "Silence to keep after the word before and before the word after, in ms (default 150)." }
3881
+ }),
3882
+ annotations: { title: "Find pauses between words", ...READ },
3883
+ handler: ({ track, min, keep }) => {
3884
+ const found = findVideoTranscriptPauses(timelineWords(track), { min, keep, fps: project().fps });
3885
+ return { pauses: found.map(RANGE_OUT), total: round(found.reduce((n2, r) => n2 + r.to - r.from, 0)) };
3886
+ }
3887
+ };
3888
+ const removeRanges = {
3889
+ name: "remove_video_ranges",
3890
+ title: "Cut out stretches",
3891
+ description: "Cuts stretches of the timeline out and closes them up, as one undo step: the ranges are merged where they touch and extracted from the last to the first, so the times from find_video_fillers, find_video_pauses, find_video_transcript_text or find_video_silences can be passed as they are. Like extract_video_range, every sync-locked track moves with the cut. Returns how many stretches and how much time were removed and the new length.",
3892
+ inputSchema: object(
3893
+ {
3894
+ ranges: { type: "array", minItems: 1, maxItems: 500, items: object({ from: MS("Start, on the timeline"), to: MS("End, on the timeline"), text: { type: "string", description: "Ignored: the words in the stretch, as the find tools return them." } }, ["from", "to"], "A stretch to remove."), description: "The stretches, in any order (timeline ms)." },
3895
+ tracks: { type: "array", items: TRACK_ID, minItems: 1, description: "Tracks to cut (default every unlocked track)." }
3896
+ },
3897
+ ["ranges"]
3898
+ ),
3899
+ annotations: { title: "Cut out stretches", ...DESTRUCTIVE },
3900
+ handler: ({ ranges, tracks }) => {
3901
+ const bad = ranges.findIndex((r) => !(r.to > r.from));
3902
+ if (bad >= 0) throw new Error(`Range ${bad + 1} is empty or backwards (${ranges[bad].from}\u2013${ranges[bad].to} ms); "to" must be after "from".`);
3903
+ const merged = mergeVideoRanges(ranges);
3904
+ const before = videoProjectDuration(project());
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) }));
3906
+ }
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
+ };
3383
3985
  const setTransition = {
3384
3986
  name: "set_video_transition",
3385
3987
  title: "Set a transition",
@@ -3490,7 +4092,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
3490
4092
  };
3491
4093
  const EDIT_OP = {
3492
4094
  type: "object",
3493
- 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 }.`,
3494
4096
  properties: { op: { type: "string", enum: VIDEO_EDIT_OPS.filter((o) => o !== "replace_project"), description: "Which edit." } },
3495
4097
  required: ["op"]
3496
4098
  };
@@ -3597,8 +4199,8 @@ function createVideoEditorToolProvider(controller, options = {}) {
3597
4199
  return { asset: r.asset.id, from: r.from, to: r.to, silences, total: round(silences.reduce((n2, x) => n2 + x.end - x.start, 0)) };
3598
4200
  }
3599
4201
  };
3600
- const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
3601
- const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, applyGradePreset, copyGrade, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
4202
+ const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
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];
3602
4204
  return {
3603
4205
  id: options.id ?? "video",
3604
4206
  namespace: options.namespace,
@@ -3843,6 +4445,7 @@ export {
3843
4445
  VIDEO_CLIP_ANIMATION_EFFECTS,
3844
4446
  VIDEO_CLIP_ANIMATION_LABELS,
3845
4447
  VIDEO_CLIP_TYPES,
4448
+ VIDEO_DEFAULT_FILLERS,
3846
4449
  VIDEO_DEFAULT_STILL_DURATION,
3847
4450
  VIDEO_DEFAULT_TITLE_DURATION,
3848
4451
  VIDEO_EASE_NAMES,
@@ -3871,6 +4474,7 @@ export {
3871
4474
  applyVideoGrade,
3872
4475
  buildVideoAudioPeaks,
3873
4476
  checkVideoProject,
4477
+ checkVideoTimeRemap,
3874
4478
  createVideoEditorToolProvider,
3875
4479
  createVideoProject,
3876
4480
  decodeVideoAudioPeaks,
@@ -3879,14 +4483,19 @@ export {
3879
4483
  ffmpegFilterValue,
3880
4484
  findVideoAudioSilences,
3881
4485
  findVideoClip,
4486
+ findVideoFillers,
3882
4487
  findVideoGaps,
4488
+ findVideoTranscriptPauses,
4489
+ findVideoTranscriptText,
3883
4490
  formatVideoSeconds,
3884
4491
  formatVideoTimecode,
3885
4492
  formatVideoTimecodeFrames,
3886
4493
  isVideoDropFrameRate,
3887
4494
  isVideoKeyframes,
4495
+ mergeVideoRanges,
3888
4496
  nextFreeVideoSlot,
3889
4497
  normalizeVideoGrade,
4498
+ normalizeVideoWord,
3890
4499
  parseVideoAnimatable,
3891
4500
  parseVideoAsset,
3892
4501
  parseVideoClip,
@@ -3896,7 +4505,10 @@ export {
3896
4505
  parseVideoLoudnessMeasurement,
3897
4506
  parseVideoMarker,
3898
4507
  parseVideoProject,
4508
+ parseVideoTimeRemap,
3899
4509
  parseVideoTrack,
4510
+ parseVideoTranscript,
4511
+ parseVideoTranscriptWords,
3900
4512
  parseVideoTransform,
3901
4513
  parseVideoTransition,
3902
4514
  placeVideoRect,
@@ -3934,7 +4546,9 @@ export {
3934
4546
  videoClipShowsWaveform,
3935
4547
  videoClipSoundGain,
3936
4548
  videoClipSourceRange,
4549
+ videoClipSourceTime,
3937
4550
  videoClipSpeed,
4551
+ videoClipSpeedAt,
3938
4552
  videoClipTint,
3939
4553
  videoClipWaveform,
3940
4554
  videoClipWaveformPath,
@@ -3949,6 +4563,7 @@ export {
3949
4563
  videoFrameDuration,
3950
4564
  videoFrameToSvg,
3951
4565
  videoFrames,
4566
+ videoFreezeFrameEdit,
3952
4567
  videoGradeCssFilter,
3953
4568
  videoGradeFfmpegFilters,
3954
4569
  videoGradeGainsFilterId,
@@ -3971,18 +4586,29 @@ export {
3971
4586
  videoPeakDb,
3972
4587
  videoProjectDuration,
3973
4588
  videoProjectSvg,
4589
+ videoRemoveRangesEdits,
3974
4590
  videoRulerTicks,
4591
+ videoSlowMotionRamp,
3975
4592
  videoSnapPoints,
4593
+ videoSpeedRampEdit,
4594
+ videoSpeedRampKeys,
3976
4595
  videoTextStyle,
4596
+ videoTimeRemapBreakpoints,
4597
+ videoTimeRemapSpan,
3977
4598
  videoTimecodeFrames,
4599
+ videoTimelineTranscript,
3978
4600
  videoTitleFrame,
3979
4601
  videoTrackSyncLocked,
4602
+ videoTranscriptEdit,
4603
+ videoTranscriptTrack,
3980
4604
  videoTransformAt,
3981
4605
  videoTransitionAudioCurve,
3982
4606
  videoTransitionEase,
3983
4607
  videoTransitionHasDirection,
3984
4608
  videoValueAt,
3985
4609
  videoWaveformPath,
3986
- videoWipeRect
4610
+ videoWipeRect,
4611
+ videoWordAt,
4612
+ videoWordsRange
3987
4613
  };
3988
4614
  //# sourceMappingURL=index.js.map