@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 @@ require('../chunk-2FHLHZAL.cjs');
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) => _nullishCoalesce(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 = _nullishCoalesce(g.temperature, () => ( 0));
258
+ steps.push({ kind: "gains", r: 1 + 0.25 * t, g: 1 - 0.25 * (_nullishCoalesce(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,8 +431,21 @@ 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`)
436
+ });
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 };
286
447
  });
448
+ return words.sort((a, b) => a.start - b.start);
287
449
  }
288
450
  function parseVideoAsset(v, path = "asset") {
289
451
  if (!isObject(v)) return fail(path, "must be an object");
@@ -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,12 +731,72 @@ 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 ? _nullishCoalesce(clip.animateIn.duration, () => ( VIDEO_CLIP_ANIMATION_DURATION)) : 0;
753
+ const b = clip.animateOut ? _nullishCoalesce(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 : _optionalChain([clip, 'access', _4 => _4.transform, 'optionalAccess', _5 => _5[prop]]);
763
+ if (isVideoKeyframes(current)) continue;
764
+ const base = _nullishCoalesce(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 = [
569
797
  { property: "opacity", keys: videoKeysOf(clip.opacity) },
570
798
  { property: "volume", keys: clip.type === "media" ? videoKeysOf(clip.volume) : [] },
571
- ...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p, keys: videoKeysOf(_optionalChain([clip, 'access', _4 => _4.transform, 'optionalAccess', _5 => _5[p]])) }))
799
+ ...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p, keys: videoKeysOf(_optionalChain([clip, 'access', _6 => _6.transform, 'optionalAccess', _7 => _7[p]])) }))
572
800
  ];
573
801
  return out.filter((o) => o.keys.length);
574
802
  }
@@ -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 ((_nullishCoalesce(clip.fadeIn, () => ( 0))) + (_nullishCoalesce(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 (${_nullishCoalesce(clip.fadeIn, () => ( 0))} + ${_nullishCoalesce(clip.fadeOut, () => ( 0))} ms) are longer than the clip (${clip.duration} ms); shorten them.` });
850
+ const anim = (clip.animateIn ? _nullishCoalesce(clip.animateIn.duration, () => ( VIDEO_CLIP_ANIMATION_DURATION)) : 0) + (clip.animateOut ? _nullishCoalesce(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 : _optionalChain([clip, 'access', _8 => _8.transform, 'optionalAccess', _9 => _9[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)
@@ -721,7 +961,7 @@ function clipAnimatables(clip) {
721
961
  { get: () => c.opacity, set: (v) => c.opacity = v },
722
962
  { get: () => c.volume, set: (v) => c.volume = v }
723
963
  ];
724
- for (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => _optionalChain([c, 'access', _6 => _6.transform, 'optionalAccess', _7 => _7[p]]), set: (v) => c.transform && (c.transform[p] = v) });
964
+ for (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => _optionalChain([c, 'access', _10 => _10.transform, 'optionalAccess', _11 => _11[p]]), set: (v) => c.transform && (c.transform[p] = v) });
725
965
  return out;
726
966
  }
727
967
  function retimeClipKeys(clip, fn) {
@@ -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 = class {
736
976
  constructor(p) {;_class.prototype.__init.call(this);
@@ -769,7 +1009,7 @@ var Editor = (_class = class {
769
1009
  kindFits(track, clip) {
770
1010
  const asset = clip.type === "media" ? this.asset(clip.asset) : null;
771
1011
  if (track.kind === "audio" && (clip.type !== "media" || asset.kind === "image")) refuse(`Track "${track.id}" is an audio track; ${clip.type === "media" ? "images" : `${clip.type} clips`} go on a video track.`);
772
- if (track.kind === "video" && _optionalChain([asset, 'optionalAccess', _8 => _8.kind]) === "audio") refuse(`Track "${track.id}" is a video track; audio asset "${asset.id}" goes on an audio track.`);
1012
+ if (track.kind === "video" && _optionalChain([asset, 'optionalAccess', _12 => _12.kind]) === "audio") refuse(`Track "${track.id}" is a video track; audio asset "${asset.id}" goes on an audio track.`);
773
1013
  }
774
1014
  /** Moves every clip on `track` starting at or after `from` by `delta`. */
775
1015
  shift(track, from, delta, except = /* @__PURE__ */ new Set()) {
@@ -811,7 +1051,7 @@ var Editor = (_class = class {
811
1051
  }
812
1052
  /** Moves a media clip's in point by `delta` of timeline time (stills, titles and colours have none). */
813
1053
  moveIn(clip, delta) {
814
- if (clip.type !== "media" || _optionalChain([videoAssetMap, 'call', _9 => _9(this.p), 'access', _10 => _10.get, 'call', _11 => _11(clip.asset), 'optionalAccess', _12 => _12.kind]) === "image") return;
1054
+ if (clip.type !== "media" || _optionalChain([videoAssetMap, 'call', _13 => _13(this.p), 'access', _14 => _14.get, 'call', _15 => _15(clip.asset), 'optionalAccess', _16 => _16.kind]) === "image") return;
815
1055
  clip.in = Math.max(0, (_nullishCoalesce(clip.in, () => ( 0))) + delta * videoClipSpeed(clip));
816
1056
  if (clip.in < 1e-9) delete clip.in;
817
1057
  }
@@ -827,6 +1067,7 @@ var Editor = (_class = 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 = 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);
@@ -1075,7 +1317,7 @@ var Editor = (_class = class {
1075
1317
  if (clip.type === "media") {
1076
1318
  const asset = assets.get(clip.asset);
1077
1319
  const out = (_nullishCoalesce(clip.in, () => ( 0))) + duration * speed;
1078
- if (_optionalChain([asset, 'optionalAccess', _13 => _13.duration]) !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
1320
+ if (_optionalChain([asset, 'optionalAccess', _17 => _17.duration]) !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
1079
1321
  refuse(`"${clip.id}" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (_nullishCoalesce(clip.in, () => ( 0)))) / speed)}, where "${asset.id}" runs out.`);
1080
1322
  }
1081
1323
  if (e.ripple) {
@@ -1131,7 +1373,7 @@ var Editor = (_class = class {
1131
1373
  let stretch = 1;
1132
1374
  if (at.clip.type === "media" && !("duration" in set)) {
1133
1375
  const was = videoClipSpeed(at.clip);
1134
- const now = "speed" in set ? set.speed : _optionalChain([e, 'access', _14 => _14.remove, 'optionalAccess', _15 => _15.includes, 'call', _16 => _16("speed")]) ? 1 : was;
1376
+ const now = "speed" in set ? set.speed : _optionalChain([e, 'access', _18 => _18.remove, 'optionalAccess', _19 => _19.includes, 'call', _20 => _20("speed")]) ? 1 : was;
1135
1377
  if (now !== was) {
1136
1378
  set.duration = at.clip.duration * was / now;
1137
1379
  stretch = was / now;
@@ -1227,7 +1469,7 @@ var Editor = (_class = class {
1227
1469
  this.trimHead(after, delta);
1228
1470
  }
1229
1471
  track.clips.sort((a, b) => a.start - b.start);
1230
- return this.outcomes.push({ op: e.op, clip: clip.id, clips: [_optionalChain([before, 'optionalAccess', _17 => _17.id]), clip.id, _optionalChain([after, 'optionalAccess', _18 => _18.id])].filter((x) => !!x), track: track.id, delta });
1472
+ return this.outcomes.push({ op: e.op, clip: clip.id, clips: [_optionalChain([before, 'optionalAccess', _21 => _21.id]), clip.id, _optionalChain([after, 'optionalAccess', _22 => _22.id])].filter((x) => !!x), track: track.id, delta });
1231
1473
  }
1232
1474
  case "lift_range":
1233
1475
  case "extract_range": {
@@ -1258,7 +1500,7 @@ var Editor = (_class = class {
1258
1500
  const limits = VIDEO_KEYFRAME_LIMITS[e.property];
1259
1501
  const transform = VIDEO_TRANSFORM_PROPERTIES.includes(e.property);
1260
1502
  const c = clip;
1261
- const current = transform ? _optionalChain([c, 'access', _19 => _19.transform, 'optionalAccess', _20 => _20[e.property]]) : c[e.property];
1503
+ const current = transform ? _optionalChain([c, 'access', _23 => _23.transform, 'optionalAccess', _24 => _24[e.property]]) : c[e.property];
1262
1504
  let value;
1263
1505
  if (e.keys === null) {
1264
1506
  const v = videoValueAt(current, 0, limits.default);
@@ -1340,7 +1582,7 @@ function applyVideoEdits(project, edits) {
1340
1582
  editor.run(edit);
1341
1583
  } catch (error) {
1342
1584
  const message = error.message;
1343
- return { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess', _21 => _21.op])}): ` : ""}${message}${/Nothing was changed\.$/.test(message) ? "" : " Nothing was changed."}` };
1585
+ return { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess', _25 => _25.op])}): ` : ""}${message}${/Nothing was changed\.$/.test(message) ? "" : " Nothing was changed."}` };
1344
1586
  }
1345
1587
  }
1346
1588
  let next;
@@ -1359,7 +1601,7 @@ function applyVideoEdits(project, edits) {
1359
1601
 
1360
1602
  // src/video-editor/evaluate.ts
1361
1603
  function videoTransformAt(transform, clipTime = 0) {
1362
- return { x: videoValueAt(_optionalChain([transform, 'optionalAccess', _22 => _22.x]), clipTime, 0), y: videoValueAt(_optionalChain([transform, 'optionalAccess', _23 => _23.y]), clipTime, 0), scale: videoValueAt(_optionalChain([transform, 'optionalAccess', _24 => _24.scale]), clipTime, 1), rotation: videoValueAt(_optionalChain([transform, 'optionalAccess', _25 => _25.rotation]), clipTime, 0) };
1604
+ return { x: videoValueAt(_optionalChain([transform, 'optionalAccess', _26 => _26.x]), clipTime, 0), y: videoValueAt(_optionalChain([transform, 'optionalAccess', _27 => _27.y]), clipTime, 0), scale: videoValueAt(_optionalChain([transform, 'optionalAccess', _28 => _28.scale]), clipTime, 1), rotation: videoValueAt(_optionalChain([transform, 'optionalAccess', _29 => _29.rotation]), clipTime, 0) };
1363
1605
  }
1364
1606
  function placeVideoRect(frame, size, fit = "contain", transform = {}, clipTime = 0) {
1365
1607
  let w = size.width;
@@ -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) {
@@ -1405,8 +1648,8 @@ function videoFadeCurveGain(curve, x) {
1405
1648
  function fadeGain(clip, time, sound = false) {
1406
1649
  let g = 1;
1407
1650
  const shape = sound && clip.type === "media" ? clip : void 0;
1408
- if (clip.fadeIn) g *= videoFadeCurveGain(_optionalChain([shape, 'optionalAccess', _26 => _26.fadeInCurve]), (time - clip.start) / clip.fadeIn);
1409
- if (clip.fadeOut) g *= videoFadeCurveGain(_optionalChain([shape, 'optionalAccess', _27 => _27.fadeOutCurve]), (videoClipEnd(clip) - time) / clip.fadeOut);
1651
+ if (clip.fadeIn) g *= videoFadeCurveGain(_optionalChain([shape, 'optionalAccess', _30 => _30.fadeInCurve]), (time - clip.start) / clip.fadeIn);
1652
+ if (clip.fadeOut) g *= videoFadeCurveGain(_optionalChain([shape, 'optionalAccess', _31 => _31.fadeOutCurve]), (videoClipEnd(clip) - time) / clip.fadeOut);
1410
1653
  return g;
1411
1654
  }
1412
1655
  function videoClipSoundGain(clip, clipTime) {
@@ -1419,7 +1662,7 @@ function activeOnTrack(track, time) {
1419
1662
  const before = clip.transition ? videoClipBefore(track, clip) : null;
1420
1663
  const lead = before ? clip.transition.duration / 2 : 0;
1421
1664
  const next = videoClipAfter(track, clip);
1422
- const tail = _optionalChain([next, 'optionalAccess', _28 => _28.transition]) ? next.transition.duration / 2 : 0;
1665
+ const tail = _optionalChain([next, 'optionalAccess', _32 => _32.transition]) ? next.transition.duration / 2 : 0;
1423
1666
  if (time < clip.start - lead || time >= videoClipEnd(clip) + tail) continue;
1424
1667
  const a = { clip };
1425
1668
  if (lead && time < clip.start + lead) a.incoming = { progress: clamp01((time - (clip.start - lead)) / (2 * lead)), type: clip.transition.type, transition: clip.transition };
@@ -1436,12 +1679,12 @@ function videoFrameAt(project, time) {
1436
1679
  for (let ti = project.tracks.length - 1; ti >= 0; ti--) {
1437
1680
  const track = project.tracks[ti];
1438
1681
  for (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {
1439
- if (_optionalChain([incoming, 'optionalAccess', _29 => _29.type]) === "dip" && incoming.progress < 0.5) continue;
1440
- if (_optionalChain([outgoing, 'optionalAccess', _30 => _30.type]) === "dip" && outgoing.progress >= 0.5) continue;
1682
+ if (_optionalChain([incoming, 'optionalAccess', _33 => _33.type]) === "dip" && incoming.progress < 0.5) continue;
1683
+ if (_optionalChain([outgoing, 'optionalAccess', _34 => _34.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: _nullishCoalesce(incoming.transition.direction, () => ( "right")), progress: incoming.progress };
1461
- else dip = { color: _nullishCoalesce(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 = _nullishCoalesce(incoming.transition.direction, () => ( "right"));
1711
+ shift = videoTransitionEase(incoming.progress) - 1;
1712
+ } else dip = { color: _nullishCoalesce(incoming.transition.color, () => ( "#000000")), opacity: (1 - incoming.progress) * 2 };
1713
+ }
1714
+ if (_optionalChain([outgoing, 'optionalAccess', _35 => _35.type]) === "dip") dip = { color: _nullishCoalesce(outgoing.next.transition.color, () => ( "#000000")), opacity: outgoing.progress * 2 };
1715
+ if (_optionalChain([outgoing, 'optionalAccess', _36 => _36.type]) === "push") {
1716
+ shiftDirection = _nullishCoalesce(outgoing.next.transition.direction, () => ( "right"));
1717
+ shift = videoTransitionEase(outgoing.progress);
1462
1718
  }
1463
- if (_optionalChain([outgoing, 'optionalAccess', _31 => _31.type]) === "dip") dip = { color: _nullishCoalesce(outgoing.next.transition.color, () => ( "#000000")), opacity: outgoing.progress * 2 };
1464
1719
  const size = clip.type === "media" ? { width: _nullishCoalesce(asset.width, () => ( project.width)), height: _nullishCoalesce(asset.height, () => ( project.height)) } : { width: project.width, height: project.height };
1465
1720
  const fit = _nullishCoalesce(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(_optionalChain([clip, 'access', _32 => _32.transform, 'optionalAccess', _33 => _33.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(_optionalChain([shown, 'access', _37 => _37.transform, 'optionalAccess', _38 => _38.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 = (_class2 = class {
1693
1955
  }
1694
1956
  }, _class2);
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(_nullishCoalesce(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 (e3) {
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(_nullishCoalesce(_nullishCoalesce(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(_nullishCoalesce(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" && _optionalChain([assets, 'access', _39 => _39.get, 'call', _40 => _40(c.asset), 'optionalAccess', _41 => _41.transcript, 'optionalAccess', _42 => _42.length]));
2024
+ return _nullishCoalesce(_optionalChain([(_nullishCoalesce(project.tracks.find((t) => t.kind === "audio" && transcribed(t)), () => ( project.tracks.find(transcribed)))), 'optionalAccess', _43 => _43.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 = _optionalChain([assets, 'access', _44 => _44.get, 'call', _45 => _45(clip.asset), 'optionalAccess', _46 => _46.transcript]);
2035
+ if (!_optionalChain([words, 'optionalAccess', _47 => _47.length])) continue;
2036
+ const speed = videoClipSpeed(clip);
2037
+ const from = _nullishCoalesce(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 = _nullishCoalesce(options.min, () => ( 700));
2074
+ const keep = _nullishCoalesce(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);
@@ -1826,7 +2276,7 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
1826
2276
  }
1827
2277
 
1828
2278
  // src/video-editor/export.ts
1829
- var reelName = (asset) => (_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _34 => _34.name]), () => ( _optionalChain([asset, 'optionalAccess', _35 => _35.id]))), () => ( "AX"))).replace(/\.[a-z0-9]+$/i, "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 8) || "AX";
2279
+ var reelName = (asset) => (_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _48 => _48.name]), () => ( _optionalChain([asset, 'optionalAccess', _49 => _49.id]))), () => ( "AX"))).replace(/\.[a-z0-9]+$/i, "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 8) || "AX";
1830
2280
  function toVideoEdl(project, options = {}) {
1831
2281
  const track = options.track !== void 0 ? project.tracks.find((t) => t.id === options.track) : [...project.tracks].reverse().find((t) => t.kind === "video");
1832
2282
  if (!track) throw new Error(options.track !== void 0 ? `There is no track "${options.track}".` : "The project has no video track.");
@@ -1858,10 +2308,10 @@ 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
- if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _36 => _36.name]), () => ( _optionalChain([asset, 'optionalAccess', _37 => _37.src]))), () => ( clip.asset))}`);
2314
+ if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _50 => _50.name]), () => ( _optionalChain([asset, 'optionalAccess', _51 => _51.src]))), () => ( clip.asset))}`);
1865
2315
  if (clip.type === "text") lines.push(`* TITLE: ${clip.text.replace(/\n/g, " ")}`);
1866
2316
  if (clip.type === "color") lines.push(`* COLOR: ${clip.color}`);
1867
2317
  lines.push("");
@@ -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=${(_nullishCoalesce(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 && _optionalChain([c, 'access', _38 => _38.transition, 'optionalAccess', _39 => _39.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 (_optionalChain([next2, 'optionalAccess', _52 => _52.transition, 'access', _53 => _53.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`);
@@ -1990,7 +2458,7 @@ function buildFfmpeg(project, options, measure) {
1990
2458
  const onTimeline = `(t*1000-${Math.round(clip.start)})`;
1991
2459
  const onChain = (v) => `(${v}*1000${lead ? `-${Math.round(lead)}` : ""})`;
1992
2460
  const tf = clip.transform;
1993
- const moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _40 => _40[p]])));
2461
+ const moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _54 => _54[p]])));
1994
2462
  if (clip.type === "text" && options.titleImage) {
1995
2463
  inputs.push(["-loop", "1", "-framerate", String(fps), "-t", sec(span), "-i", options.titleImage(moving ? { ...clip, transform: void 0 } : clip, { width: project.width, height: project.height })]);
1996
2464
  index = inputs.length - 1;
@@ -1999,7 +2467,7 @@ function buildFfmpeg(project, options, measure) {
1999
2467
  const op = isVideoKeyframes(clip.opacity) ? `clip(${expr(clip.opacity, onTimeline, 1, "opacity")},0,1)` : String(_nullishCoalesce(clip.opacity, () => ( 1)));
2000
2468
  const alpha = [op, clip.fadeIn ? `min(1,(t-${sec(clip.start)})/${sec(clip.fadeIn)})` : "", clip.fadeOut ? `min(1,(${sec(end)}-t)/${sec(clip.fadeOut)})` : ""].filter(Boolean).join("*");
2001
2469
  const coord = (axis) => {
2002
- const v = _optionalChain([tf, 'optionalAccess', _41 => _41[axis]]);
2470
+ const v = _optionalChain([tf, 'optionalAccess', _55 => _55[axis]]);
2003
2471
  const base = `(${axis === "x" ? "w-text_w" : "h-text_h"})/2`;
2004
2472
  return isVideoKeyframes(v) ? `${axis}='${base}+(${expr(v, onTimeline, 0, "position")})'` : `${axis}=${base}+${_nullishCoalesce(v, () => ( 0))}`;
2005
2473
  };
@@ -2007,7 +2475,7 @@ function buildFfmpeg(project, options, measure) {
2007
2475
  const opts = [
2008
2476
  `text=${ffmpegFilterValue(clip.text)}`,
2009
2477
  options.fontFile ? `fontfile=${ffmpegFilterValue(options.fontFile)}` : `font=${ffmpegFilterValue(_nullishCoalesce(clip.fontFamily, () => ( "sans-serif")))}`,
2010
- isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _42 => _42.scale])) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, "scale")})'` : `fontsize=${Math.round(fontSize * (_nullishCoalesce(_optionalChain([tf, 'optionalAccess', _43 => _43.scale]), () => ( 1))))}`,
2478
+ isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _56 => _56.scale])) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, "scale")})'` : `fontsize=${Math.round(fontSize * (_nullishCoalesce(_optionalChain([tf, 'optionalAccess', _57 => _57.scale]), () => ( 1))))}`,
2011
2479
  `fontcolor=${color(_nullishCoalesce(clip.color, () => ( "#ffffff")))}`,
2012
2480
  coord("x"),
2013
2481
  coord("y"),
@@ -2015,7 +2483,9 @@ function buildFfmpeg(project, options, measure) {
2015
2483
  ...clip.background ? ["box=1", `boxcolor=${color(clip.background)}`, `boxborderw=${Math.round((_nullishCoalesce(clip.fontSize, () => ( 64))) / 2)}`] : [],
2016
2484
  `enable='between(t,${sec(clip.start)},${sec(end)})'`
2017
2485
  ];
2018
- if (_optionalChain([tf, 'optionalAccess', _44 => _44.rotation])) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
2486
+ if (_optionalChain([tf, 'optionalAccess', _58 => _58.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(_optionalChain([placed, 'optionalAccess', _45 => _45.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
  }
2051
- const rotation = _optionalChain([placed, 'optionalAccess', _46 => _46.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)`);
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
+ }
2523
+ const rotation = _optionalChain([placed, 'optionalAccess', _59 => _59.rotation]);
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(_optionalChain([placed, 'optionalAccess', _60 => _60.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 = _nullishCoalesce(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 (_optionalChain([after, 'optionalAccess', _61 => _61.transition, 'access', _62 => _62.type]) === "push") shiftBy(after.transition.direction, end - tail, 2 * tail, false);
2057
2546
  const at = (axis, c) => {
2058
- const v = _optionalChain([placed, 'optionalAccess', _47 => _47[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'`;
2547
+ const v = _optionalChain([placed, 'optionalAccess', _63 => _63[axis]]);
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") {
@@ -2078,10 +2578,11 @@ function buildFfmpeg(project, options, measure) {
2078
2578
  if (options.loudness && !audio.length) notes.push("Nothing makes a sound, so there was no loudness to normalise.");
2079
2579
  else if (options.loudness && !options.loudness.measured)
2080
2580
  notes.push("Loudness is normalised in one pass, which adjusts the level as it goes; for a fixed gain that keeps the dynamics, run toVideoLoudnessMeasure first and pass its result as loudness.measured.");
2081
- if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && _optionalChain([assets, 'access', _48 => _48.get, 'call', _49 => _49(c.asset), 'optionalAccess', _50 => _50.kind]) === "video" && _optionalChain([assets, 'access', _51 => _51.get, 'call', _52 => _52(c.asset), 'optionalAccess', _53 => _53.hasAudio]) === void 0)))
2581
+ if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && _optionalChain([assets, 'access', _64 => _64.get, 'call', _65 => _65(c.asset), 'optionalAccess', _66 => _66.kind]) === "video" && _optionalChain([assets, 'access', _67 => _67.get, 'call', _68 => _68(c.asset), 'optionalAccess', _69 => _69.hasAudio]) === void 0)))
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) {
@@ -2112,11 +2613,11 @@ function buildFfmpeg(project, options, measure) {
2112
2613
 
2113
2614
  // src/video-editor/preview.ts
2114
2615
  var VideoPreviewEngine = (_class3 = class {
2115
- constructor(source, canvas, options = {}) {;_class3.prototype.__init6.call(this);_class3.prototype.__init7.call(this);_class3.prototype.__init8.call(this);_class3.prototype.__init9.call(this);_class3.prototype.__init10.call(this);
2616
+ constructor(source, canvas, options = {}) {;_class3.prototype.__init6.call(this);_class3.prototype.__init7.call(this);_class3.prototype.__init8.call(this);_class3.prototype.__init9.call(this);_class3.prototype.__init10.call(this);_class3.prototype.__init11.call(this);_class3.prototype.__init12.call(this);_class3.prototype.__init13.call(this);
2116
2617
  this.source = source;
2117
2618
  this.options = options;
2118
2619
  this.canvas = canvas;
2119
- this.ctx = options.context !== void 0 ? options.context : _nullishCoalesce(_optionalChain([canvas, 'access', _54 => _54.getContext, 'optionalCall', _55 => _55("2d")]), () => ( null));
2620
+ this.ctx = options.context !== void 0 ? options.context : _nullishCoalesce(_optionalChain([canvas, 'access', _70 => _70.getContext, 'optionalCall', _71 => _71("2d")]), () => ( null));
2120
2621
  this.unsubscribe = source.subscribe(() => this.update());
2121
2622
  this.update();
2122
2623
  }
@@ -2129,6 +2630,8 @@ var VideoPreviewEngine = (_class3 = class {
2129
2630
 
2130
2631
  __init8() {this.scheduled = false}
2131
2632
  __init9() {this.destroyed = false}
2633
+ __init10() {this.filterHost = null}
2634
+ __init11() {this.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);
@@ -2164,8 +2667,8 @@ var VideoPreviewEngine = (_class3 = class {
2164
2667
  entry.media.pause();
2165
2668
  entry.media.removeEventListener("seeked", entry.onReady);
2166
2669
  entry.media.removeEventListener("loadeddata", entry.onReady);
2167
- _optionalChain([entry, 'access', _56 => _56.media, 'access', _57 => _57.removeAttribute, 'optionalCall', _58 => _58("src")]);
2168
- _optionalChain([entry, 'access', _59 => _59.media, 'access', _60 => _60.load, 'optionalCall', _61 => _61()]);
2670
+ _optionalChain([entry, 'access', _72 => _72.media, 'access', _73 => _73.removeAttribute, 'optionalCall', _74 => _74("src")]);
2671
+ _optionalChain([entry, 'access', _75 => _75.media, 'access', _76 => _76.load, 'optionalCall', _77 => _77()]);
2169
2672
  this.media.delete(entry.clip);
2170
2673
  }
2171
2674
  /** Brings the media elements in step with the editor and draws the frame. */
@@ -2198,7 +2701,7 @@ var VideoPreviewEngine = (_class3 = class {
2198
2701
  for (const layer of frame.layers) {
2199
2702
  if (layer.type !== "media" || layer.assetKind !== "video" || !layer.src) continue;
2200
2703
  const sound = sounding.get(layer.clip);
2201
- sync(layer.clip, layer.src, "video", _nullishCoalesce(layer.sourceTime, () => ( 0)), _nullishCoalesce(_optionalChain([sound, 'optionalAccess', _62 => _62.speed]), () => ( 1)), sound ? sound.gain : null);
2704
+ sync(layer.clip, layer.src, "video", _nullishCoalesce(layer.sourceTime, () => ( 0)), _nullishCoalesce(_optionalChain([sound, 'optionalAccess', _78 => _78.speed]), () => ( 1)), sound ? sound.gain : null);
2202
2705
  sounding.delete(layer.clip);
2203
2706
  }
2204
2707
  for (const a of sounding.values()) sync(a.clip, a.src, "audio", a.sourceTime, a.speed, a.gain);
@@ -2213,7 +2716,7 @@ var VideoPreviewEngine = (_class3 = class {
2213
2716
  if (!this.destroyed) this.draw(videoFrameAt(this.source.getState().project, this.source.getState().time));
2214
2717
  });
2215
2718
  }
2216
- __init10() {this.sourceFor = (layer) => {
2719
+ __init12() {this.sourceFor = (layer) => {
2217
2720
  if (layer.assetKind === "image" && layer.src) {
2218
2721
  const img = this.image(layer.src);
2219
2722
  return img.complete ? img : null;
@@ -2230,12 +2733,31 @@ var VideoPreviewEngine = (_class3 = 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);
2234
- _optionalChain([this, 'access', _63 => _63.options, 'access', _64 => _64.onFrame, 'optionalCall', _65 => _65(frame)]);
2736
+ drawVideoFrame(this.ctx, frame, this.sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
2737
+ _optionalChain([this, 'access', _79 => _79.options, 'access', _80 => _80.onFrame, 'optionalCall', _81 => _81(frame)]);
2235
2738
  }
2739
+ __init13() {this.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
+ _optionalChain([this, 'access', _82 => _82.filterHost, 'optionalAccess', _83 => _83.remove, 'call', _84 => _84()]);
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();
@@ -2250,16 +2772,16 @@ var VideoEditorController = (_class4 = class {
2250
2772
 
2251
2773
 
2252
2774
 
2253
- __init11() {this.selection = []}
2775
+ __init14() {this.selection = []}
2254
2776
 
2255
- __init12() {this.error = null}
2256
- __init13() {this.revision = 0}
2257
- __init14() {this.applying = false}
2777
+ __init15() {this.error = null}
2778
+ __init16() {this.revision = 0}
2779
+ __init17() {this.applying = false}
2258
2780
 
2259
- __init15() {this.listeners = /* @__PURE__ */ new Set()}
2260
- __init16() {this.unsubscribers = []}
2781
+ __init18() {this.listeners = /* @__PURE__ */ new Set()}
2782
+ __init19() {this.unsubscribers = []}
2261
2783
 
2262
- constructor(options = {}) {;_class4.prototype.__init11.call(this);_class4.prototype.__init12.call(this);_class4.prototype.__init13.call(this);_class4.prototype.__init14.call(this);_class4.prototype.__init15.call(this);_class4.prototype.__init16.call(this);_class4.prototype.__init17.call(this);_class4.prototype.__init18.call(this);
2784
+ constructor(options = {}) {;_class4.prototype.__init14.call(this);_class4.prototype.__init15.call(this);_class4.prototype.__init16.call(this);_class4.prototype.__init17.call(this);_class4.prototype.__init18.call(this);_class4.prototype.__init19.call(this);_class4.prototype.__init20.call(this);_class4.prototype.__init21.call(this);
2263
2785
  this.history = _nullishCoalesce(options.history, () => ( new (0, _chunkQKLRAA4Tcjs.HistoryManager)()));
2264
2786
  this.audioPeaks = options.audioPeaks instanceof VideoAudioPeaksCache ? options.audioPeaks : new VideoAudioPeaksCache(options.audioPeaks);
2265
2787
  this.project = options.project ? parseVideoProject(options.project) : createVideoProject();
@@ -2284,8 +2806,8 @@ var VideoEditorController = (_class4 = class {
2284
2806
  this.player.pause();
2285
2807
  this.unsubscribers.splice(0).forEach((u) => u());
2286
2808
  }
2287
- __init17() {this.getState = () => this.state}
2288
- __init18() {this.subscribe = (listener) => {
2809
+ __init20() {this.getState = () => this.state}
2810
+ __init21() {this.subscribe = (listener) => {
2289
2811
  this.connect();
2290
2812
  this.listeners.add(listener);
2291
2813
  return () => {
@@ -2341,7 +2863,7 @@ var VideoEditorController = (_class4 = class {
2341
2863
  this.applying = false;
2342
2864
  }
2343
2865
  this.notify();
2344
- _optionalChain([this, 'access', _66 => _66.onChange, 'optionalCall', _67 => _67(project)]);
2866
+ _optionalChain([this, 'access', _85 => _85.onChange, 'optionalCall', _86 => _86(project)]);
2345
2867
  }
2346
2868
  commit(label, next) {
2347
2869
  const before = this.project;
@@ -2405,7 +2927,7 @@ var VideoEditorController = (_class4 = class {
2405
2927
  }
2406
2928
  /** Selects clips (unknown ids are dropped). `add` toggles them into the current selection. */
2407
2929
  select(clips, options = {}) {
2408
- const known = _nullishCoalesce(_optionalChain([clips, 'optionalAccess', _68 => _68.filter, 'call', _69 => _69((id) => findVideoClip(this.project, id))]), () => ( []));
2930
+ const known = _nullishCoalesce(_optionalChain([clips, 'optionalAccess', _87 => _87.filter, 'call', _88 => _88((id) => findVideoClip(this.project, id))]), () => ( []));
2409
2931
  let next;
2410
2932
  if (options.add) {
2411
2933
  next = [...this.selection];
@@ -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,
@@ -2677,14 +3221,14 @@ function createVideoEditorToolProvider(controller, options = {}) {
2677
3221
  return null;
2678
3222
  };
2679
3223
  const placed = (o) => ({
2680
- ..._optionalChain([o, 'access', _70 => _70.created, 'optionalAccess', _71 => _71.length]) ? { created: o.created } : {},
2681
- ..._optionalChain([o, 'access', _72 => _72.trimmed, 'optionalAccess', _73 => _73.length]) ? { trimmed: o.trimmed } : {},
2682
- ..._optionalChain([o, 'access', _74 => _74.removed, 'optionalAccess', _75 => _75.length]) ? { removed: o.removed } : {},
3224
+ ..._optionalChain([o, 'access', _89 => _89.created, 'optionalAccess', _90 => _90.length]) ? { created: o.created } : {},
3225
+ ..._optionalChain([o, 'access', _91 => _91.trimmed, 'optionalAccess', _92 => _92.length]) ? { trimmed: o.trimmed } : {},
3226
+ ..._optionalChain([o, 'access', _93 => _93.removed, 'optionalAccess', _94 => _94.length]) ? { removed: o.removed } : {},
2683
3227
  ...o.rippled ? { rippled: o.rippled } : {}
2684
3228
  });
2685
3229
  const keysOut = (p, clip, property) => {
2686
3230
  const c = clipOut(p, clip);
2687
- const value = VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([c, 'optionalAccess', _76 => _76.transform, 'optionalAccess', _77 => _77[property]]) : _optionalChain([c, 'optionalAccess', _78 => _78[property]]);
3231
+ const value = VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([c, 'optionalAccess', _95 => _95.transform, 'optionalAccess', _96 => _96[property]]) : _optionalChain([c, 'optionalAccess', _97 => _97[property]]);
2688
3232
  return typeof value === "object" ? { keys: value.keys } : { keys: [], value: _nullishCoalesce(value, () => ( null)) };
2689
3233
  };
2690
3234
  const getProject = {
@@ -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" && _optionalChain([p, 'access', _98 => _98.assets, 'access', _99 => _99.find, 'call', _100 => _100((a) => a.id === clip.asset), 'optionalAccess', _101 => _101.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, _nullishCoalesce(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: _nullishCoalesce(_optionalChain([findVideoClip, 'call', _102 => _102(r.project, id), 'optionalAccess', _103 => _103.clip, 'access', _104 => _104.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: _nullishCoalesce(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 > (_nullishCoalesce(from, () => ( 0))) && w.start < (_nullishCoalesce(to, () => ( Infinity))));
3616
+ const words = all.slice(0, _nullishCoalesce(limit, () => ( 1e3)));
3617
+ return { track: _nullishCoalesce(_optionalChain([words, 'access', _105 => _105[0], 'optionalAccess', _106 => _106.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: _nullishCoalesce(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",
@@ -3247,7 +3975,7 @@ function videoClipLabel(clip, asset) {
3247
3975
  if (clip.name) return clip.name;
3248
3976
  if (clip.type === "text") return clip.text.split("\n")[0];
3249
3977
  if (clip.type === "color") return clip.color;
3250
- const base = _nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _79 => _79.name]), () => ( _optionalChain([asset, 'optionalAccess', _80 => _80.src, 'access', _81 => _81.replace, 'call', _82 => _82(/^.*[\\/]/, "")]))), () => ( clip.asset));
3978
+ const base = _nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _107 => _107.name]), () => ( _optionalChain([asset, 'optionalAccess', _108 => _108.src, 'access', _109 => _109.replace, 'call', _110 => _110(/^.*[\\/]/, "")]))), () => ( clip.asset));
3251
3979
  const speed = videoClipSpeed(clip);
3252
3980
  return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
3253
3981
  }
@@ -3291,7 +4019,7 @@ function videoAssetDropEdit(project, assetId, options = {}) {
3291
4019
  const asset = videoAssetMap(project).get(assetId);
3292
4020
  if (!asset) return null;
3293
4021
  const kind = asset.kind === "audio" ? "audio" : "video";
3294
- const track = _nullishCoalesce(options.track, () => ( _optionalChain([project, 'access', _83 => _83.tracks, 'access', _84 => _84.find, 'call', _85 => _85((t) => t.kind === kind && !t.locked), 'optionalAccess', _86 => _86.id])));
4022
+ const track = _nullishCoalesce(options.track, () => ( _optionalChain([project, 'access', _111 => _111.tracks, 'access', _112 => _112.find, 'call', _113 => _113((t) => t.kind === kind && !t.locked), 'optionalAccess', _114 => _114.id])));
3295
4023
  if (!track) return null;
3296
4024
  return { op: "add_clip", track, clip: { type: "media", asset: asset.id, ...options.start !== void 0 ? { start: options.start } : {} }, ...options.ripple ? { ripple: true } : {}, ...options.mode && options.mode !== "refuse" ? { mode: options.mode } : {} };
3297
4025
  }
@@ -3340,14 +4068,14 @@ var KEY_LABELS = {
3340
4068
  scale: { label: "Scale", step: 0.05 },
3341
4069
  rotation: { label: "Rotation", step: 1 }
3342
4070
  };
3343
- var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([clip, 'access', _87 => _87.transform, 'optionalAccess', _88 => _88[property]]) : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
4071
+ var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([clip, 'access', _115 => _115.transform, 'optionalAccess', _116 => _116[property]]) : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
3344
4072
  var playheadInClip = (clip, time) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));
3345
4073
  function videoKeyframeRows(project, clipId, time) {
3346
4074
  const at = findVideoClip(project, clipId);
3347
4075
  if (!at) return [];
3348
4076
  const { clip } = at;
3349
4077
  const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
3350
- const visual = !(_optionalChain([asset, 'optionalAccess', _89 => _89.kind]) === "audio" || at.track.kind === "audio");
4078
+ const visual = !(_optionalChain([asset, 'optionalAccess', _117 => _117.kind]) === "audio" || at.track.kind === "audio");
3351
4079
  const audible = clip.type === "media" && videoAssetHasSound(asset);
3352
4080
  const properties = [...visual ? ["opacity"] : [], ...audible ? ["volume"] : [], ...visual ? VIDEO_TRANSFORM_PROPERTIES : []];
3353
4081
  const clipTime = playheadInClip(clip, time);
@@ -3518,5 +4246,48 @@ function videoKeyframeToggleEdit(project, clipId, property, time) {
3518
4246
 
3519
4247
 
3520
4248
 
3521
- exports.VIDEO_CLIP_TYPES = VIDEO_CLIP_TYPES; exports.VIDEO_DEFAULT_STILL_DURATION = VIDEO_DEFAULT_STILL_DURATION; exports.VIDEO_DEFAULT_TITLE_DURATION = VIDEO_DEFAULT_TITLE_DURATION; exports.VIDEO_EASE_NAMES = VIDEO_EASE_NAMES; exports.VIDEO_EDIT_OPS = VIDEO_EDIT_OPS; exports.VIDEO_FADE_CURVES = VIDEO_FADE_CURVES; exports.VIDEO_FADE_CURVE_LABELS = VIDEO_FADE_CURVE_LABELS; exports.VIDEO_FITS = VIDEO_FITS; exports.VIDEO_ISSUE_CODES = VIDEO_ISSUE_CODES; exports.VIDEO_KEYFRAME_LIMITS = VIDEO_KEYFRAME_LIMITS; exports.VIDEO_KEYFRAME_PROPERTIES = VIDEO_KEYFRAME_PROPERTIES; exports.VIDEO_PEAK_BLOCK = VIDEO_PEAK_BLOCK; exports.VIDEO_PLACEMENT_MODES = VIDEO_PLACEMENT_MODES; exports.VIDEO_TIME_EPSILON = VIDEO_TIME_EPSILON; exports.VIDEO_TRANSFORM_PROPERTIES = VIDEO_TRANSFORM_PROPERTIES; exports.VIDEO_TRANSITION_TYPES = VIDEO_TRANSITION_TYPES; exports.VIDEO_WIPE_DIRECTIONS = VIDEO_WIPE_DIRECTIONS; exports.VideoAudioPeaksCache = VideoAudioPeaksCache; exports.VideoEditorController = VideoEditorController; exports.VideoPreviewEngine = VideoPreviewEngine; exports.allVideoClipIds = allVideoClipIds; exports.applyVideoEdits = applyVideoEdits; exports.buildVideoAudioPeaks = buildVideoAudioPeaks; exports.checkVideoProject = checkVideoProject; exports.createVideoEditorToolProvider = createVideoEditorToolProvider; exports.createVideoProject = createVideoProject; exports.decodeVideoAudioPeaks = decodeVideoAudioPeaks; exports.describeVideoProject = describeVideoProject; exports.drawVideoFrame = drawVideoFrame; exports.ffmpegFilterValue = ffmpegFilterValue; exports.findVideoAudioSilences = findVideoAudioSilences; exports.findVideoClip = findVideoClip; exports.findVideoGaps = findVideoGaps; exports.formatVideoSeconds = formatVideoSeconds; exports.formatVideoTimecode = formatVideoTimecode; exports.formatVideoTimecodeFrames = formatVideoTimecodeFrames; exports.isVideoDropFrameRate = isVideoDropFrameRate; exports.isVideoKeyframes = isVideoKeyframes; exports.nextFreeVideoSlot = nextFreeVideoSlot; exports.parseVideoAnimatable = parseVideoAnimatable; exports.parseVideoAsset = parseVideoAsset; exports.parseVideoClip = parseVideoClip; exports.parseVideoKeyframes = parseVideoKeyframes; exports.parseVideoLoudnessMeasurement = parseVideoLoudnessMeasurement; exports.parseVideoMarker = parseVideoMarker; exports.parseVideoProject = parseVideoProject; exports.parseVideoTrack = parseVideoTrack; exports.parseVideoTransform = parseVideoTransform; exports.parseVideoTransition = parseVideoTransition; exports.placeVideoRect = placeVideoRect; exports.planVideoClipDrag = planVideoClipDrag; exports.probeVideoMedia = probeVideoMedia; exports.removeVideoKey = removeVideoKey; exports.retimeVideoAnimatable = retimeVideoAnimatable; exports.setVideoKey = setVideoKey; exports.snapToVideoFrame = snapToVideoFrame; exports.snapVideoTime = snapVideoTime; exports.sortVideoKeys = sortVideoKeys; exports.sortedVideoClips = sortedVideoClips; exports.splitVideoAnimatable = splitVideoAnimatable; exports.toVideoEdl = toVideoEdl; exports.toVideoFfmpeg = toVideoFfmpeg; exports.toVideoLoudnessMeasure = toVideoLoudnessMeasure; exports.videoAssetDropEdit = videoAssetDropEdit; exports.videoAssetHasSound = videoAssetHasSound; exports.videoAssetKindOf = videoAssetKindOf; exports.videoAssetMap = videoAssetMap; exports.videoAudioLevels = videoAudioLevels; exports.videoAudioPeakRange = videoAudioPeakRange; exports.videoAudioPeakSpans = videoAudioPeakSpans; exports.videoAudioPeaksDuration = videoAudioPeaksDuration; exports.videoClipAfter = videoClipAfter; exports.videoClipBefore = videoClipBefore; exports.videoClipEnd = videoClipEnd; exports.videoClipHandles = videoClipHandles; exports.videoClipKeyframes = videoClipKeyframes; exports.videoClipLabel = videoClipLabel; exports.videoClipOverhang = videoClipOverhang; exports.videoClipShowsWaveform = videoClipShowsWaveform; exports.videoClipSoundGain = videoClipSoundGain; exports.videoClipSourceRange = videoClipSourceRange; exports.videoClipSpeed = videoClipSpeed; exports.videoClipTint = videoClipTint; exports.videoClipWaveform = videoClipWaveform; exports.videoClipWaveformPath = videoClipWaveformPath; exports.videoClipsAt = videoClipsAt; exports.videoContactSheetSvg = videoContactSheetSvg; exports.videoDragLabel = videoDragLabel; exports.videoDropTracks = videoDropTracks; exports.videoEaseBezier = videoEaseBezier; exports.videoEaseProgress = videoEaseProgress; exports.videoFadeCurveGain = videoFadeCurveGain; exports.videoFrameAt = videoFrameAt; exports.videoFrameDuration = videoFrameDuration; exports.videoFrameToSvg = videoFrameToSvg; exports.videoFrames = videoFrames; exports.videoHourFrames = videoHourFrames; exports.videoIssueKey = videoIssueKey; exports.videoKeyframeBreakpoints = videoKeyframeBreakpoints; exports.videoKeyframeFfmpegExpr = videoKeyframeFfmpegExpr; exports.videoKeyframeRows = videoKeyframeRows; exports.videoKeyframeToggleEdit = videoKeyframeToggleEdit; exports.videoKeyframeValueEdit = videoKeyframeValueEdit; exports.videoKeysOf = videoKeysOf; exports.videoOverlap = videoOverlap; exports.videoPeakDb = videoPeakDb; exports.videoProjectDuration = videoProjectDuration; exports.videoProjectSvg = videoProjectSvg; exports.videoRulerTicks = videoRulerTicks; exports.videoSnapPoints = videoSnapPoints; exports.videoTextStyle = videoTextStyle; exports.videoTimecodeFrames = videoTimecodeFrames; exports.videoTitleFrame = videoTitleFrame; exports.videoTrackSyncLocked = videoTrackSyncLocked; exports.videoTransformAt = videoTransformAt; exports.videoValueAt = videoValueAt; exports.videoWaveformPath = videoWaveformPath; exports.videoWipeRect = videoWipeRect;
4249
+
4250
+
4251
+
4252
+
4253
+
4254
+
4255
+
4256
+
4257
+
4258
+
4259
+
4260
+
4261
+
4262
+
4263
+
4264
+
4265
+
4266
+
4267
+
4268
+
4269
+
4270
+
4271
+
4272
+
4273
+
4274
+
4275
+
4276
+
4277
+
4278
+
4279
+
4280
+
4281
+
4282
+
4283
+
4284
+
4285
+
4286
+
4287
+
4288
+
4289
+
4290
+
4291
+
4292
+ exports.VIDEO_BLEND_MODES = VIDEO_BLEND_MODES; exports.VIDEO_BLEND_MODE_LABELS = VIDEO_BLEND_MODE_LABELS; exports.VIDEO_CLIP_ANIMATION_DURATION = VIDEO_CLIP_ANIMATION_DURATION; exports.VIDEO_CLIP_ANIMATION_EFFECTS = VIDEO_CLIP_ANIMATION_EFFECTS; exports.VIDEO_CLIP_ANIMATION_LABELS = VIDEO_CLIP_ANIMATION_LABELS; exports.VIDEO_CLIP_TYPES = VIDEO_CLIP_TYPES; exports.VIDEO_DEFAULT_FILLERS = VIDEO_DEFAULT_FILLERS; exports.VIDEO_DEFAULT_STILL_DURATION = VIDEO_DEFAULT_STILL_DURATION; exports.VIDEO_DEFAULT_TITLE_DURATION = VIDEO_DEFAULT_TITLE_DURATION; exports.VIDEO_EASE_NAMES = VIDEO_EASE_NAMES; exports.VIDEO_EDIT_OPS = VIDEO_EDIT_OPS; exports.VIDEO_FADE_CURVES = VIDEO_FADE_CURVES; exports.VIDEO_FADE_CURVE_LABELS = VIDEO_FADE_CURVE_LABELS; exports.VIDEO_FITS = VIDEO_FITS; exports.VIDEO_GRADE_CONTROLS = VIDEO_GRADE_CONTROLS; exports.VIDEO_GRADE_PRESETS = VIDEO_GRADE_PRESETS; exports.VIDEO_GRADE_PRESET_NAMES = VIDEO_GRADE_PRESET_NAMES; exports.VIDEO_ISSUE_CODES = VIDEO_ISSUE_CODES; exports.VIDEO_KEYFRAME_LIMITS = VIDEO_KEYFRAME_LIMITS; exports.VIDEO_KEYFRAME_PROPERTIES = VIDEO_KEYFRAME_PROPERTIES; exports.VIDEO_PEAK_BLOCK = VIDEO_PEAK_BLOCK; exports.VIDEO_PLACEMENT_MODES = VIDEO_PLACEMENT_MODES; exports.VIDEO_TIME_EPSILON = VIDEO_TIME_EPSILON; exports.VIDEO_TRANSFORM_PROPERTIES = VIDEO_TRANSFORM_PROPERTIES; exports.VIDEO_TRANSITION_LABELS = VIDEO_TRANSITION_LABELS; exports.VIDEO_TRANSITION_TYPES = VIDEO_TRANSITION_TYPES; exports.VIDEO_WIPE_DIRECTIONS = VIDEO_WIPE_DIRECTIONS; exports.VideoAudioPeaksCache = VideoAudioPeaksCache; exports.VideoEditorController = VideoEditorController; exports.VideoPreviewEngine = VideoPreviewEngine; exports.allVideoClipIds = allVideoClipIds; exports.applyVideoEdits = applyVideoEdits; exports.applyVideoGrade = applyVideoGrade; exports.buildVideoAudioPeaks = buildVideoAudioPeaks; exports.checkVideoProject = checkVideoProject; exports.createVideoEditorToolProvider = createVideoEditorToolProvider; exports.createVideoProject = createVideoProject; exports.decodeVideoAudioPeaks = decodeVideoAudioPeaks; exports.describeVideoProject = describeVideoProject; exports.drawVideoFrame = drawVideoFrame; exports.ffmpegFilterValue = ffmpegFilterValue; exports.findVideoAudioSilences = findVideoAudioSilences; exports.findVideoClip = findVideoClip; exports.findVideoFillers = findVideoFillers; exports.findVideoGaps = findVideoGaps; exports.findVideoTranscriptPauses = findVideoTranscriptPauses; exports.findVideoTranscriptText = findVideoTranscriptText; exports.formatVideoSeconds = formatVideoSeconds; exports.formatVideoTimecode = formatVideoTimecode; exports.formatVideoTimecodeFrames = formatVideoTimecodeFrames; exports.isVideoDropFrameRate = isVideoDropFrameRate; exports.isVideoKeyframes = isVideoKeyframes; exports.mergeVideoRanges = mergeVideoRanges; exports.nextFreeVideoSlot = nextFreeVideoSlot; exports.normalizeVideoGrade = normalizeVideoGrade; exports.normalizeVideoWord = normalizeVideoWord; exports.parseVideoAnimatable = parseVideoAnimatable; exports.parseVideoAsset = parseVideoAsset; exports.parseVideoClip = parseVideoClip; exports.parseVideoClipAnimation = parseVideoClipAnimation; exports.parseVideoGrade = parseVideoGrade; exports.parseVideoKeyframes = parseVideoKeyframes; exports.parseVideoLoudnessMeasurement = parseVideoLoudnessMeasurement; exports.parseVideoMarker = parseVideoMarker; exports.parseVideoProject = parseVideoProject; exports.parseVideoTrack = parseVideoTrack; exports.parseVideoTranscript = parseVideoTranscript; exports.parseVideoTranscriptWords = parseVideoTranscriptWords; exports.parseVideoTransform = parseVideoTransform; exports.parseVideoTransition = parseVideoTransition; exports.placeVideoRect = placeVideoRect; exports.planVideoClipDrag = planVideoClipDrag; exports.probeVideoMedia = probeVideoMedia; exports.removeVideoKey = removeVideoKey; exports.retimeVideoAnimatable = retimeVideoAnimatable; exports.setVideoKey = setVideoKey; exports.snapToVideoFrame = snapToVideoFrame; exports.snapVideoTime = snapVideoTime; exports.sortVideoKeys = sortVideoKeys; exports.sortedVideoClips = sortedVideoClips; exports.splitVideoAnimatable = splitVideoAnimatable; exports.toVideoEdl = toVideoEdl; exports.toVideoFfmpeg = toVideoFfmpeg; exports.toVideoLoudnessMeasure = toVideoLoudnessMeasure; exports.videoAssetDropEdit = videoAssetDropEdit; exports.videoAssetHasSound = videoAssetHasSound; exports.videoAssetKindOf = videoAssetKindOf; exports.videoAssetMap = videoAssetMap; exports.videoAudioLevels = videoAudioLevels; exports.videoAudioPeakRange = videoAudioPeakRange; exports.videoAudioPeakSpans = videoAudioPeakSpans; exports.videoAudioPeaksDuration = videoAudioPeaksDuration; exports.videoClipAfter = videoClipAfter; exports.videoClipAnimated = videoClipAnimated; exports.videoClipAnimationProperties = videoClipAnimationProperties; exports.videoClipAnimationSpans = videoClipAnimationSpans; exports.videoClipBefore = videoClipBefore; exports.videoClipEnd = videoClipEnd; exports.videoClipHandles = videoClipHandles; exports.videoClipKeyframes = videoClipKeyframes; exports.videoClipLabel = videoClipLabel; exports.videoClipOverhang = videoClipOverhang; exports.videoClipShowsWaveform = videoClipShowsWaveform; exports.videoClipSoundGain = videoClipSoundGain; exports.videoClipSourceRange = videoClipSourceRange; exports.videoClipSpeed = videoClipSpeed; exports.videoClipTint = videoClipTint; exports.videoClipWaveform = videoClipWaveform; exports.videoClipWaveformPath = videoClipWaveformPath; exports.videoClipsAt = videoClipsAt; exports.videoContactSheetSvg = videoContactSheetSvg; exports.videoDragLabel = videoDragLabel; exports.videoDropTracks = videoDropTracks; exports.videoEaseBezier = videoEaseBezier; exports.videoEaseProgress = videoEaseProgress; exports.videoFadeCurveGain = videoFadeCurveGain; exports.videoFrameAt = videoFrameAt; exports.videoFrameDuration = videoFrameDuration; exports.videoFrameToSvg = videoFrameToSvg; exports.videoFrames = videoFrames; exports.videoGradeCssFilter = videoGradeCssFilter; exports.videoGradeFfmpegFilters = videoGradeFfmpegFilters; exports.videoGradeGainsFilterId = videoGradeGainsFilterId; exports.videoGradePreset = videoGradePreset; exports.videoGradeStepMatrix = videoGradeStepMatrix; exports.videoGradeStepSvgValues = videoGradeStepSvgValues; exports.videoGradeSteps = videoGradeSteps; exports.videoGradeSvgDefs = videoGradeSvgDefs; exports.videoGradeSvgFilter = videoGradeSvgFilter; exports.videoHourFrames = videoHourFrames; exports.videoIrisRadius = videoIrisRadius; exports.videoIssueKey = videoIssueKey; exports.videoKeyframeBreakpoints = videoKeyframeBreakpoints; exports.videoKeyframeFfmpegExpr = videoKeyframeFfmpegExpr; exports.videoKeyframeRows = videoKeyframeRows; exports.videoKeyframeToggleEdit = videoKeyframeToggleEdit; exports.videoKeyframeValueEdit = videoKeyframeValueEdit; exports.videoKeysOf = videoKeysOf; exports.videoOverlap = videoOverlap; exports.videoPeakDb = videoPeakDb; exports.videoProjectDuration = videoProjectDuration; exports.videoProjectSvg = videoProjectSvg; exports.videoRemoveRangesEdits = videoRemoveRangesEdits; exports.videoRulerTicks = videoRulerTicks; exports.videoSnapPoints = videoSnapPoints; exports.videoTextStyle = videoTextStyle; exports.videoTimecodeFrames = videoTimecodeFrames; exports.videoTimelineTranscript = videoTimelineTranscript; exports.videoTitleFrame = videoTitleFrame; exports.videoTrackSyncLocked = videoTrackSyncLocked; exports.videoTranscriptEdit = videoTranscriptEdit; exports.videoTranscriptTrack = videoTranscriptTrack; exports.videoTransformAt = videoTransformAt; exports.videoTransitionAudioCurve = videoTransitionAudioCurve; exports.videoTransitionEase = videoTransitionEase; exports.videoTransitionHasDirection = videoTransitionHasDirection; exports.videoValueAt = videoValueAt; exports.videoWaveformPath = videoWaveformPath; exports.videoWipeRect = videoWipeRect; exports.videoWordAt = videoWordAt; exports.videoWordsRange = videoWordsRange;
3522
4293
  //# sourceMappingURL=index.cjs.map