@pixerate/editor 0.83.0 → 0.85.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.
@@ -13,8 +13,11 @@ import "../chunk-AGR4P4UZ.js";
13
13
 
14
14
  // src/video-editor/types.ts
15
15
  var VIDEO_FITS = ["contain", "cover", "fill", "none"];
16
- var VIDEO_TRANSITION_TYPES = ["crossfade", "dip", "wipe"];
16
+ var VIDEO_TRANSITION_TYPES = ["crossfade", "dip", "wipe", "push", "slide", "iris"];
17
17
  var VIDEO_WIPE_DIRECTIONS = ["left", "right", "up", "down"];
18
+ var videoTransitionAudioCurve = (t) => t.audioCurve ?? (t.type === "dip" ? "linear" : "equal-power");
19
+ var VIDEO_BLEND_MODES = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "add"];
20
+ var VIDEO_CLIP_ANIMATION_EFFECTS = ["fade", "rise", "drop", "slide-left", "slide-right", "zoom", "pop"];
18
21
  var VIDEO_FADE_CURVES = ["linear", "equal-power", "s-curve", "exponential", "logarithmic"];
19
22
  var VIDEO_PLACEMENT_MODES = ["refuse", "overwrite", "insert"];
20
23
  var VIDEO_CLIP_TYPES = ["media", "text", "color"];
@@ -207,6 +210,136 @@ function videoKeyframeFfmpegExpr(prop, time, fallback, step) {
207
210
  return `if(lt(${time},${fmt(points[0][0])}),${fmt(points[0][1])},${expr})`;
208
211
  }
209
212
 
213
+ // src/video-editor/grade.ts
214
+ var VIDEO_GRADE_CONTROLS = [
215
+ { key: "exposure", label: "Exposure", min: -3, max: 3, step: 0.1, description: "Exposure in stops: +1 doubles every channel, -1 halves it (default 0)." },
216
+ { key: "contrast", label: "Contrast", min: -1, max: 1, step: 0.05, description: "Contrast around mid grey, -1 (flat grey) to 1 (twice as steep); default 0." },
217
+ { key: "saturation", label: "Saturation", min: -1, max: 1, step: 0.05, description: "Saturation, -1 (black and white) to 1 (twice as saturated); default 0." },
218
+ { key: "hue", label: "Hue", min: -180, max: 180, step: 1, description: "Hue rotation in degrees (default 0)." },
219
+ { key: "temperature", label: "Temperature", min: -1, max: 1, step: 0.05, description: "White balance, -1 (cool, bluer) to 1 (warm, more orange); default 0." },
220
+ { key: "tint", label: "Tint", min: -1, max: 1, step: 0.05, description: "White balance, -1 (greener) to 1 (more magenta); default 0." }
221
+ ];
222
+ var VIDEO_GRADE_PRESETS = {
223
+ warm: { temperature: 0.4, saturation: 0.1 },
224
+ cool: { temperature: -0.4, tint: -0.05 },
225
+ punchy: { contrast: 0.25, saturation: 0.3 },
226
+ faded: { exposure: 0.15, contrast: -0.3, saturation: -0.3 },
227
+ "black-and-white": { saturation: -1, contrast: 0.15 },
228
+ "bleach-bypass": { saturation: -0.6, contrast: 0.4 },
229
+ "day-for-night": { exposure: -1.5, saturation: -0.4, temperature: -0.6 }
230
+ };
231
+ var VIDEO_GRADE_PRESET_NAMES = Object.keys(VIDEO_GRADE_PRESETS);
232
+ var clampTo = (c, v) => Math.min(c.max, Math.max(c.min, v));
233
+ function normalizeVideoGrade(grade) {
234
+ if (!grade) return void 0;
235
+ const out = {};
236
+ for (const c of VIDEO_GRADE_CONTROLS) {
237
+ const v = grade[c.key];
238
+ if (typeof v === "number" && Number.isFinite(v) && v !== 0) out[c.key] = Math.round(clampTo(c, v) * 1e3) / 1e3;
239
+ }
240
+ return Object.keys(out).length ? out : void 0;
241
+ }
242
+ function videoGradePreset(name, amount = 1) {
243
+ const preset = VIDEO_GRADE_PRESETS[name];
244
+ if (!preset) throw new Error(`There is no grade preset "${name}"; the presets are ${VIDEO_GRADE_PRESET_NAMES.join(", ")}.`);
245
+ const k = Math.min(1, Math.max(0, amount));
246
+ return normalizeVideoGrade(Object.fromEntries(Object.entries(preset).map(([key, v]) => [key, v * k])));
247
+ }
248
+ function videoGradeSteps(grade) {
249
+ const g = normalizeVideoGrade(grade);
250
+ if (!g) return [];
251
+ const steps = [];
252
+ if (g.exposure) steps.push({ kind: "brightness", amount: 2 ** g.exposure });
253
+ if (g.contrast) steps.push({ kind: "contrast", amount: 1 + g.contrast });
254
+ if (g.saturation) steps.push({ kind: "saturate", amount: 1 + g.saturation });
255
+ if (g.hue) steps.push({ kind: "hue-rotate", degrees: g.hue });
256
+ if (g.temperature || g.tint) {
257
+ const t = g.temperature ?? 0;
258
+ steps.push({ kind: "gains", r: 1 + 0.25 * t, g: 1 - 0.25 * (g.tint ?? 0), b: 1 - 0.25 * t });
259
+ }
260
+ return steps;
261
+ }
262
+ function videoGradeStepMatrix(step) {
263
+ switch (step.kind) {
264
+ case "brightness":
265
+ return [
266
+ [step.amount, 0, 0, 0],
267
+ [0, step.amount, 0, 0],
268
+ [0, 0, step.amount, 0]
269
+ ];
270
+ case "contrast": {
271
+ const c = step.amount;
272
+ const o = 0.5 - 0.5 * c;
273
+ return [
274
+ [c, 0, 0, o],
275
+ [0, c, 0, o],
276
+ [0, 0, c, o]
277
+ ];
278
+ }
279
+ case "gains":
280
+ return [
281
+ [step.r, 0, 0, 0],
282
+ [0, step.g, 0, 0],
283
+ [0, 0, step.b, 0]
284
+ ];
285
+ case "saturate": {
286
+ const s = step.amount;
287
+ return [
288
+ [0.213 + 0.787 * s, 0.715 - 0.715 * s, 0.072 - 0.072 * s, 0],
289
+ [0.213 - 0.213 * s, 0.715 + 0.285 * s, 0.072 - 0.072 * s, 0],
290
+ [0.213 - 0.213 * s, 0.715 - 0.715 * s, 0.072 + 0.928 * s, 0]
291
+ ];
292
+ }
293
+ case "hue-rotate": {
294
+ const a = step.degrees * Math.PI / 180;
295
+ const cos = Math.cos(a);
296
+ const sin = Math.sin(a);
297
+ return [
298
+ [0.213 + cos * 0.787 - sin * 0.213, 0.715 - cos * 0.715 - sin * 0.715, 0.072 - cos * 0.072 + sin * 0.928, 0],
299
+ [0.213 - cos * 0.213 + sin * 0.143, 0.715 + cos * 0.285 + sin * 0.14, 0.072 - cos * 0.072 - sin * 0.283, 0],
300
+ [0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072, 0]
301
+ ];
302
+ }
303
+ }
304
+ }
305
+ function applyVideoGrade(grade, rgb) {
306
+ let v = rgb.map((x) => x / 255);
307
+ for (const step of videoGradeSteps(grade)) {
308
+ const m = videoGradeStepMatrix(step);
309
+ v = m.map((row) => Math.min(1, Math.max(0, row[0] * v[0] + row[1] * v[1] + row[2] * v[2] + row[3])));
310
+ }
311
+ return v.map((x) => Math.round(x * 255));
312
+ }
313
+ var fmt2 = (v) => String(Math.round(v * 1e5) / 1e5);
314
+ function videoGradeStepSvgValues(step) {
315
+ return videoGradeStepMatrix(step).map((row) => [row[0], row[1], row[2], 0, row[3]].map(fmt2).join(" ")).concat("0 0 0 1 0").join(" ");
316
+ }
317
+ function videoGradeSvgFilter(id, grade) {
318
+ const steps = videoGradeSteps(grade);
319
+ return `<filter id="${id}" color-interpolation-filters="sRGB" x="0" y="0" width="100%" height="100%">${steps.map((s) => `<feColorMatrix type="matrix" values="${videoGradeStepSvgValues(s)}"/>`).join("")}</filter>`;
320
+ }
321
+ var videoGradeGainsFilterId = (step) => `pixerate-wb-${[step.r, step.g, step.b].map((v) => Math.round(v * 1e3)).join("-")}`;
322
+ function videoGradeCssFilter(grade) {
323
+ return videoGradeSteps(grade).map((s) => s.kind === "brightness" ? `brightness(${fmt2(s.amount)})` : s.kind === "contrast" ? `contrast(${fmt2(s.amount)})` : s.kind === "saturate" ? `saturate(${fmt2(s.amount)})` : s.kind === "hue-rotate" ? `hue-rotate(${fmt2(s.degrees)}deg)` : `url(#${videoGradeGainsFilterId(s)})`).join(" ");
324
+ }
325
+ function videoGradeSvgDefs(grade) {
326
+ return videoGradeSteps(grade).filter((s) => s.kind === "gains").map((s) => {
327
+ const id = videoGradeGainsFilterId(s);
328
+ return { id, markup: `<filter id="${id}" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${videoGradeStepSvgValues(s)}"/></filter>` };
329
+ });
330
+ }
331
+ function videoGradeFfmpegFilters(grade) {
332
+ return videoGradeSteps(grade).map((step) => {
333
+ const m = videoGradeStepMatrix(step);
334
+ if (step.kind === "brightness" || step.kind === "contrast") {
335
+ const lut = (i) => `clip(val*${fmt2(m[i][i])}+${fmt2(m[i][3] * 255)},0,255)`;
336
+ return `lutrgb=r='${lut(0)}':g='${lut(1)}':b='${lut(2)}'`;
337
+ }
338
+ const k = ["r", "g", "b"];
339
+ return `colorchannelmixer=${m.flatMap((row, i) => row.slice(0, 3).map((v, j) => `${k[i]}${k[j]}=${fmt2(v)}`)).join(":")}`;
340
+ });
341
+ }
342
+
210
343
  // src/video-editor/parse.ts
211
344
  var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
212
345
  var fail = (path, what) => {
@@ -275,6 +408,22 @@ function parseVideoTransform(v, path) {
275
408
  rotation: parseVideoAnimatable(v.rotation, `${path}.rotation`, { optional: true })
276
409
  });
277
410
  }
411
+ function parseVideoClipAnimation(v, path) {
412
+ if (v === void 0) return void 0;
413
+ if (!isObject(v)) return fail(path, "must be an object { effect, duration }");
414
+ return compact({
415
+ effect: oneOf(v.effect, VIDEO_CLIP_ANIMATION_EFFECTS, `${path}.effect`, false),
416
+ duration: num(v.duration, `${path}.duration`, { optional: true, positive: true })
417
+ });
418
+ }
419
+ function parseVideoGrade(v, path) {
420
+ if (v === void 0) return void 0;
421
+ if (!isObject(v)) return fail(path, "must be an object such as { exposure, contrast, saturation }");
422
+ const known = new Set(VIDEO_GRADE_CONTROLS.map((c) => c.key));
423
+ const unknown = Object.keys(v).find((k) => !known.has(k));
424
+ if (unknown) fail(`${path}.${unknown}`, `is not a grade setting; use ${[...known].join(", ")}`);
425
+ return compact(Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, num(v[c.key], `${path}.${c.key}`, { optional: true, min: c.min, max: c.max })])));
426
+ }
278
427
  function parseVideoTransition(v, path) {
279
428
  if (v === void 0) return void 0;
280
429
  if (!isObject(v)) return fail(path, "must be an object { type, duration }");
@@ -282,9 +431,22 @@ function parseVideoTransition(v, path) {
282
431
  type: oneOf(v.type, VIDEO_TRANSITION_TYPES, `${path}.type`, false),
283
432
  duration: num(v.duration, `${path}.duration`, { positive: true }),
284
433
  color: str(v.color, `${path}.color`, true),
285
- direction: oneOf(v.direction, VIDEO_WIPE_DIRECTIONS, `${path}.direction`)
434
+ direction: oneOf(v.direction, VIDEO_WIPE_DIRECTIONS, `${path}.direction`),
435
+ audioCurve: oneOf(v.audioCurve, VIDEO_FADE_CURVES, `${path}.audioCurve`)
286
436
  });
287
437
  }
438
+ function parseVideoTranscriptWords(v, path) {
439
+ if (v === void 0) return void 0;
440
+ if (!Array.isArray(v)) return fail(path, "must be an array of { word, start, end } (ms in the source)");
441
+ const words = v.map((w, i) => {
442
+ if (!isObject(w)) return fail(`${path}[${i}]`, "must be an object { word, start, end }");
443
+ const word = str(w.word, `${path}[${i}].word`);
444
+ const start = num(w.start, `${path}[${i}].start`, { min: 0 });
445
+ const end = num(w.end, `${path}[${i}].end`, { min: start });
446
+ return { word, start, end };
447
+ });
448
+ return words.sort((a, b) => a.start - b.start);
449
+ }
288
450
  function parseVideoAsset(v, path = "asset") {
289
451
  if (!isObject(v)) return fail(path, "must be an object");
290
452
  const kind = oneOf(v.kind, ["video", "audio", "image"], `${path}.kind`, false);
@@ -297,8 +459,10 @@ function parseVideoAsset(v, path = "asset") {
297
459
  width: num(v.width, `${path}.width`, { optional: true, positive: true }),
298
460
  height: num(v.height, `${path}.height`, { optional: true, positive: true }),
299
461
  hasAudio: bool(v.hasAudio, `${path}.hasAudio`),
300
- poster: str(v.poster, `${path}.poster`, true)
462
+ poster: str(v.poster, `${path}.poster`, true),
463
+ transcript: parseVideoTranscriptWords(v.transcript, `${path}.transcript`)
301
464
  });
465
+ if (kind === "image" && asset.transcript) fail(`${path}.transcript`, "is only for video and audio assets");
302
466
  if (kind === "image" && asset.duration !== void 0) fail(`${path}.duration`, "is only for video and audio assets");
303
467
  return asset;
304
468
  }
@@ -313,6 +477,10 @@ function parseVideoClip(v, path = "clip") {
313
477
  opacity: parseVideoAnimatable(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),
314
478
  transform: parseVideoTransform(v.transform, `${path}.transform`),
315
479
  fit: oneOf(v.fit, VIDEO_FITS, `${path}.fit`),
480
+ blend: oneOf(v.blend, VIDEO_BLEND_MODES, `${path}.blend`),
481
+ grade: parseVideoGrade(v.grade, `${path}.grade`),
482
+ animateIn: parseVideoClipAnimation(v.animateIn, `${path}.animateIn`),
483
+ animateOut: parseVideoClipAnimation(v.animateOut, `${path}.animateOut`),
316
484
  fadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),
317
485
  fadeOut: num(v.fadeOut, `${path}.fadeOut`, { optional: true, min: 0 }),
318
486
  transition: parseVideoTransition(v.transition, `${path}.transition`)
@@ -563,6 +731,66 @@ function videoClipsAt(project, time) {
563
731
  return out;
564
732
  }
565
733
 
734
+ // src/video-editor/clip-animation.ts
735
+ var VIDEO_CLIP_ANIMATION_DURATION = 500;
736
+ var EFFECTS = {
737
+ fade: { easeIn: "linear", easeOut: "linear" },
738
+ rise: { y: 0.06, easeIn: "ease-out", easeOut: "ease-in" },
739
+ drop: { y: -0.06, easeIn: "ease-out", easeOut: "ease-in" },
740
+ "slide-left": { x: 0.15, easeIn: "ease-out", easeOut: "ease-in" },
741
+ "slide-right": { x: -0.15, easeIn: "ease-out", easeOut: "ease-in" },
742
+ zoom: { scale: 0.6, easeIn: "ease-out", easeOut: "ease-in" },
743
+ // Overshoots past full size, then settles (a "back" curve).
744
+ pop: { scale: 0.3, easeIn: [0.34, 1.56, 0.64, 1], easeOut: [0.36, 0, 0.66, -0.56] }
745
+ };
746
+ var PROPS = ["opacity", "x", "y", "scale"];
747
+ function videoClipAnimationProperties(animation) {
748
+ const spec = EFFECTS[animation.effect];
749
+ return PROPS.filter((p) => p === "opacity" || spec[p] !== void 0);
750
+ }
751
+ function videoClipAnimationSpans(clip) {
752
+ const a = clip.animateIn ? clip.animateIn.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0;
753
+ const b = clip.animateOut ? clip.animateOut.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0;
754
+ const k = a + b > clip.duration ? clip.duration / (a + b) : 1;
755
+ return { in: a * k, out: b * k };
756
+ }
757
+ function videoClipAnimated(clip, project) {
758
+ if (!clip.animateIn && !clip.animateOut) return clip;
759
+ const spans = videoClipAnimationSpans(clip);
760
+ const out = { ...clip, transform: { ...clip.transform } };
761
+ for (const prop of PROPS) {
762
+ const current = prop === "opacity" ? clip.opacity : clip.transform?.[prop];
763
+ if (isVideoKeyframes(current)) continue;
764
+ const base = current ?? (prop === "opacity" || prop === "scale" ? 1 : 0);
765
+ const away = (spec) => {
766
+ if (prop === "opacity") return 0;
767
+ const v = spec[prop];
768
+ if (v === void 0) return void 0;
769
+ return prop === "scale" ? base * v : base + v * (prop === "x" ? project.width : project.height);
770
+ };
771
+ const keys = [];
772
+ if (clip.animateIn) {
773
+ const spec = EFFECTS[clip.animateIn.effect];
774
+ const from = away(spec);
775
+ if (from !== void 0) keys.push({ t: 0, v: from, ease: spec.easeIn }, { t: spans.in, v: base });
776
+ }
777
+ if (clip.animateOut) {
778
+ const spec = EFFECTS[clip.animateOut.effect];
779
+ const to = away(spec);
780
+ if (to !== void 0) {
781
+ const start = clip.duration - spans.out;
782
+ if (!keys.length || keys[keys.length - 1].t < start) keys.push({ t: start, v: base, ease: spec.easeOut });
783
+ else keys[keys.length - 1] = { ...keys[keys.length - 1], ease: spec.easeOut };
784
+ keys.push({ t: clip.duration, v: to });
785
+ }
786
+ }
787
+ if (!keys.length) continue;
788
+ if (prop === "opacity") out.opacity = { keys };
789
+ else out.transform[prop] = { keys };
790
+ }
791
+ return out;
792
+ }
793
+
566
794
  // src/video-editor/check.ts
567
795
  function videoClipKeyframes(clip) {
568
796
  const out = [
@@ -581,6 +809,8 @@ var VIDEO_ISSUE_CODES = [
581
809
  "transition-too-long",
582
810
  "transition-handles",
583
811
  "fades-too-long",
812
+ "animations-too-long",
813
+ "animation-keyed",
584
814
  "keyframes-outside-clip",
585
815
  "unknown-duration",
586
816
  "unused-asset",
@@ -617,6 +847,16 @@ function checkVideoProject(project) {
617
847
  issues.push({ code: "overlap", severity: "error", ...at, message: `Clips "${clip.id}" and "${next.id}" overlap on track "${track.id}" from ${formatVideoSeconds(next.start)}; move one of them.` });
618
848
  if ((clip.fadeIn ?? 0) + (clip.fadeOut ?? 0) > clip.duration + VIDEO_TIME_EPSILON)
619
849
  issues.push({ code: "fades-too-long", severity: "warning", ...at, message: `Clip "${clip.id}"'s fades (${clip.fadeIn ?? 0} + ${clip.fadeOut ?? 0} ms) are longer than the clip (${clip.duration} ms); shorten them.` });
850
+ const anim = (clip.animateIn ? clip.animateIn.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0) + (clip.animateOut ? clip.animateOut.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0);
851
+ if (anim > clip.duration + VIDEO_TIME_EPSILON)
852
+ issues.push({ code: "animations-too-long", severity: "warning", ...at, message: `Clip "${clip.id}"'s entrance and exit (${anim} ms together) are longer than the clip (${clip.duration} ms), so both are shortened; give them shorter durations.` });
853
+ for (const which of ["animateIn", "animateOut"]) {
854
+ const a = clip[which];
855
+ if (!a) continue;
856
+ const keyed = videoClipAnimationProperties(a).filter((p) => videoKeysOf(p === "opacity" ? clip.opacity : clip.transform?.[p]).length);
857
+ if (keyed.length)
858
+ issues.push({ code: "animation-keyed", severity: "note", ...at, message: `Clip "${clip.id}"'s ${a.effect} ${which === "animateIn" ? "entrance" : "exit"} leaves ${keyed.join(", ")} alone, because the clip keys ${keyed.length > 1 ? "them" : "it"} itself; remove those keyframes or the animation.` });
859
+ }
620
860
  for (const { property, keys } of videoClipKeyframes(clip)) {
621
861
  const outside = keys.filter((k) => k.t < -VIDEO_TIME_EPSILON || k.t > clip.duration + VIDEO_TIME_EPSILON);
622
862
  if (outside.length)
@@ -730,7 +970,7 @@ function retimeClipKeys(clip, fn) {
730
970
  if (isVideoKeyframes(v)) a.set(fn(v));
731
971
  }
732
972
  }
733
- var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
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"];
734
974
  var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
735
975
  var Editor = class {
736
976
  constructor(p) {
@@ -827,6 +1067,7 @@ var Editor = class {
827
1067
  delete right.transition;
828
1068
  delete right.fadeIn;
829
1069
  delete right.fadeInCurve;
1070
+ delete right.animateIn;
830
1071
  const leftValues = clipAnimatables(clip);
831
1072
  clipAnimatables(right).forEach((r, i) => {
832
1073
  const [l, rr] = splitVideoAnimatable(leftValues[i].get(), at - clip.start);
@@ -836,6 +1077,7 @@ var Editor = class {
836
1077
  this.moveIn(right, at - clip.start);
837
1078
  clip.duration = at - clip.start;
838
1079
  delete clip.fadeOut;
1080
+ delete clip.animateOut;
839
1081
  if (clip.type === "media") delete clip.fadeOutCurve;
840
1082
  track.clips.push(right);
841
1083
  track.clips.sort((a, b) => a.start - b.start);
@@ -1387,6 +1629,7 @@ function videoTitleFrame(project, clip, clipTime = 0) {
1387
1629
  return { time: clip.start, width: project.width, height: project.height, background: "rgba(0,0,0,0)", layers: [layer], audio: [] };
1388
1630
  }
1389
1631
  var clamp01 = (v) => Math.min(1, Math.max(0, v));
1632
+ var videoTransitionEase = (progress) => (1 - Math.cos(Math.PI * clamp01(progress))) / 2;
1390
1633
  function videoFadeCurveGain(curve, x) {
1391
1634
  const t = clamp01(x);
1392
1635
  switch (curve) {
@@ -1440,8 +1683,8 @@ function videoFrameAt(project, time) {
1440
1683
  if (outgoing?.type === "dip" && outgoing.progress >= 0.5) continue;
1441
1684
  const inside = Math.min(Math.max(time, clip.start), videoClipEnd(clip));
1442
1685
  const fade = fadeGain(clip, inside);
1443
- const level = (t, into) => t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress;
1444
- const mix = (incoming ? level(incoming, true) : 1) * (outgoing ? level(outgoing, false) : 1);
1686
+ const level = (t, transition, into) => videoFadeCurveGain(videoTransitionAudioCurve(transition), t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress);
1687
+ const mix = (incoming ? level(incoming, incoming.transition, true) : 1) * (outgoing ? level(outgoing, outgoing.next.transition, false) : 1);
1445
1688
  const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
1446
1689
  const sourceTime = sourceTimeAt(clip, time);
1447
1690
  const clipTime = time - clip.start;
@@ -1452,20 +1695,39 @@ function videoFrameAt(project, time) {
1452
1695
  }
1453
1696
  if (track.kind !== "video" || track.hidden) continue;
1454
1697
  if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
1455
- let opacity = videoValueAt(clip.opacity, clipTime, 1) * fade;
1698
+ const shown = videoClipAnimated(clip, project);
1699
+ let opacity = videoValueAt(shown.opacity, clipTime, 1) * fade;
1456
1700
  let wipe;
1457
1701
  let dip = null;
1702
+ let iris;
1703
+ let shift = 0;
1704
+ let shiftDirection = "right";
1458
1705
  if (incoming) {
1459
1706
  if (incoming.type === "crossfade") opacity *= incoming.progress;
1460
1707
  else if (incoming.type === "wipe") wipe = { direction: incoming.transition.direction ?? "right", progress: incoming.progress };
1461
- else dip = { color: incoming.transition.color ?? "#000000", opacity: (1 - incoming.progress) * 2 };
1708
+ else if (incoming.type === "iris") iris = { progress: incoming.progress };
1709
+ else if (incoming.type === "push" || incoming.type === "slide") {
1710
+ shiftDirection = incoming.transition.direction ?? "right";
1711
+ shift = videoTransitionEase(incoming.progress) - 1;
1712
+ } else dip = { color: incoming.transition.color ?? "#000000", opacity: (1 - incoming.progress) * 2 };
1462
1713
  }
1463
1714
  if (outgoing?.type === "dip") dip = { color: outgoing.next.transition.color ?? "#000000", opacity: outgoing.progress * 2 };
1715
+ if (outgoing?.type === "push") {
1716
+ shiftDirection = outgoing.next.transition.direction ?? "right";
1717
+ shift = videoTransitionEase(outgoing.progress);
1718
+ }
1464
1719
  const size = clip.type === "media" ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
1465
1720
  const fit = clip.fit ?? (clip.type === "media" ? "contain" : "fill");
1466
- const rect = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, clip.transform, clipTime);
1467
- const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(clip.transform?.rotation, clipTime, 0) };
1721
+ const placed = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, shown.transform, clipTime);
1722
+ const across = shiftDirection === "left" || shiftDirection === "right";
1723
+ const sign = shiftDirection === "left" || shiftDirection === "up" ? -1 : 1;
1724
+ const rect = shift ? { ...placed, ...across ? { x: placed.x + sign * shift * project.width } : { y: placed.y + sign * shift * project.height } } : placed;
1725
+ const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(shown.transform?.rotation, clipTime, 0) };
1468
1726
  if (wipe) layer.wipe = wipe;
1727
+ if (iris) layer.iris = iris;
1728
+ if (clip.blend && clip.blend !== "normal") layer.blend = clip.blend;
1729
+ const grade = normalizeVideoGrade(clip.grade);
1730
+ if (grade) layer.grade = grade;
1469
1731
  if (clip.type === "media") Object.assign(layer, { asset: asset.id, assetKind: asset.kind, src: asset.src, sourceTime });
1470
1732
  if (clip.type === "text") layer.text = videoTextStyle(clip);
1471
1733
  if (clip.type === "color") layer.color = clip.color;
@@ -1693,9 +1955,175 @@ var VideoAudioPeaksCache = class {
1693
1955
  }
1694
1956
  };
1695
1957
 
1958
+ // src/video-editor/transcript.ts
1959
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1960
+ function cueTime(s) {
1961
+ const m = s.trim().match(/^(?:(\d+):)?(\d{1,2}):(\d{1,2})[.,](\d{1,3})$/);
1962
+ if (!m) throw new Error(`"${s.trim()}" is not a cue time (HH:MM:SS,mmm).`);
1963
+ return ((Number(m[1] ?? 0) * 60 + Number(m[2])) * 60 + Number(m[3])) * 1e3 + Number(m[4].padEnd(3, "0"));
1964
+ }
1965
+ function cueWords(text, start, end) {
1966
+ const words = text.replace(/<[^>]+>/g, "").split(/\s+/).filter(Boolean);
1967
+ const step = (end - start) / Math.max(1, words.length);
1968
+ return words.map((word, i) => ({ word, start: Math.round(start + i * step), end: Math.round(start + (i + 1) * step) }));
1969
+ }
1970
+ function parseVideoTranscript(input) {
1971
+ let data = input;
1972
+ if (typeof input === "string") {
1973
+ const text = input.replace(/^/, "").trim();
1974
+ if (text.startsWith("{") || text.startsWith("[")) {
1975
+ try {
1976
+ data = JSON.parse(text);
1977
+ } catch {
1978
+ throw new Error("The transcript looks like JSON but is not valid JSON.");
1979
+ }
1980
+ } else {
1981
+ const words2 = [];
1982
+ for (const block of text.replace(/\r\n?/g, "\n").split(/\n{2,}/)) {
1983
+ const lines = block.split("\n");
1984
+ const at = lines.findIndex((l) => l.includes("-->"));
1985
+ if (at < 0) continue;
1986
+ const [a, b] = lines[at].split("-->");
1987
+ words2.push(...cueWords(lines.slice(at + 1).join(" "), cueTime(a), cueTime(b.trim().split(/\s+/)[0])));
1988
+ }
1989
+ if (!words2.length) throw new Error("No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.");
1990
+ return { words: words2, approximate: true };
1991
+ }
1992
+ }
1993
+ const seconds = (v) => typeof v === "number" && Number.isFinite(v) ? Math.round(v * 1e3) : NaN;
1994
+ const fromList = (list, toMs) => list.map((w, i) => {
1995
+ if (!isRecord(w)) throw new Error(`Word ${i + 1} is not an object { word, start, end }.`);
1996
+ const word = String(w.word ?? w.text ?? "").trim();
1997
+ const start = toMs(w.start);
1998
+ const end = toMs(w.end);
1999
+ if (!word || !Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Word ${i + 1} needs a word and numeric start and end.`);
2000
+ return { word, start, end: Math.max(start, end) };
2001
+ });
2002
+ let words = [];
2003
+ if (Array.isArray(data)) words = fromList(data, (v) => typeof v === "number" ? v : NaN);
2004
+ else if (isRecord(data) && Array.isArray(data.words)) words = fromList(data.words, seconds);
2005
+ else if (isRecord(data) && Array.isArray(data.segments)) {
2006
+ const segments = data.segments.filter(isRecord);
2007
+ if (segments.some((s) => Array.isArray(s.words))) words = fromList(segments.flatMap((s) => Array.isArray(s.words) ? s.words : []), seconds);
2008
+ else {
2009
+ for (const s of segments) words.push(...cueWords(String(s.text ?? ""), seconds(s.start), seconds(s.end)));
2010
+ if (!words.length) throw new Error("No words found in the segments.");
2011
+ return { words, approximate: true };
2012
+ }
2013
+ }
2014
+ if (!words.length) throw new Error("No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.");
2015
+ return { words: words.sort((a, b) => a.start - b.start), approximate: false };
2016
+ }
2017
+ function videoTranscriptTrack(project, track) {
2018
+ if (track !== void 0) {
2019
+ 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(", ")}.`);
2020
+ return track;
2021
+ }
2022
+ const assets = videoAssetMap(project);
2023
+ const transcribed = (t) => t.clips.some((c) => c.type === "media" && assets.get(c.asset)?.transcript?.length);
2024
+ return (project.tracks.find((t) => t.kind === "audio" && transcribed(t)) ?? project.tracks.find(transcribed))?.id ?? null;
2025
+ }
2026
+ function videoTimelineTranscript(project, options = {}) {
2027
+ const id = videoTranscriptTrack(project, options.track);
2028
+ if (!id) return [];
2029
+ const track = project.tracks.find((t) => t.id === id);
2030
+ const assets = videoAssetMap(project);
2031
+ const out = [];
2032
+ for (const clip of sortedVideoClips(track)) {
2033
+ if (clip.type !== "media") continue;
2034
+ const words = assets.get(clip.asset)?.transcript;
2035
+ if (!words?.length) continue;
2036
+ const speed = videoClipSpeed(clip);
2037
+ const from = clip.in ?? 0;
2038
+ const to = from + clip.duration * speed;
2039
+ const toTimeline = (s) => Math.min(videoClipEnd(clip), Math.max(clip.start, clip.start + (s - from) / speed));
2040
+ words.forEach((w, index) => {
2041
+ const mid = (w.start + w.end) / 2;
2042
+ if (mid < from || mid >= to) return;
2043
+ out.push({ word: w.word, start: toTimeline(w.start), end: toTimeline(w.end), clip: clip.id, track: track.id, index });
2044
+ });
2045
+ }
2046
+ return out;
2047
+ }
2048
+ var normalizeVideoWord = (word) => word.toLowerCase().replace(/^[^\p{L}\p{N}']+|[^\p{L}\p{N}']+$/gu, "");
2049
+ 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(" ") });
2050
+ function findVideoTranscriptText(words, query) {
2051
+ const q = query.split(/\s+/).map(normalizeVideoWord).filter(Boolean);
2052
+ if (!q.length) throw new Error("Search for at least one word.");
2053
+ const norm = words.map((w) => normalizeVideoWord(w.word));
2054
+ const out = [];
2055
+ 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));
2056
+ return out;
2057
+ }
2058
+ var VIDEO_DEFAULT_FILLERS = ["um", "umm", "uh", "uhh", "uhm", "erm", "er", "ah", "hmm", "mm", "you know", "i mean"];
2059
+ function findVideoFillers(words, fillers = VIDEO_DEFAULT_FILLERS) {
2060
+ const phrases = fillers.map((f) => f.split(/\s+/).map(normalizeVideoWord).filter(Boolean)).filter((p) => p.length).sort((a, b) => b.length - a.length);
2061
+ const norm = words.map((w) => normalizeVideoWord(w.word));
2062
+ const out = [];
2063
+ for (let i = 0; i < norm.length; ) {
2064
+ const hit = phrases.find((p) => i + p.length <= norm.length && p.every((x, k) => norm[i + k] === x));
2065
+ if (hit) {
2066
+ out.push(rangeOf(words, i, hit.length));
2067
+ i += hit.length;
2068
+ } else i++;
2069
+ }
2070
+ return out;
2071
+ }
2072
+ function findVideoTranscriptPauses(words, options = {}) {
2073
+ const min = options.min ?? 700;
2074
+ const keep = options.keep ?? 150;
2075
+ const out = [];
2076
+ for (let i = 1; i < words.length; i++) {
2077
+ const a = words[i - 1];
2078
+ const b = words[i];
2079
+ if (a.clip !== b.clip || b.start - a.end < min) continue;
2080
+ let from = a.end + keep;
2081
+ let to = b.start - keep;
2082
+ if (options.fps) {
2083
+ const f = 1e3 / options.fps;
2084
+ from = snapToVideoFrame(Math.ceil(from / f - 1e-9) * f, options.fps);
2085
+ to = snapToVideoFrame(Math.floor(to / f + 1e-9) * f, options.fps);
2086
+ }
2087
+ if (to - from > VIDEO_TIME_EPSILON) out.push({ from, to, text: `(pause after "${a.word}")` });
2088
+ }
2089
+ return out;
2090
+ }
2091
+ function mergeVideoRanges(ranges) {
2092
+ 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);
2093
+ const out = [];
2094
+ for (const r of sorted) {
2095
+ const last = out[out.length - 1];
2096
+ if (last && r.from <= last.to + VIDEO_TIME_EPSILON) last.to = Math.max(last.to, r.to);
2097
+ else out.push(r);
2098
+ }
2099
+ return out;
2100
+ }
2101
+ function videoRemoveRangesEdits(ranges, tracks) {
2102
+ return mergeVideoRanges(ranges).reverse().map((r) => ({ op: "extract_range", from: r.from, to: r.to, ...tracks ? { tracks } : {} }));
2103
+ }
2104
+ function videoWordsRange(words, a, b) {
2105
+ const [i, j] = a <= b ? [a, b] : [b, a];
2106
+ return { from: words[i].start, to: words[j].end };
2107
+ }
2108
+ function videoTranscriptEdit(assetId, text) {
2109
+ return { op: "update_asset", asset: assetId, set: { transcript: parseVideoTranscript(text).words } };
2110
+ }
2111
+ function videoWordAt(words, time) {
2112
+ let lo = 0;
2113
+ let hi = words.length - 1;
2114
+ while (lo <= hi) {
2115
+ const mid = lo + hi >> 1;
2116
+ if (words[mid].end <= time) lo = mid + 1;
2117
+ else if (words[mid].start > time) hi = mid - 1;
2118
+ else return mid;
2119
+ }
2120
+ return -1;
2121
+ }
2122
+
1696
2123
  // src/video-editor/render.ts
1697
2124
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1698
2125
  var n = (v) => String(Math.round(v * 100) / 100);
2126
+ var videoIrisRadius = (frame, progress) => progress * Math.hypot(frame.width / 2, frame.height / 2);
1699
2127
  function videoWipeRect(rect, wipe) {
1700
2128
  const { direction, progress } = wipe;
1701
2129
  const w = rect.width * progress;
@@ -1713,6 +2141,11 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
1713
2141
  const { rect } = layer;
1714
2142
  const attrs = [];
1715
2143
  if (layer.opacity < 1) attrs.push(`opacity="${n(layer.opacity)}"`);
2144
+ if (layer.blend) attrs.push(`style="mix-blend-mode:${layer.blend === "add" ? "plus-lighter" : layer.blend}"`);
2145
+ if (layer.grade) {
2146
+ defs.push(videoGradeSvgFilter(`g${i}`, layer.grade));
2147
+ attrs.push(`filter="url(#g${i})"`);
2148
+ }
1716
2149
  if (layer.rotation) attrs.push(`transform="rotate(${n(layer.rotation)} ${n(rect.x + rect.width / 2)} ${n(rect.y + rect.height / 2)})"`);
1717
2150
  if (layer.wipe) {
1718
2151
  const r = videoWipeRect(rect, layer.wipe);
@@ -1744,7 +2177,12 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
1744
2177
  inner = `<rect ${box} fill="#334155" stroke="#94a3b8" stroke-width="2"/><text x="${n(rect.x + rect.width / 2)}" y="${n(rect.y + rect.height / 2)}" text-anchor="middle" dominant-baseline="central" font-family="sans-serif" font-size="${n(size)}" fill="#e2e8f0">${esc(label)}</text>`;
1745
2178
  }
1746
2179
  }
1747
- body.push(attrs.length ? `<g ${attrs.join(" ")}>${inner}</g>` : inner);
2180
+ let item = attrs.length ? `<g ${attrs.join(" ")}>${inner}</g>` : inner;
2181
+ if (layer.iris) {
2182
+ defs.push(`<clipPath id="i${i}"><circle cx="${n(frame.width / 2)}" cy="${n(frame.height / 2)}" r="${n(videoIrisRadius(frame, layer.iris.progress))}"/></clipPath>`);
2183
+ item = `<g clip-path="url(#i${i})">${item}</g>`;
2184
+ }
2185
+ body.push(item);
1748
2186
  });
1749
2187
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(frame.width)} ${n(frame.height)}" width="${n(frame.width)}" height="${n(frame.height)}">${defs.length ? `<defs>${defs.join("")}</defs>` : ""}<rect width="100%" height="100%" fill="${esc(frame.background)}"/>${body.join("")}</svg>`;
1750
2188
  }
@@ -1768,7 +2206,7 @@ function videoContactSheetSvg(project, times, options = {}) {
1768
2206
  });
1769
2207
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(W)} ${n(H)}" width="${n(W)}" height="${n(H)}"><rect width="100%" height="100%" fill="#f8fafc"/>${cells.join("")}</svg>`;
1770
2208
  }
1771
- function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
2209
+ function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
1772
2210
  ctx.save();
1773
2211
  ctx.setTransform(scale, 0, 0, scale, 0, 0);
1774
2212
  ctx.globalAlpha = 1;
@@ -1778,6 +2216,18 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
1778
2216
  const { rect } = layer;
1779
2217
  ctx.save();
1780
2218
  ctx.globalAlpha = layer.opacity;
2219
+ if (layer.iris) {
2220
+ ctx.beginPath();
2221
+ ctx.arc(frame.width / 2, frame.height / 2, videoIrisRadius(frame, layer.iris.progress), 0, 2 * Math.PI);
2222
+ ctx.clip();
2223
+ }
2224
+ if (layer.blend && layer.blend !== "normal") ctx.globalCompositeOperation = layer.blend === "add" ? "lighter" : layer.blend;
2225
+ if (layer.grade) {
2226
+ const defs = videoGradeSvgDefs(layer.grade);
2227
+ if (options.defineSvgFilter) for (const d of defs) options.defineSvgFilter(d.id, d.markup);
2228
+ const css = videoGradeCssFilter(layer.grade);
2229
+ ctx.filter = options.defineSvgFilter ? css : css.replace(/\s*url\([^)]*\)/g, "").trim() || "none";
2230
+ }
1781
2231
  if (layer.rotation) {
1782
2232
  ctx.translate(rect.x + rect.width / 2, rect.y + rect.height / 2);
1783
2233
  ctx.rotate(layer.rotation * Math.PI / 180);
@@ -1858,7 +2308,7 @@ function toVideoEdl(project, options = {}) {
1858
2308
  const outReel = before.type === "media" ? reelName(assets.get(before.asset)) : "BL";
1859
2309
  const at = sourceAt(before, recIn);
1860
2310
  lines.push(row(n2, outReel, "C", at, at, recIn, recIn));
1861
- lines.push(row(n2, reel, `${t.type === "wipe" ? "W001" : "D"} ${frames}`, sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
2311
+ lines.push(row(n2, reel, `${t.type === "crossfade" || t.type === "dip" ? "D" : "W001"} ${frames}`, sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
1862
2312
  } else lines.push(row(n2, reel, "C", sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
1863
2313
  if (clip.type === "media" && videoClipSpeed(clip) !== 1) lines.push(`M2 ${reel.padEnd(8)} ${(videoClipSpeed(clip) * fps).toFixed(1).padStart(6)} ${tc(sourceAt(clip, recIn))}`);
1864
2314
  if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${asset?.name ?? asset?.src ?? clip.asset}`);
@@ -1894,6 +2344,20 @@ function parseVideoLoudnessMeasurement(output) {
1894
2344
  return m;
1895
2345
  }
1896
2346
  var sec = (ms) => String(Math.round(ms) / 1e3);
2347
+ var FFMPEG_BLEND = {
2348
+ multiply: "multiply",
2349
+ screen: "screen",
2350
+ overlay: "overlay",
2351
+ darken: "darken",
2352
+ lighten: "lighten",
2353
+ "color-dodge": "dodge",
2354
+ "color-burn": "burn",
2355
+ "hard-light": "hardlight",
2356
+ "soft-light": "softlight",
2357
+ difference: "difference",
2358
+ exclusion: "exclusion",
2359
+ add: "addition"
2360
+ };
1897
2361
  var AFADE_CURVES = { linear: "tri", "equal-power": "qsin", "s-curve": "hsin", exponential: "cub", logarithmic: "ipar" };
1898
2362
  var afadeCurve = (curve) => curve && curve !== "linear" ? `:curve=${AFADE_CURVES[curve]}` : "";
1899
2363
  var even = (v) => Math.max(2, 2 * Math.round(v / 2));
@@ -1939,6 +2403,7 @@ function buildFfmpeg(project, options, measure) {
1939
2403
  let cur = "base";
1940
2404
  let usesDrawtext = false;
1941
2405
  const animated = /* @__PURE__ */ new Set();
2406
+ const blended = /* @__PURE__ */ new Set();
1942
2407
  let k = 0;
1943
2408
  const step = 2e3 / fps;
1944
2409
  const expr = (prop, clipTime, fallback, what) => {
@@ -1954,7 +2419,8 @@ function buildFfmpeg(project, options, measure) {
1954
2419
  return inputs.length - 1;
1955
2420
  };
1956
2421
  for (const track of [...project.tracks].reverse()) {
1957
- for (const clip of sortedVideoClips(track)) {
2422
+ for (const raw of sortedVideoClips(track)) {
2423
+ const clip = videoClipAnimated(raw, project);
1958
2424
  const { lead, tail } = videoClipOverhang(track, clip);
1959
2425
  const t0 = clip.start - lead;
1960
2426
  const span = clip.duration + lead + tail;
@@ -1973,11 +2439,13 @@ function buildFfmpeg(project, options, measure) {
1973
2439
  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}`;
1974
2440
  const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), volume];
1975
2441
  const before2 = clip.transition ? videoClipBefore(track, clip) : null;
1976
- if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}`);
1977
- if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}`);
1978
- if (before2 && clip.transition.type === "dip") chain2.push(`afade=t=in:st=0:d=${sec(clip.transition.duration / 2)}`);
1979
- const after = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition?.type === "dip");
1980
- if (after) chain2.push(`afade=t=out:st=${sec(span - after.transition.duration / 2)}:d=${sec(after.transition.duration / 2)}`);
2442
+ const next2 = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition);
2443
+ const inCurve = clip.transition ? afadeCurve(videoTransitionAudioCurve(clip.transition)) : "";
2444
+ const outCurve = next2 ? afadeCurve(videoTransitionAudioCurve(next2.transition)) : "";
2445
+ if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}${inCurve}`);
2446
+ if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}${outCurve}`);
2447
+ if (before2 && clip.transition.type === "dip") chain2.push(`afade=t=in:st=0:d=${sec(clip.transition.duration / 2)}${inCurve}`);
2448
+ if (next2?.transition.type === "dip") chain2.push(`afade=t=out:st=${sec(span - next2.transition.duration / 2)}:d=${sec(next2.transition.duration / 2)}${outCurve}`);
1981
2449
  if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}${afadeCurve(clip.fadeInCurve)}`);
1982
2450
  if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}${afadeCurve(clip.fadeOutCurve)}`);
1983
2451
  chain2.push(`adelay=${Math.round(t0)}:all=1`);
@@ -2016,6 +2484,8 @@ function buildFfmpeg(project, options, measure) {
2016
2484
  `enable='between(t,${sec(clip.start)},${sec(end)})'`
2017
2485
  ];
2018
2486
  if (tf?.rotation) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
2487
+ if (videoGradeFfmpegFilters(clip.grade).length) notes.push(`Title "${clip.id}" is drawn without its grade (drawtext draws straight onto the picture); pass titleImage to grade titles.`);
2488
+ if (clip.blend && clip.blend !== "normal") notes.push(`Title "${clip.id}" is drawn without its blend mode (drawtext draws straight onto the picture); pass titleImage to blend titles.`);
2019
2489
  graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
2020
2490
  cur = next;
2021
2491
  continue;
@@ -2031,10 +2501,8 @@ function buildFfmpeg(project, options, measure) {
2031
2501
  source = `[${index}:v]`;
2032
2502
  chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
2033
2503
  }
2034
- if (isVideoKeyframes(placed?.scale)) {
2035
- const s = expr(placed.scale, onChain("t"), 1, "scale");
2036
- chain.push(`scale=w='max(2,trunc(${even(rect.width)}*(${s})/2)*2)':h='max(2,trunc(${even(rect.height)}*(${s})/2)*2)':eval=frame`);
2037
- }
2504
+ const grade = videoGradeFfmpegFilters(clip.grade);
2505
+ if (grade.length) chain.push("format=rgba", ...grade);
2038
2506
  chain.push("format=yuva420p");
2039
2507
  if (isVideoKeyframes(clip.opacity)) chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='alpha(X,Y)*clip(${expr(clip.opacity, onChain("T"), 1, "opacity")},0,1)'`);
2040
2508
  else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
@@ -2048,18 +2516,50 @@ function buildFfmpeg(project, options, measure) {
2048
2516
  const test = dir === "right" ? `lt(X,W*${p})` : dir === "left" ? `gt(X,W*(1-${p}))` : dir === "down" ? `lt(Y,H*${p})` : `gt(Y,H*(1-${p}))`;
2049
2517
  chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
2050
2518
  }
2519
+ if (lead && clip.transition.type === "iris") {
2520
+ const r = videoIrisRadius({ width: W, height: H }, 1);
2521
+ chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(lte(hypot(X+${Math.round(rect.x)}-${W / 2},Y+${Math.round(rect.y)}-${H / 2}),${Math.round(r)}*min(1,T/${sec(2 * lead)})),alpha(X,Y),0)'`);
2522
+ }
2051
2523
  const rotation = placed?.rotation;
2052
- if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=none:ow='hypot(iw,ih)':oh=ow`);
2053
- else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=none:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
2524
+ if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=black@0:ow='hypot(iw,ih)':oh=ow`);
2525
+ else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=black@0:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
2526
+ if (isVideoKeyframes(placed?.scale)) {
2527
+ const s = expr(placed.scale, onChain("t"), 1, "scale");
2528
+ chain.push(`scale=w='max(2,trunc(iw*(${s})/2)*2)':h='max(2,trunc(ih*(${s})/2)*2)':eval=frame`);
2529
+ }
2054
2530
  chain.push(`setpts=PTS+${sec(t0)}/TB`);
2055
2531
  const cx = rect.x + rect.width / 2;
2056
2532
  const cy = rect.y + rect.height / 2;
2533
+ const shifts = { x: [], y: [] };
2534
+ const shiftBy = (direction, from, d, entering) => {
2535
+ const dir = direction ?? "right";
2536
+ const axis = dir === "left" || dir === "right" ? "x" : "y";
2537
+ const size2 = axis === "x" ? W : H;
2538
+ const sign = dir === "left" || dir === "up" ? -1 : 1;
2539
+ const ease = `(1-cos(PI*clip((t-${sec(from)})/${sec(d)},0,1)))/2`;
2540
+ shifts[axis].push(`${sign * size2}*(${ease}${entering ? "-1" : ""})`);
2541
+ };
2542
+ const into = lead ? clip.transition.type : void 0;
2543
+ if (into === "push" || into === "slide") shiftBy(clip.transition.direction, clip.start - lead, 2 * lead, true);
2544
+ const after = tail ? sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition) : void 0;
2545
+ if (after?.transition.type === "push") shiftBy(after.transition.direction, end - tail, 2 * tail, false);
2057
2546
  const at = (axis, c) => {
2058
2547
  const v = placed?.[axis];
2059
- return isVideoKeyframes(v) ? `${axis}='${Math.round(axis === "x" ? project.width / 2 : project.height / 2)}+(${expr(v, onTimeline, 0, "position")})-${axis === "x" ? "w" : "h"}/2'` : `${axis}='${Math.round(c)}-${axis === "x" ? "w" : "h"}/2'`;
2548
+ const extra = shifts[axis].map((s) => `+${s}`).join("");
2549
+ return isVideoKeyframes(v) ? `${axis}='${Math.round(axis === "x" ? project.width / 2 : project.height / 2)}+(${expr(v, onTimeline, 0, "position")})-${axis === "x" ? "w" : "h"}/2${extra}'` : `${axis}='${Math.round(c)}-${axis === "x" ? "w" : "h"}/2${extra}'`;
2060
2550
  };
2061
2551
  graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
2062
- graph.push(`[${cur}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable}[${next}]`);
2552
+ if (clip.blend && clip.blend !== "normal") {
2553
+ graph.push(`color=c=black@0:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[t${next}]`);
2554
+ graph.push(`[t${next}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable},format=gbrap,split[f${next}][g${next}]`);
2555
+ graph.push(`[${cur}]format=gbrap,split[c${next}][d${next}]`);
2556
+ const layerFirst = clip.blend === "color-dodge" || clip.blend === "color-burn";
2557
+ graph.push(`${layerFirst ? `[f${next}][c${next}]` : `[c${next}][f${next}]`}blend=all_mode=${FFMPEG_BLEND[clip.blend]}[b${next}]`);
2558
+ graph.push(`[g${next}]alphaextract[m${next}]`);
2559
+ graph.push(`[b${next}][m${next}]alphamerge[x${next}]`);
2560
+ graph.push(`[d${next}][x${next}]overlay=format=gbrp,format=yuva420p[${next}]`);
2561
+ blended.add(clip.blend);
2562
+ } else graph.push(`[${cur}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable}[${next}]`);
2063
2563
  cur = next;
2064
2564
  const before = clip.transition ? videoClipBefore(track, clip) : null;
2065
2565
  if (before && clip.transition.type === "dip") {
@@ -2082,6 +2582,7 @@ function buildFfmpeg(project, options, measure) {
2082
2582
  notes.push('Video assets are assumed to have sound; set "hasAudio": false on any that do not, or ffmpeg will stop at a missing audio stream.');
2083
2583
  if (animated.size)
2084
2584
  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
+ 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.");
2085
2586
  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.");
2086
2587
  const filterComplex = graph.join(";");
2087
2588
  if (measure) {
@@ -2129,6 +2630,8 @@ var VideoPreviewEngine = class {
2129
2630
  unsubscribe;
2130
2631
  scheduled = false;
2131
2632
  destroyed = false;
2633
+ filterHost = null;
2634
+ filters = /* @__PURE__ */ new Set();
2132
2635
  createMedia(kind) {
2133
2636
  if (this.options.createMedia) return this.options.createMedia(kind);
2134
2637
  const el = document.createElement(kind);
@@ -2230,12 +2733,31 @@ var VideoPreviewEngine = class {
2230
2733
  const h = Math.max(1, Math.round(frame.height * resolution));
2231
2734
  if (this.canvas.width !== w) this.canvas.width = w;
2232
2735
  if (this.canvas.height !== h) this.canvas.height = h;
2233
- drawVideoFrame(this.ctx, frame, this.sourceFor, resolution);
2736
+ drawVideoFrame(this.ctx, frame, this.sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
2234
2737
  this.options.onFrame?.(frame);
2235
2738
  }
2739
+ defineSvgFilter = (id, markup) => {
2740
+ if (this.options.defineSvgFilter === null) return;
2741
+ if (this.options.defineSvgFilter) return this.options.defineSvgFilter(id, markup);
2742
+ if (this.filters.has(id) || typeof document === "undefined") return;
2743
+ if (!this.filterHost) {
2744
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2745
+ svg.setAttribute("width", "0");
2746
+ svg.setAttribute("height", "0");
2747
+ svg.setAttribute("aria-hidden", "true");
2748
+ svg.setAttribute("style", "position:absolute;width:0;height:0;overflow:hidden");
2749
+ document.body.appendChild(svg);
2750
+ this.filterHost = svg;
2751
+ }
2752
+ this.filterHost.insertAdjacentHTML("beforeend", markup);
2753
+ this.filters.add(id);
2754
+ };
2236
2755
  /** Stops following the editor and releases every media element. */
2237
2756
  destroy() {
2238
2757
  this.destroyed = true;
2758
+ this.filterHost?.remove();
2759
+ this.filterHost = null;
2760
+ this.filters.clear();
2239
2761
  this.unsubscribe();
2240
2762
  for (const entry of [...this.media.values()]) this.release(entry);
2241
2763
  this.images.clear();
@@ -2529,19 +3051,37 @@ var TRANSFORM = object(
2529
3051
  );
2530
3052
  var TRANSITION = object(
2531
3053
  {
2532
- type: { type: "string", enum: [...VIDEO_TRANSITION_TYPES], description: "crossfade (this clip fades up over the one before), dip (down into a colour and up out of it) or wipe (an edge travels across revealing this clip)." },
2533
- duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms, centred on the cut. A crossfade or wipe needs half of it in media past the cut on both sides (handles); a dip needs none." },
3054
+ type: { type: "string", enum: [...VIDEO_TRANSITION_TYPES], description: "crossfade (this clip fades up over the one before), dip (down into a colour and up out of it), wipe (an edge travels across revealing this clip), push (this clip slides in and pushes the one before out), slide (this clip slides in over the one before) or iris (a circle opens from the centre)." },
3055
+ duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms, centred on the cut. Every type but dip needs half of it in media past the cut on both sides (handles); a dip needs none." },
2534
3056
  color: COLOR("For dip: the colour passed through (default black)"),
2535
- direction: { type: "string", enum: [...VIDEO_WIPE_DIRECTIONS], description: "For wipe: the way the edge travels (default right)." }
3057
+ direction: { type: "string", enum: [...VIDEO_WIPE_DIRECTIONS], description: "For wipe, push and slide: the way the edge or picture travels (default right, coming in from the left)." },
3058
+ audioCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fades through it: default equal-power for every type but dip (steady loudness while the two sounds overlap), linear for dip; or s-curve, exponential, logarithmic." }
2536
3059
  },
2537
3060
  ["type", "duration"],
2538
3061
  "A transition from the clip that ends where this one starts on the same track."
2539
3062
  );
3063
+ var GRADE = object(
3064
+ Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, { type: "number", minimum: c.min, maximum: c.max, description: c.description }])),
3065
+ [],
3066
+ "Colour correction of the picture, applied in order: exposure, contrast, saturation, hue, white balance (temperature, tint). Leave a setting out for no change; the object replaces the clip's whole grade."
3067
+ );
3068
+ var ANIMATION = (when) => object(
3069
+ {
3070
+ effect: { type: "string", enum: [...VIDEO_CLIP_ANIMATION_EFFECTS], description: "fade; rise (up into place) or drop (down into place); slide-left / slide-right (moving that way); zoom (grows into place); pop (grows past its size and settles). Each also fades; an exit plays the movement backwards." },
3071
+ duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms (default 500)." }
3072
+ },
3073
+ ["effect"],
3074
+ when
3075
+ );
2540
3076
  var CLIP_COMMON = {
2541
3077
  name: { type: "string", description: "A label for the clip." },
2542
3078
  opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1). A number replaces any keyframes; set_video_keyframes animates it." },
2543
3079
  transform: TRANSFORM,
2544
3080
  fit: { type: "string", enum: [...VIDEO_FITS], description: "How the picture fits the frame before the transform, as CSS object-fit (default contain for media, fill for colours)." },
3081
+ grade: GRADE,
3082
+ animateIn: ANIMATION("How the picture enters, from the clip's start (on top of any fade in). Titles usually rise, pop or fade in."),
3083
+ animateOut: ANIMATION("How the picture leaves, ending at the clip's end."),
3084
+ blend: { type: "string", enum: [...VIDEO_BLEND_MODES], description: "How the picture mixes with what is beneath it, as CSS mix-blend-mode (default normal): multiply darkens (shadows, textures), screen and add lighten (light leaks, flares, glows), overlay and soft-light add contrast, difference and exclusion invert." },
2545
3085
  fadeIn: MS("Fade up from nothing (picture and sound) over this long"),
2546
3086
  fadeOut: MS("Fade down to nothing over this long")
2547
3087
  };
@@ -2630,6 +3170,10 @@ var issueSummary = (issues) => ({
2630
3170
  notes: issues.filter((i) => i.severity === "note").length,
2631
3171
  lines: issues.map((i) => `${i.severity}: ${i.message}`)
2632
3172
  });
3173
+ var videoAssetOut = (a) => {
3174
+ const { transcript, ...rest } = a;
3175
+ return transcript ? { ...rest, transcriptWords: transcript.length } : rest;
3176
+ };
2633
3177
  function describeVideoProject(project) {
2634
3178
  return {
2635
3179
  ...project.name ? { name: project.name } : {},
@@ -2638,7 +3182,7 @@ function describeVideoProject(project) {
2638
3182
  fps: project.fps,
2639
3183
  duration: videoProjectDuration(project),
2640
3184
  ...project.background ? { background: project.background } : {},
2641
- assets: project.assets,
3185
+ assets: project.assets.map(videoAssetOut),
2642
3186
  tracks: project.tracks.map((t, index) => ({
2643
3187
  id: t.id,
2644
3188
  kind: t.kind,
@@ -2702,7 +3246,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
2702
3246
  const getFrame = {
2703
3247
  name: "get_video_frame",
2704
3248
  title: "What plays at a moment",
2705
- description: "Returns what plays at a time without changing anything: the pictures back to front (clip, track, asset, source time in ms, opacity, placement rect, rotation, any wipe) and the sounds (clip, source time, gain after volumes, fades and transitions).",
3249
+ description: "Returns what plays at a time without changing anything: the pictures back to front (clip, track, asset, source time in ms, opacity, placement rect, rotation, any wipe or iris) and the sounds (clip, source time, gain after volumes, fades and transitions).",
2706
3250
  inputSchema: object({ time: MS("Time from the start (default: the playhead)") }),
2707
3251
  annotations: { title: "What plays at a moment", ...READ },
2708
3252
  handler: ({ time }) => {
@@ -2724,7 +3268,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
2724
3268
  const checkProject = {
2725
3269
  name: "check_video_project",
2726
3270
  title: "Check the video project",
2727
- description: "Lists the project's problems, errors first, each with a code, the clip / track / asset it is about and how to fix it: missing assets, overlaps, clips past the end of their source, transitions without a cut or handles, fades longer than the clip, keyframes outside their clip, assets with no duration, unused assets.",
3271
+ description: "Lists the project's problems, errors first, each with a code, the clip / track / asset it is about and how to fix it: missing assets, overlaps, clips past the end of their source, transitions without a cut or handles, fades or animations longer than the clip, animations a clip's own keyframes override, keyframes outside their clip, assets with no duration, unused assets.",
2728
3272
  inputSchema: object({}),
2729
3273
  annotations: { title: "Check the video project", ...READ },
2730
3274
  handler: () => {
@@ -2860,9 +3404,9 @@ function createVideoEditorToolProvider(controller, options = {}) {
2860
3404
  name: "update_video_asset",
2861
3405
  title: "Change media details",
2862
3406
  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.",
2863
- 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"]),
3407
+ 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"]),
2864
3408
  annotations: { title: "Change media details", ...WRITE_IDEMPOTENT },
2865
- handler: ({ asset, set, remove }) => apply({ op: "update_asset", asset, set, remove }, "Change media", (r) => ({ asset: r.project.assets.find((a) => a.id === asset) }))
3409
+ handler: ({ asset, set, remove }) => apply({ op: "update_asset", asset, set, remove }, "Change media", (r) => ({ asset: videoAssetOut(r.project.assets.find((a) => a.id === asset)) }))
2866
3410
  };
2867
3411
  const removeAsset = {
2868
3412
  name: "remove_video_asset",
@@ -2956,12 +3500,12 @@ function createVideoEditorToolProvider(controller, options = {}) {
2956
3500
  const updateClip = {
2957
3501
  name: "update_video_clip",
2958
3502
  title: "Change a clip",
2959
- description: `Changes a clip's properties (one undo step): opacity, transform, fit, fades, name; for media volume, mute, the sound's fade curves, speed (keeps the stretch of source, so the length changes) or asset; for titles text and style; for colours the colour. Fields in "remove" go back to their defaults. Use move / trim for timing. Returns the clip.`,
3503
+ description: `Changes a clip's properties (one undo step): opacity, transform, fit, blend mode, colour grade, entrance and exit animations, fades, name; for media volume, mute, the sound's fade curves, speed (keeps the stretch of source, so the length changes) or asset; for titles text and style; for colours the colour. Fields in "remove" go back to their defaults. Use move / trim for timing. Returns the clip.`,
2960
3504
  inputSchema: object(
2961
3505
  {
2962
3506
  clip: CLIP_ID,
2963
3507
  set: object({ ...CLIP_COMMON, ...MEDIA_FIELDS, asset: { ...ASSET_ID, description: "For media: play another asset (same in point)." }, ...TEXT_FIELDS, color: COLOR("Text colour, or a colour clip's fill") }, [], "Fields to replace (only those that fit the clip's type)."),
2964
- remove: { type: "array", items: { type: "string", enum: ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "in", "speed", "volume", "muted", "fontSize", "fontFamily", "fontWeight", "background"] }, description: "Fields to reset to their defaults." }
3508
+ remove: { type: "array", items: { type: "string", enum: ["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "in", "speed", "volume", "muted", "fontSize", "fontFamily", "fontWeight", "background"] }, description: "Fields to reset to their defaults." }
2965
3509
  },
2966
3510
  ["clip"]
2967
3511
  ),
@@ -2969,17 +3513,168 @@ function createVideoEditorToolProvider(controller, options = {}) {
2969
3513
  handler: ({ clip, set, remove }) => {
2970
3514
  const current = clipOut(project(), clip);
2971
3515
  if (current && set) {
2972
- const allowed = /* @__PURE__ */ new Set(["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", ...current.type === "media" ? ["in", "speed", "volume", "muted", "asset", "fadeInCurve", "fadeOutCurve"] : current.type === "text" ? Object.keys(TEXT_FIELDS) : ["color"]]);
3516
+ const allowed = /* @__PURE__ */ new Set(["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", ...current.type === "media" ? ["in", "speed", "volume", "muted", "asset", "fadeInCurve", "fadeOutCurve"] : current.type === "text" ? Object.keys(TEXT_FIELDS) : ["color"]]);
2973
3517
  const wrong = Object.keys(set).filter((k) => !allowed.has(k));
2974
3518
  if (wrong.length) throw new Error(`${wrong.map((k) => `"${k}"`).join(", ")} cannot be set on a ${current.type} clip; it takes ${[...allowed].join(", ")}.`);
2975
3519
  }
2976
3520
  return apply({ op: "update_clip", clip, set, remove }, "Change clip", (r) => ({ clip: clipOut(r.project, clip) }));
2977
3521
  }
2978
3522
  };
3523
+ const gradeTargets = (ids) => {
3524
+ const p = project();
3525
+ for (const id of ids) {
3526
+ const at = findVideoClip(p, id);
3527
+ if (!at) throw new Error(`There is no clip "${id}"; get_video_project lists the clips.`);
3528
+ const { clip } = at;
3529
+ if (at.track.kind !== "video" || clip.type === "media" && p.assets.find((a) => a.id === clip.asset)?.kind === "audio")
3530
+ throw new Error(`Clip "${id}" is sound only; grades change pictures. Pass clips on video tracks.`);
3531
+ }
3532
+ };
3533
+ const applyGradePreset = {
3534
+ name: "apply_video_grade_preset",
3535
+ title: "Apply a colour look",
3536
+ description: "Gives clips a named colour look at a strength (one undo step), replacing their grades: warm, cool, punchy (more contrast and colour), faded (lifted and muted), black-and-white, bleach-bypass (desaturated, contrasty), day-for-night (dark and blue). Returns each clip's resulting grade; tweak it with update_video_clip set.grade.",
3537
+ inputSchema: object(
3538
+ {
3539
+ clips: { type: "array", items: CLIP_ID, minItems: 1, description: "Clips on video tracks to grade." },
3540
+ preset: { type: "string", enum: [...VIDEO_GRADE_PRESET_NAMES], description: "The look." },
3541
+ amount: { type: "number", minimum: 0, maximum: 1, description: "Strength, 0 (none) to 1 (full, the default); 0.5 is a subtler version." }
3542
+ },
3543
+ ["clips", "preset"]
3544
+ ),
3545
+ annotations: { title: "Apply a colour look", ...WRITE_IDEMPOTENT },
3546
+ handler: ({ clips, preset, amount }) => {
3547
+ gradeTargets(clips);
3548
+ const grade = videoGradePreset(preset, amount ?? 1);
3549
+ const edits = clips.map((clip) => grade ? { op: "update_clip", clip, set: { grade } } : { op: "update_clip", clip, remove: ["grade"] });
3550
+ return apply(edits, `Apply ${preset} look`, (r) => ({ clips: clips.map((id) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));
3551
+ }
3552
+ };
3553
+ const copyGrade = {
3554
+ name: "copy_video_grade",
3555
+ title: "Copy a colour grade",
3556
+ description: "Gives clips the same colour grade as another clip (one undo step), to match shots in a scene; a source with no grade clears theirs. Returns the grade copied and the clips changed.",
3557
+ inputSchema: object({ from: { ...CLIP_ID, description: "The clip whose grade to copy." }, to: { type: "array", items: CLIP_ID, minItems: 1, description: "Clips on video tracks to give it to." } }, ["from", "to"]),
3558
+ annotations: { title: "Copy a colour grade", ...WRITE_IDEMPOTENT },
3559
+ handler: ({ from, to }) => {
3560
+ gradeTargets([from, ...to]);
3561
+ const grade = findVideoClip(project(), from).clip.grade;
3562
+ const edits = to.map((clip) => grade ? { op: "update_clip", clip, set: { grade: { ...grade } } } : { op: "update_clip", clip, remove: ["grade"] });
3563
+ return apply(edits, "Copy grade", () => ({ grade: grade ?? null, clips: to }));
3564
+ }
3565
+ };
3566
+ 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)." };
3567
+ const RANGE_OUT = (r) => ({ from: round(r.from), to: round(r.to), text: r.text });
3568
+ const timelineWords = (track) => {
3569
+ const words = videoTimelineTranscript(project(), { track });
3570
+ 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.");
3571
+ return words;
3572
+ };
3573
+ const setTranscript = {
3574
+ name: "set_video_transcript",
3575
+ title: "Attach a transcript",
3576
+ 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.',
3577
+ inputSchema: object(
3578
+ {
3579
+ asset: { ...ASSET_ID, description: "A video or audio asset." },
3580
+ text: { type: "string", minLength: 1, description: "The transcript file's contents: Whisper-style JSON (times in seconds), .srt or .vtt. Pass this or words." },
3581
+ 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." }
3582
+ },
3583
+ ["asset"]
3584
+ ),
3585
+ annotations: { title: "Attach a transcript", ...WRITE_IDEMPOTENT },
3586
+ handler: ({ asset, text, words }) => {
3587
+ if (text === void 0 === (words === void 0)) throw new Error(`Pass either "text" (a transcript file's contents) or "words", not both.`);
3588
+ const a = project().assets.find((x) => x.id === asset);
3589
+ if (!a) throw new Error(`There is no asset "${asset}"; the assets are ${project().assets.map((x) => `"${x.id}"`).join(", ") || "none"}.`);
3590
+ if (a.kind === "image") throw new Error(`Asset "${asset}" is an image and has no sound to transcribe.`);
3591
+ const parsed = text !== void 0 ? parseVideoTranscript(text) : { words, approximate: false };
3592
+ const list = parsed.words;
3593
+ return apply({ op: "update_asset", asset, set: { transcript: list } }, "Attach transcript", () => ({
3594
+ asset,
3595
+ words: list.length,
3596
+ approximate: parsed.approximate,
3597
+ first: list[0],
3598
+ last: list[list.length - 1],
3599
+ ...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?` } : {}
3600
+ }));
3601
+ }
3602
+ };
3603
+ const getTranscript = {
3604
+ name: "get_video_transcript",
3605
+ title: "Read the transcript",
3606
+ 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.",
3607
+ inputSchema: object({
3608
+ track: TRANSCRIPT_TRACK,
3609
+ from: MS("Only words from this timeline time (default 0)"),
3610
+ to: MS("Only words up to this timeline time (default the end)"),
3611
+ limit: { type: "integer", minimum: 1, maximum: 5e3, description: 'Most words to return (default 1000); "more" says if there are others.' }
3612
+ }),
3613
+ annotations: { title: "Read the transcript", ...READ },
3614
+ handler: ({ track, from, to, limit }) => {
3615
+ const all = timelineWords(track).filter((w) => w.end > (from ?? 0) && w.start < (to ?? Infinity));
3616
+ const words = all.slice(0, limit ?? 1e3);
3617
+ 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 };
3618
+ }
3619
+ };
3620
+ const findText = {
3621
+ name: "find_video_transcript_text",
3622
+ title: "Find words in the transcript",
3623
+ 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.",
3624
+ inputSchema: object({ query: { type: "string", minLength: 1, description: "The word or phrase." }, track: TRANSCRIPT_TRACK }, ["query"]),
3625
+ annotations: { title: "Find words in the transcript", ...READ },
3626
+ handler: ({ query, track }) => ({ matches: findVideoTranscriptText(timelineWords(track), query).map(RANGE_OUT) })
3627
+ };
3628
+ const findFillers = {
3629
+ name: "find_video_fillers",
3630
+ title: "Find filler words",
3631
+ 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.`,
3632
+ 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." } }),
3633
+ annotations: { title: "Find filler words", ...READ },
3634
+ handler: ({ track, fillers }) => {
3635
+ const found = findVideoFillers(timelineWords(track), fillers);
3636
+ return { fillers: found.map(RANGE_OUT), total: round(found.reduce((n2, r) => n2 + r.to - r.from, 0)) };
3637
+ }
3638
+ };
3639
+ const findPauses = {
3640
+ name: "find_video_pauses",
3641
+ title: "Find pauses between words",
3642
+ 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.',
3643
+ inputSchema: object({
3644
+ track: TRANSCRIPT_TRACK,
3645
+ min: { type: "number", exclusiveMinimum: 0, description: "Shortest gap that counts, in ms (default 700)." },
3646
+ keep: { type: "number", minimum: 0, description: "Silence to keep after the word before and before the word after, in ms (default 150)." }
3647
+ }),
3648
+ annotations: { title: "Find pauses between words", ...READ },
3649
+ handler: ({ track, min, keep }) => {
3650
+ const found = findVideoTranscriptPauses(timelineWords(track), { min, keep, fps: project().fps });
3651
+ return { pauses: found.map(RANGE_OUT), total: round(found.reduce((n2, r) => n2 + r.to - r.from, 0)) };
3652
+ }
3653
+ };
3654
+ const removeRanges = {
3655
+ name: "remove_video_ranges",
3656
+ title: "Cut out stretches",
3657
+ 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.",
3658
+ inputSchema: object(
3659
+ {
3660
+ 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)." },
3661
+ tracks: { type: "array", items: TRACK_ID, minItems: 1, description: "Tracks to cut (default every unlocked track)." }
3662
+ },
3663
+ ["ranges"]
3664
+ ),
3665
+ annotations: { title: "Cut out stretches", ...DESTRUCTIVE },
3666
+ handler: ({ ranges, tracks }) => {
3667
+ const bad = ranges.findIndex((r) => !(r.to > r.from));
3668
+ 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".`);
3669
+ const merged = mergeVideoRanges(ranges);
3670
+ const before = videoProjectDuration(project());
3671
+ 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
+ }
3673
+ };
2979
3674
  const setTransition = {
2980
3675
  name: "set_video_transition",
2981
3676
  title: "Set a transition",
2982
- description: "Sets (or with null removes) the transition into a clip from the clip that ends where it starts on the same track (one undo step). A crossfade or wipe plays half before and half after the cut, so both clips need that much media past the cut; when they do not, the error says the longest that fits. Returns the clip.",
3677
+ description: "Sets (or with null removes) the transition into a clip from the clip that ends where it starts on the same track (one undo step). Every type but dip plays half before and half after the cut, so both clips need that much media past the cut; when they do not, the error says the longest that fits. Returns the clip.",
2983
3678
  inputSchema: object({ clip: { ...CLIP_ID, description: "The incoming clip (the one after the cut)." }, transition: { anyOf: [TRANSITION, { type: "null" }], description: "The transition, or null to remove it." } }, ["clip", "transition"]),
2984
3679
  annotations: { title: "Set a transition", ...WRITE_IDEMPOTENT },
2985
3680
  handler: ({ clip, transition }) => apply({ op: "set_transition", clip, transition }, transition ? "Set transition" : "Remove transition", (r) => ({ clip: clipOut(r.project, clip) }))
@@ -3193,8 +3888,8 @@ function createVideoEditorToolProvider(controller, options = {}) {
3193
3888
  return { asset: r.asset.id, from: r.from, to: r.to, silences, total: round(silences.reduce((n2, x) => n2 + x.end - x.start, 0)) };
3194
3889
  }
3195
3890
  };
3196
- const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
3197
- const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
3891
+ const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
3892
+ const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setTranscript, removeRanges, applyGradePreset, copyGrade, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
3198
3893
  return {
3199
3894
  id: options.id ?? "video",
3200
3895
  namespace: options.namespace,
@@ -3230,6 +3925,39 @@ function videoClipShowsWaveform(clip, asset, mode = "audio") {
3230
3925
  if (clip.type !== "media" || mode === "none" || !videoAssetHasSound(asset)) return false;
3231
3926
  return mode === "all" || asset.kind === "audio";
3232
3927
  }
3928
+ var VIDEO_CLIP_ANIMATION_LABELS = {
3929
+ fade: "Fade",
3930
+ rise: "Rise",
3931
+ drop: "Drop",
3932
+ "slide-left": "Slide left",
3933
+ "slide-right": "Slide right",
3934
+ zoom: "Zoom",
3935
+ pop: "Pop"
3936
+ };
3937
+ var VIDEO_TRANSITION_LABELS = {
3938
+ crossfade: "Crossfade",
3939
+ dip: "Dip to colour",
3940
+ wipe: "Wipe",
3941
+ push: "Push",
3942
+ slide: "Slide",
3943
+ iris: "Iris"
3944
+ };
3945
+ var videoTransitionHasDirection = (type) => type === "wipe" || type === "push" || type === "slide";
3946
+ var VIDEO_BLEND_MODE_LABELS = {
3947
+ normal: "Normal",
3948
+ multiply: "Multiply",
3949
+ screen: "Screen",
3950
+ overlay: "Overlay",
3951
+ darken: "Darken",
3952
+ lighten: "Lighten",
3953
+ "color-dodge": "Colour dodge",
3954
+ "color-burn": "Colour burn",
3955
+ "hard-light": "Hard light",
3956
+ "soft-light": "Soft light",
3957
+ difference: "Difference",
3958
+ exclusion: "Exclusion",
3959
+ add: "Add"
3960
+ };
3233
3961
  var VIDEO_FADE_CURVE_LABELS = {
3234
3962
  linear: "Linear",
3235
3963
  "equal-power": "Equal power",
@@ -3400,7 +4128,13 @@ function videoKeyframeToggleEdit(project, clipId, property, time) {
3400
4128
  return { op: "set_keyframes", clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };
3401
4129
  }
3402
4130
  export {
4131
+ VIDEO_BLEND_MODES,
4132
+ VIDEO_BLEND_MODE_LABELS,
4133
+ VIDEO_CLIP_ANIMATION_DURATION,
4134
+ VIDEO_CLIP_ANIMATION_EFFECTS,
4135
+ VIDEO_CLIP_ANIMATION_LABELS,
3403
4136
  VIDEO_CLIP_TYPES,
4137
+ VIDEO_DEFAULT_FILLERS,
3404
4138
  VIDEO_DEFAULT_STILL_DURATION,
3405
4139
  VIDEO_DEFAULT_TITLE_DURATION,
3406
4140
  VIDEO_EASE_NAMES,
@@ -3408,6 +4142,9 @@ export {
3408
4142
  VIDEO_FADE_CURVES,
3409
4143
  VIDEO_FADE_CURVE_LABELS,
3410
4144
  VIDEO_FITS,
4145
+ VIDEO_GRADE_CONTROLS,
4146
+ VIDEO_GRADE_PRESETS,
4147
+ VIDEO_GRADE_PRESET_NAMES,
3411
4148
  VIDEO_ISSUE_CODES,
3412
4149
  VIDEO_KEYFRAME_LIMITS,
3413
4150
  VIDEO_KEYFRAME_PROPERTIES,
@@ -3415,6 +4152,7 @@ export {
3415
4152
  VIDEO_PLACEMENT_MODES,
3416
4153
  VIDEO_TIME_EPSILON,
3417
4154
  VIDEO_TRANSFORM_PROPERTIES,
4155
+ VIDEO_TRANSITION_LABELS,
3418
4156
  VIDEO_TRANSITION_TYPES,
3419
4157
  VIDEO_WIPE_DIRECTIONS,
3420
4158
  VideoAudioPeaksCache,
@@ -3422,6 +4160,7 @@ export {
3422
4160
  VideoPreviewEngine,
3423
4161
  allVideoClipIds,
3424
4162
  applyVideoEdits,
4163
+ applyVideoGrade,
3425
4164
  buildVideoAudioPeaks,
3426
4165
  checkVideoProject,
3427
4166
  createVideoEditorToolProvider,
@@ -3432,21 +4171,31 @@ export {
3432
4171
  ffmpegFilterValue,
3433
4172
  findVideoAudioSilences,
3434
4173
  findVideoClip,
4174
+ findVideoFillers,
3435
4175
  findVideoGaps,
4176
+ findVideoTranscriptPauses,
4177
+ findVideoTranscriptText,
3436
4178
  formatVideoSeconds,
3437
4179
  formatVideoTimecode,
3438
4180
  formatVideoTimecodeFrames,
3439
4181
  isVideoDropFrameRate,
3440
4182
  isVideoKeyframes,
4183
+ mergeVideoRanges,
3441
4184
  nextFreeVideoSlot,
4185
+ normalizeVideoGrade,
4186
+ normalizeVideoWord,
3442
4187
  parseVideoAnimatable,
3443
4188
  parseVideoAsset,
3444
4189
  parseVideoClip,
4190
+ parseVideoClipAnimation,
4191
+ parseVideoGrade,
3445
4192
  parseVideoKeyframes,
3446
4193
  parseVideoLoudnessMeasurement,
3447
4194
  parseVideoMarker,
3448
4195
  parseVideoProject,
3449
4196
  parseVideoTrack,
4197
+ parseVideoTranscript,
4198
+ parseVideoTranscriptWords,
3450
4199
  parseVideoTransform,
3451
4200
  parseVideoTransition,
3452
4201
  placeVideoRect,
@@ -3472,6 +4221,9 @@ export {
3472
4221
  videoAudioPeakSpans,
3473
4222
  videoAudioPeaksDuration,
3474
4223
  videoClipAfter,
4224
+ videoClipAnimated,
4225
+ videoClipAnimationProperties,
4226
+ videoClipAnimationSpans,
3475
4227
  videoClipBefore,
3476
4228
  videoClipEnd,
3477
4229
  videoClipHandles,
@@ -3496,7 +4248,17 @@ export {
3496
4248
  videoFrameDuration,
3497
4249
  videoFrameToSvg,
3498
4250
  videoFrames,
4251
+ videoGradeCssFilter,
4252
+ videoGradeFfmpegFilters,
4253
+ videoGradeGainsFilterId,
4254
+ videoGradePreset,
4255
+ videoGradeStepMatrix,
4256
+ videoGradeStepSvgValues,
4257
+ videoGradeSteps,
4258
+ videoGradeSvgDefs,
4259
+ videoGradeSvgFilter,
3499
4260
  videoHourFrames,
4261
+ videoIrisRadius,
3500
4262
  videoIssueKey,
3501
4263
  videoKeyframeBreakpoints,
3502
4264
  videoKeyframeFfmpegExpr,
@@ -3508,15 +4270,24 @@ export {
3508
4270
  videoPeakDb,
3509
4271
  videoProjectDuration,
3510
4272
  videoProjectSvg,
4273
+ videoRemoveRangesEdits,
3511
4274
  videoRulerTicks,
3512
4275
  videoSnapPoints,
3513
4276
  videoTextStyle,
3514
4277
  videoTimecodeFrames,
4278
+ videoTimelineTranscript,
3515
4279
  videoTitleFrame,
3516
4280
  videoTrackSyncLocked,
4281
+ videoTranscriptEdit,
4282
+ videoTranscriptTrack,
3517
4283
  videoTransformAt,
4284
+ videoTransitionAudioCurve,
4285
+ videoTransitionEase,
4286
+ videoTransitionHasDirection,
3518
4287
  videoValueAt,
3519
4288
  videoWaveformPath,
3520
- videoWipeRect
4289
+ videoWipeRect,
4290
+ videoWordAt,
4291
+ videoWordsRange
3521
4292
  };
3522
4293
  //# sourceMappingURL=index.js.map