@pixerate/editor 0.230.0 → 0.231.1

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.
@@ -23,6 +23,7 @@ export * from './grade.cjs';
23
23
  export * from './lut.cjs';
24
24
  export * from './scopes.cjs';
25
25
  export * from './clip-animation.cjs';
26
+ export * from './camera.cjs';
26
27
  export * from './transcript.cjs';
27
28
  export * from './transcript-controller.cjs';
28
29
  export * from './remap.cjs';
@@ -35,6 +36,7 @@ export * from './preview.cjs';
35
36
  export * from './controller.cjs';
36
37
  export * from './tools.cjs';
37
38
  export * from './effect-tools.cjs';
39
+ export * from './camera-tools.cjs';
38
40
  export * from './dialogue-tools.cjs';
39
41
  export * from './music-tools.cjs';
40
42
  export * from './effect-authoring.cjs';
@@ -23,6 +23,7 @@ export * from './grade.js';
23
23
  export * from './lut.js';
24
24
  export * from './scopes.js';
25
25
  export * from './clip-animation.js';
26
+ export * from './camera.js';
26
27
  export * from './transcript.js';
27
28
  export * from './transcript-controller.js';
28
29
  export * from './remap.js';
@@ -35,6 +36,7 @@ export * from './preview.js';
35
36
  export * from './controller.js';
36
37
  export * from './tools.js';
37
38
  export * from './effect-tools.js';
39
+ export * from './camera-tools.js';
38
40
  export * from './dialogue-tools.js';
39
41
  export * from './music-tools.js';
40
42
  export * from './effect-authoring.js';
@@ -111,6 +111,7 @@ import {
111
111
  videoGradeSvgFilter,
112
112
  videoHourFrames,
113
113
  videoIrisRadius,
114
+ videoKeyTimesProblem,
114
115
  videoKeyframeBreakpoints,
115
116
  videoKeyframeFfmpegExpr,
116
117
  videoKeysOf,
@@ -148,15 +149,20 @@ import {
148
149
  videoTransitionEase,
149
150
  videoValueAt,
150
151
  videoWipeRect
151
- } from "../chunk-ONNNNGNS.js";
152
+ } from "../chunk-CSM5T6SE.js";
152
153
  import "../chunk-EHLOCVWB.js";
153
154
  import {
154
155
  namedWithSuggestion
155
156
  } from "../chunk-TYCJGFCF.js";
156
157
  import {
158
+ CAMERA_MOVE_MAX_MS,
157
159
  EffectsSlot,
160
+ cameraMoveField,
161
+ cameraMoveParts,
158
162
  createEffectToolProvider,
159
- isEffectError
163
+ isEffectError,
164
+ resolveCameraMoveSpec,
165
+ sampleEffectCameraMove
160
166
  } from "../chunk-TCZ2J7PR.js";
161
167
  import "../chunk-V2ZCJRDM.js";
162
168
  import {
@@ -228,7 +234,10 @@ function parseVideoKeyframes(v, path, { min, max } = {}) {
228
234
  for (const key of Object.keys(k)) if (!["t", "v", "ease"].includes(key)) fail(`${at}.${key}`, "is not a key field (t, v, ease)");
229
235
  return compact({ t: num(k.t, `${at}.t`), v: num(k.v, `${at}.v`, { min, max }), ease: parseVideoEase(k.ease, `${at}.ease`) });
230
236
  });
231
- for (let i = 1; i < keys.length; i++) if (!(keys[i].t > keys[i - 1].t)) fail(`${path}.keys[${i}].t`, `must be later than the key before (${keys[i - 1].t} ms); keys go in time order, one per time`);
237
+ for (let i = 1; i < keys.length; i++) {
238
+ if (keys[i].t < keys[i - 1].t) fail(`${path}.keys[${i}].t`, `must not be before the key before (${keys[i - 1].t} ms); keys go in time order`);
239
+ if (i >= 2 && keys[i].t === keys[i - 2].t) fail(`${path}.keys[${i}].t`, `is the third key at ${keys[i].t} ms; a time takes one key, or two for a jump (the value just before, then the value after)`);
240
+ }
232
241
  return { keys };
233
242
  }
234
243
  function parseVideoAnimatable(v, path, range = {}) {
@@ -253,6 +262,7 @@ function parseVideoTimeRemap(v, speed, path) {
253
262
  const remap = parseVideoAnimatable(v, path, { optional: true, min: 0 });
254
263
  if (typeof remap === "number" || remap === void 0) return fail(path, "must be keyframes { keys: [{ t, v, ease }] } (v: ms of source after the in point)");
255
264
  if (speed !== void 0 && speed !== 1) fail(path, 'cannot be used with a speed; a ramp sets the speed itself (remove "speed")');
265
+ for (let i = 1; i < remap.keys.length; i++) if (remap.keys[i].t === remap.keys[i - 1].t) fail(`${path}.keys[${i}].t`, `must be later than the key before (${remap.keys[i - 1].t} ms); a speed ramp has one key per time`);
256
266
  try {
257
267
  checkVideoTimeRemap(remap, path);
258
268
  } catch (e) {
@@ -414,6 +424,42 @@ function parseVideoAssetProxies(v, path) {
414
424
  }
415
425
  return out.length ? out : void 0;
416
426
  }
427
+ var CAMERA_KEYS = ["in", "during", "from", "framing", "base"];
428
+ function parseCameraFraming(v, path) {
429
+ if (v === void 0) return void 0;
430
+ if (!isObject(v)) return fail(path, "must be an object { zoom, x, y }");
431
+ const unknown = Object.keys(v).find((k) => !["zoom", "x", "y"].includes(k));
432
+ if (unknown) fail(`${path}.${unknown}`, "is not a framing field (zoom, x, y)");
433
+ return compact({ zoom: num(v.zoom, `${path}.zoom`, { optional: true, positive: true }), x: num(v.x, `${path}.x`, { optional: true }), y: num(v.y, `${path}.y`, { optional: true }) });
434
+ }
435
+ function parseCameraMove(v, path, hold) {
436
+ if (v === void 0) return void 0;
437
+ if (typeof v === "string" && v) return v;
438
+ if (!isObject(v) || typeof v.move !== "string" || !v.move) return fail(path, "must be a camera move's id, or { move, ms, preset, values }");
439
+ const unknown = Object.keys(v).find((k) => !["move", "ms", "preset", "values"].includes(k));
440
+ if (unknown) fail(`${path}.${unknown}`, "is not a camera move field (move, ms, preset, values)");
441
+ if (hold && v.ms !== void 0) fail(`${path}.ms`, "is not for a hold move, which plays across the rest of the clip");
442
+ if (v.values !== void 0 && !isObject(v.values)) fail(`${path}.values`, "must be an object of the move's control values");
443
+ return compact({ move: v.move, ms: num(v.ms, `${path}.ms`, { optional: true, min: 0 }), preset: str(v.preset, `${path}.preset`, true), values: v.values });
444
+ }
445
+ function parseVideoClipCamera(v, path) {
446
+ if (v === void 0) return void 0;
447
+ if (!isObject(v)) return fail(path, "must be an object { in, during, from, framing, base }");
448
+ const unknown = Object.keys(v).find((k) => !CAMERA_KEYS.includes(k));
449
+ if (unknown) fail(`${path}.${unknown}`, `is not a camera field; use ${CAMERA_KEYS.join(", ")}`);
450
+ if (v.in === void 0 && v.during === void 0) fail(path, 'names no move: give "in", "during" or both, or remove the camera');
451
+ if (!isObject(v.base)) fail(`${path}.base`, "must be the clip's transform before the moves were baked, { x, y, scale, rotation } as numbers");
452
+ const base = v.base;
453
+ const extra = Object.keys(base).find((k) => !["x", "y", "scale", "rotation"].includes(k));
454
+ if (extra) fail(`${path}.base.${extra}`, "is not a transform field (x, y, scale, rotation)");
455
+ return compact({
456
+ in: parseCameraMove(v.in, `${path}.in`, false),
457
+ during: parseCameraMove(v.during, `${path}.during`, true),
458
+ from: parseCameraFraming(v.from, `${path}.from`),
459
+ framing: parseCameraFraming(v.framing, `${path}.framing`),
460
+ base: compact({ x: num(base.x, `${path}.base.x`, { optional: true }), y: num(base.y, `${path}.base.y`, { optional: true }), scale: num(base.scale, `${path}.base.scale`, { optional: true, min: 0 }), rotation: num(base.rotation, `${path}.base.rotation`, { optional: true }) })
461
+ });
462
+ }
417
463
  function parseVideoClip(v, path = "clip") {
418
464
  if (!isObject(v)) return fail(path, "must be an object");
419
465
  const type = oneOf(v.type ?? "media", VIDEO_CLIP_TYPES, `${path}.type`, false);
@@ -433,7 +479,8 @@ function parseVideoClip(v, path = "clip") {
433
479
  fadeOut: num(v.fadeOut, `${path}.fadeOut`, { optional: true, min: 0 }),
434
480
  transition: parseVideoTransition(v.transition, `${path}.transition`),
435
481
  effects: parseVideoClipEffects(v.effects, `${path}.effects`),
436
- mask: parseVideoClipMask(v.mask, `${path}.mask`)
482
+ mask: parseVideoClipMask(v.mask, `${path}.mask`),
483
+ camera: parseVideoClipCamera(v.camera, `${path}.camera`)
437
484
  };
438
485
  if (type === "media")
439
486
  return compact({
@@ -597,6 +644,7 @@ var VIDEO_ISSUE_CODES = [
597
644
  "animations-too-long",
598
645
  "animation-keyed",
599
646
  "keyframes-outside-clip",
647
+ "camera-stale",
600
648
  "unknown-duration",
601
649
  "unused-asset",
602
650
  "unused-sequence",
@@ -663,6 +711,17 @@ function checkVideoProject(project) {
663
711
  if (keyed.length)
664
712
  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.` });
665
713
  }
714
+ if (clip.camera) {
715
+ const last = Math.max(0, ...VIDEO_TRANSFORM_PROPERTIES.flatMap((p) => videoKeysOf(clip.transform?.[p]).map((k) => k.t)));
716
+ const frame = 1e3 / (project.fps || 30);
717
+ if (last > 0 && Math.abs(last - clip.duration) > frame + VIDEO_TIME_EPSILON)
718
+ issues.push({
719
+ code: "camera-stale",
720
+ severity: "warning",
721
+ ...at,
722
+ message: `Clip "${clip.id}"'s camera moves were baked for ${Math.round(last)} ms but the clip is ${Math.round(clip.duration)} ms, so ${last < clip.duration ? "the camera stops early" : "the end of the move is cut off"}; set the move again (set_clip_camera_move) to re-bake it.`
723
+ });
724
+ }
666
725
  for (const { property, keys } of videoClipKeyframes(clip)) {
667
726
  const outside = keys.filter((k) => k.t < -VIDEO_TIME_EPSILON || k.t > clip.duration + VIDEO_TIME_EPSILON);
668
727
  if (outside.length)
@@ -784,7 +843,7 @@ function retimeClipKeys(clip, fn) {
784
843
  if (isVideoKeyframes(v)) a.set(fn(v));
785
844
  }
786
845
  }
787
- var OPTIONAL_CLIP_FIELDS = ["name", "effects", "mask", "scope", "targetTrack", "values", "provider", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "timeRemap", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
846
+ var OPTIONAL_CLIP_FIELDS = ["name", "effects", "mask", "camera", "scope", "targetTrack", "values", "provider", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "timeRemap", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
788
847
  var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
789
848
  var Editor = class {
790
849
  constructor(p) {
@@ -1256,7 +1315,7 @@ var Editor = class {
1256
1315
  } else {
1257
1316
  if (!Array.isArray(e.keys) || !e.keys.length) refuse('"keys" must be a list of at least one { t, v, ease } (or null to remove the ramp).');
1258
1317
  const remap = parseVideoTimeRemap({ keys: e.keys }, void 0, "keys");
1259
- remap.keys = sortVideoKeys(remap.keys);
1318
+ remap.keys = sortVideoKeys(remap.keys, { jumps: false });
1260
1319
  checkVideoTimeRemap(remap, "keys");
1261
1320
  const span = videoTimeRemapSpan(remap);
1262
1321
  if (asset.duration !== void 0 && (media.in ?? 0) + span.to > asset.duration + VIDEO_TIME_EPSILON)
@@ -2160,7 +2219,7 @@ function planVideoEdits(project, input) {
2160
2219
  const result = applyVideoEdits(project, input.edits);
2161
2220
  if (!result.ok) return { ok: false, error: result.error };
2162
2221
  const known = new Set(checkVideoProject(project).map(videoIssueKey));
2163
- const summary = { ...diffVideoProjects(project, result.project), newIssues: result.issues.filter((i) => !known.has(videoIssueKey(i))), edits: input.edits.length };
2222
+ const summary2 = { ...diffVideoProjects(project, result.project), newIssues: result.issues.filter((i) => !known.has(videoIssueKey(i))), edits: input.edits.length };
2164
2223
  const plan = {
2165
2224
  id: input.id ?? "plan",
2166
2225
  title: input.title.trim(),
@@ -2168,7 +2227,7 @@ function planVideoEdits(project, input) {
2168
2227
  ...input.source ? { source: input.source } : {},
2169
2228
  edits: structuredClone([...input.edits]),
2170
2229
  ...input.ranges?.length ? { ranges: input.ranges.map((r) => ({ ...r })) } : {},
2171
- summary
2230
+ summary: summary2
2172
2231
  };
2173
2232
  return { ok: true, plan, project: result.project };
2174
2233
  }
@@ -2514,23 +2573,23 @@ var VideoPreviewPixels = class {
2514
2573
  next(time, timeout = 5e3) {
2515
2574
  const matches = () => this.drawnAt && this.drawnAt.complete && Math.abs(this.drawnAt.time - time) < 0.5;
2516
2575
  return new Promise((resolve, reject) => {
2517
- const done = (fn) => {
2576
+ const done2 = (fn) => {
2518
2577
  clearTimeout(timer);
2519
2578
  off();
2520
2579
  fn();
2521
2580
  };
2522
2581
  const check = () => {
2523
- if (!this.reader) return done(() => reject(new Error("The preview was closed before it drew the frame.")));
2582
+ if (!this.reader) return done2(() => reject(new Error("The preview was closed before it drew the frame.")));
2524
2583
  if (!matches()) return;
2525
2584
  try {
2526
2585
  const snap = this.read();
2527
- done(() => snap ? resolve(snap) : reject(new Error("The preview has no pixels to read.")));
2586
+ done2(() => snap ? resolve(snap) : reject(new Error("The preview has no pixels to read.")));
2528
2587
  } catch (e) {
2529
- done(() => reject(e));
2588
+ done2(() => reject(e));
2530
2589
  }
2531
2590
  };
2532
2591
  const off = this.subscribe(check);
2533
- const timer = setTimeout(() => done(() => reject(new Error(`The preview did not finish drawing ${Math.round(time)} ms within ${timeout} ms; its media may still be loading or may have failed to load. Try again, or check the asset sources.`))), timeout);
2592
+ const timer = setTimeout(() => done2(() => reject(new Error(`The preview did not finish drawing ${Math.round(time)} ms within ${timeout} ms; its media may still be loading or may have failed to load. Try again, or check the asset sources.`))), timeout);
2534
2593
  check();
2535
2594
  });
2536
2595
  }
@@ -2555,6 +2614,88 @@ function readVideoScopesPanel(feed, kinds = VIDEO_SCOPE_KINDS, options = {}) {
2555
2614
  return { ok: true, time: snap.time, complete: snap.complete, images, stats, notes: videoExposureNotes(stats) };
2556
2615
  }
2557
2616
 
2617
+ // src/video-editor/camera.ts
2618
+ function videoCameraView(framing, stage) {
2619
+ const zoom = framing?.zoom ?? 1;
2620
+ return { zoom, scrollX: stage.width / (2 * zoom) - (stage.width / 2 + (framing?.x ?? 0)), scrollY: stage.height / (2 * zoom) - (stage.height / 2 + (framing?.y ?? 0)) };
2621
+ }
2622
+ function videoTransformOfView(view, base, stage) {
2623
+ const cx = stage.width / 2;
2624
+ const cy = stage.height / 2;
2625
+ const px = (cx + (base.x ?? 0) + view.scrollX) * view.zoom - cx;
2626
+ const py = (cy + (base.y ?? 0) + view.scrollY) * view.zoom - cy;
2627
+ const roll = view.roll ?? 0;
2628
+ const a = roll * Math.PI / 180;
2629
+ const tidy = (n) => Math.round(n * 1e3) / 1e3 || 0;
2630
+ return {
2631
+ x: tidy(px * Math.cos(a) - py * Math.sin(a)),
2632
+ y: tidy(px * Math.sin(a) + py * Math.cos(a)),
2633
+ scale: tidy((base.scale ?? 1) * view.zoom),
2634
+ rotation: tidy((base.rotation ?? 0) + roll)
2635
+ };
2636
+ }
2637
+ function videoCameraBase(clip) {
2638
+ if (clip.camera) return clip.camera.base;
2639
+ const t = clip.transform ?? {};
2640
+ const keyed = VIDEO_TRANSFORM_PROPERTIES.filter((p) => isVideoKeyframes(t[p]));
2641
+ if (keyed.length)
2642
+ throw new Error(`Clip "${clip.id}" keys its ${keyed.join(", ")}, which a camera move would replace; remove those keyframes first (set_video_keyframes with keys null).`);
2643
+ return Object.fromEntries(VIDEO_TRANSFORM_PROPERTIES.flatMap((p) => typeof t[p] === "number" ? [[p, t[p]]] : []));
2644
+ }
2645
+ async function bakeVideoClipCamera(clip, camera, project, provider) {
2646
+ const stage = { width: project.width, height: project.height };
2647
+ const fps = project.fps > 0 ? project.fps : 30;
2648
+ const to = videoCameraView(camera.framing, stage);
2649
+ const notes = [];
2650
+ const cuts = [];
2651
+ const windows = {};
2652
+ let keys = [];
2653
+ let inMs = 0;
2654
+ const moveIn = cameraMoveParts(camera.in);
2655
+ if (moveIn) {
2656
+ const { description } = await resolveCameraMoveSpec(provider, moveIn, "transition");
2657
+ inMs = moveIn.ms ?? description.camera.durationMs;
2658
+ if (inMs > clip.duration) throw new Error(`The move into clip "${clip.id}" takes ${inMs} ms, longer than the clip (${clip.duration} ms); give a shorter "ms" or lengthen the clip.`);
2659
+ const sample = await sampleEffectCameraMove(provider, moveIn.move, { from: videoCameraView(camera.from, stage), to, ms: inMs, stage, fps, values: moveIn.values });
2660
+ keys.push(...sample.keys);
2661
+ if (sample.cut !== void 0) cuts.push(sample.cut);
2662
+ windows.in = { start: 0, end: inMs };
2663
+ }
2664
+ const moveDuring = cameraMoveParts(camera.during);
2665
+ const holdMs = clip.duration - inMs;
2666
+ if (moveDuring && holdMs > 0) {
2667
+ await resolveCameraMoveSpec(provider, moveDuring, "hold");
2668
+ const at = keys.length ? keys[keys.length - 1].view : to;
2669
+ const sample = await sampleEffectCameraMove(provider, moveDuring.move, { from: at, to: at, ms: holdMs, stage, fps, values: moveDuring.values });
2670
+ const shifted = sample.keys.map((k) => ({ t: k.t + inMs, view: k.view }));
2671
+ if (keys.length && shifted.length && sameView(keys[keys.length - 1].view, shifted[0].view)) shifted.shift();
2672
+ keys.push(...shifted);
2673
+ if (sample.cut !== void 0) cuts.push(sample.cut + inMs);
2674
+ windows.during = { start: inMs, end: clip.duration };
2675
+ } else if (moveDuring) notes.push(`The move into the clip fills it, so the hold move "${moveDuring.move}" has no time to play; shorten the move in or lengthen the clip.`);
2676
+ if (!keys.length) keys = [{ t: 0, view: to }];
2677
+ const flat = keys.some((k) => k.view.tilt || k.view.turn || k.view.blur && k.view.blur > 0);
2678
+ if (flat) notes.push("A clip transform has no tilt, turn or motion blur, so those parts of the move are left out; the zoom, pan and roll are kept.");
2679
+ const base = videoCameraBase(clip);
2680
+ const transforms = keys.map((k) => ({ t: Math.round(k.t * 1e3) / 1e3, value: videoTransformOfView(k.view, base, stage) }));
2681
+ const transform = {};
2682
+ for (const p of VIDEO_TRANSFORM_PROPERTIES) {
2683
+ const out = [];
2684
+ for (const { t, value } of transforms) {
2685
+ const last = out[out.length - 1];
2686
+ const prev = out[out.length - 2];
2687
+ if (last && prev && last.v === value[p] && prev.v === value[p] && prev.t !== last.t) out[out.length - 1] = { t, v: value[p] };
2688
+ else out.push({ t, v: value[p] });
2689
+ }
2690
+ transform[p] = out.length > 1 && out.some((k) => k.v !== out[0].v) ? { keys: out } : out[0].v;
2691
+ }
2692
+ return { transform, cuts, windows, notes };
2693
+ }
2694
+ var sameView = (a, b) => ["zoom", "scrollX", "scrollY", "tilt", "turn", "roll", "blur"].every((k) => Math.abs((a[k] ?? 0) - (b[k] ?? 0)) < 1e-9);
2695
+ function videoClipCameraMove(clip, role) {
2696
+ return cameraMoveParts(clip?.camera?.[role === "transition" ? "in" : "during"]);
2697
+ }
2698
+
2558
2699
  // src/video-editor/transcript-controller.ts
2559
2700
  var TranscriptVideoController = class {
2560
2701
  constructor(controller, options = {}) {
@@ -3758,6 +3899,87 @@ var VideoEditorController = class _VideoEditorController {
3758
3899
  return { edit: { op: "update_clip", clip, set: { effects: stack } }, label: `Change effect ${was.name ?? was.effect}`, clip, effect: () => next };
3759
3900
  }, options.merge);
3760
3901
  }
3902
+ /** The provider's camera moves for a role (`transition` into a clip, `hold` while it plays), or all of them. Empty without camera support. */
3903
+ async cameraMoves(role) {
3904
+ const provider = this.effects.provider;
3905
+ if (!provider?.capabilities.supportsCamera || !provider.cameraView) return [];
3906
+ return (await provider.list({ kind: "camera" })).filter((e) => e.kind === "camera" && e.camera && (!role || e.camera.role === role));
3907
+ }
3908
+ cameraClip(id) {
3909
+ const at = findVideoClip(this.project, id);
3910
+ if (!at) {
3911
+ const ids = this.project.tracks.flatMap((t) => t.clips.map((c) => c.id));
3912
+ throw new Error(`There is no clip "${id}"; the clips are ${ids.map((c) => `"${c}"`).join(", ") || "none"} (get_video_project lists them).`);
3913
+ }
3914
+ if (at.track.kind === "audio") throw new Error(`Clip "${id}" is on audio track "${at.track.id}" and has no picture for a camera to move over.`);
3915
+ if (at.clip.type === "adjustment") throw new Error(`Clip "${id}" is an adjustment clip, which has no picture of its own for a camera to move over.`);
3916
+ return at.clip;
3917
+ }
3918
+ async cameraEdit(clipId, camera, label, merge) {
3919
+ const clip = this.cameraClip(clipId);
3920
+ const base = videoCameraBase(clip);
3921
+ if (!camera) {
3922
+ const restored = Object.keys(base).length ? { transform: base } : {};
3923
+ const r2 = this.apply({ op: "update_clip", clip: clipId, set: restored, remove: ["camera", ...Object.keys(base).length ? [] : ["transform"]] }, label, { merge });
3924
+ if (!r2.ok) return r2;
3925
+ return { ok: true, clip: clipId, camera: null, keys: { x: 0, y: 0, scale: 0, rotation: 0 }, cuts: [], windows: {}, notes: [] };
3926
+ }
3927
+ const bake = await bakeVideoClipCamera(clip, camera, this.project, this.effects.require());
3928
+ const r = this.apply({ op: "update_clip", clip: clipId, set: { camera, transform: bake.transform } }, label, { merge });
3929
+ if (!r.ok) return r;
3930
+ const keys = Object.fromEntries(VIDEO_TRANSFORM_PROPERTIES.map((p) => [p, videoKeysOf(bake.transform[p]).length]));
3931
+ return { ok: true, clip: clipId, camera, keys, cuts: bake.cuts, windows: bake.windows, notes: bake.notes };
3932
+ }
3933
+ /**
3934
+ * Sets the camera move a clip plays into (`transition`: from `from` to `framing` from the
3935
+ * clip's start) or while it holds (`hold`: across the rest of the clip at `framing`), and bakes
3936
+ * the clip's moves into its x, y, scale and rotation keys at the project's frame rate, keeping a
3937
+ * cut's two keys. The move is checked first (role, preset, values; a preset's values and a move
3938
+ * in's length are written out). A clip whose transform is keyed by hand is refused. One undo step.
3939
+ */
3940
+ async setClipCameraMove(clip, role, spec, options = {}) {
3941
+ const reason = this.readOnlyReason();
3942
+ if (reason) return this.refused(reason);
3943
+ try {
3944
+ const current = this.cameraClip(clip);
3945
+ const { spec: parts, description } = await resolveCameraMoveSpec(this.effects.require(), spec, role);
3946
+ const move = role === "transition" && parts.ms === void 0 ? { ...parts, ms: description.camera.durationMs } : parts;
3947
+ const simple = move.ms === void 0 && move.preset === void 0 && move.values === void 0;
3948
+ const camera = { ...current.camera ?? { base: videoCameraBase(current) }, [cameraMoveField(role)]: simple ? move.move : move };
3949
+ for (const key of ["from", "framing"]) {
3950
+ if (options[key] === null) delete camera[key];
3951
+ else if (options[key] !== void 0) camera[key] = options[key];
3952
+ }
3953
+ if (camera.from && !camera.in) throw new Error('"from" is where a move in starts; set a transition move too, or leave "from" out.');
3954
+ return await this.cameraEdit(clip, camera, `${role === "hold" ? "Hold move" : "Camera move"} ${description.name}`, options.merge);
3955
+ } catch (error) {
3956
+ if (isEffectError(error) || error instanceof Error) return this.refused(error.message);
3957
+ throw error;
3958
+ }
3959
+ }
3960
+ /**
3961
+ * Removes a clip's camera move for a role and bakes what is left (which needs the provider);
3962
+ * with no move left the transform goes back to what it was before the moves. One undo step.
3963
+ */
3964
+ async clearClipCameraMove(clip, role) {
3965
+ const reason = this.readOnlyReason();
3966
+ if (reason) return this.refused(reason);
3967
+ try {
3968
+ const current = this.cameraClip(clip);
3969
+ const field = cameraMoveField(role);
3970
+ const other = field === "in" ? "during" : "in";
3971
+ if (current.camera?.[field] === void 0)
3972
+ throw new Error(`Clip "${clip}" has no camera "${field}" move to clear${current.camera?.[other] !== void 0 ? `; it has a "${other}" move (role ${role === "hold" ? "transition" : "hold"})` : ""}.`);
3973
+ const camera = { ...current.camera };
3974
+ delete camera[field];
3975
+ if (field === "in") delete camera.from;
3976
+ const left = camera.in !== void 0 || camera.during !== void 0;
3977
+ return await this.cameraEdit(clip, left ? camera : null, role === "hold" ? "Clear hold move" : "Clear camera move");
3978
+ } catch (error) {
3979
+ if (isEffectError(error) || error instanceof Error) return this.refused(error.message);
3980
+ throw error;
3981
+ }
3982
+ }
3761
3983
  /** Removes an effect from a clip's stack (works without a provider, so placeholders can be cleared). One undo step. */
3762
3984
  removeClipEffect(clip, instance) {
3763
3985
  const reason = this.readOnlyReason();
@@ -4041,7 +4263,7 @@ function probeVideoMedia(src, kind, timeout = 15e3) {
4041
4263
  return new Promise((resolve, reject) => {
4042
4264
  if (typeof document === "undefined") return reject(new Error("Media can only be probed in a browser."));
4043
4265
  const timer = setTimeout(() => reject(new Error(`"${src}" took too long to load.`)), timeout);
4044
- const done = (probe) => {
4266
+ const done2 = (probe) => {
4045
4267
  clearTimeout(timer);
4046
4268
  resolve(probe);
4047
4269
  };
@@ -4051,7 +4273,7 @@ function probeVideoMedia(src, kind, timeout = 15e3) {
4051
4273
  };
4052
4274
  if (kind === "image") {
4053
4275
  const img = new Image();
4054
- img.onload = () => done({ kind, width: img.naturalWidth, height: img.naturalHeight });
4276
+ img.onload = () => done2({ kind, width: img.naturalWidth, height: img.naturalHeight });
4055
4277
  img.onerror = failed;
4056
4278
  img.src = src;
4057
4279
  return;
@@ -4061,7 +4283,7 @@ function probeVideoMedia(src, kind, timeout = 15e3) {
4061
4283
  el.onloadedmetadata = () => {
4062
4284
  const duration = Number.isFinite(el.duration) ? Math.round(el.duration * 1e3) : void 0;
4063
4285
  const v = el;
4064
- done({ kind, ...duration ? { duration } : {}, ...kind === "video" && v.videoWidth ? { width: v.videoWidth, height: v.videoHeight } : {} });
4286
+ done2({ kind, ...duration ? { duration } : {}, ...kind === "video" && v.videoWidth ? { width: v.videoWidth, height: v.videoHeight } : {} });
4065
4287
  };
4066
4288
  el.onerror = failed;
4067
4289
  el.src = src;
@@ -5526,6 +5748,90 @@ function createVideoEditorToolProvider(controller, options = {}) {
5526
5748
  }, options.playbackSurface);
5527
5749
  }
5528
5750
 
5751
+ // src/video-editor/camera-tools.ts
5752
+ var ROLE = {
5753
+ type: "string",
5754
+ enum: ["transition", "hold"],
5755
+ description: 'transition: the move the camera makes into the clip from its start (a glide, whip pan, punch zoom, cut), from "from" to "framing". hold: the move it makes across the rest of the clip (a Ken Burns drift, a crash zoom), at "framing".'
5756
+ };
5757
+ var FRAMING = (what) => ({
5758
+ anyOf: [
5759
+ {
5760
+ type: "object",
5761
+ properties: {
5762
+ zoom: { type: "number", exclusiveMinimum: 0, description: "Zoom on the picture as placed (1 = the whole picture, 2 = half of it fills the frame). Default 1." },
5763
+ x: { type: "number", description: "Point the camera centres on, pixels right of the frame's centre. Default 0." },
5764
+ y: { type: "number", description: "Point the camera centres on, pixels below the frame's centre. Default 0." }
5765
+ },
5766
+ additionalProperties: false
5767
+ },
5768
+ { type: "null" }
5769
+ ],
5770
+ description: `${what} null removes it (the whole picture). Left out, the clip keeps the one it has.`
5771
+ });
5772
+ var summary = (e) => ({ id: e.id, name: e.name, role: e.camera.role, durationMs: e.camera.durationMs, ...e.camera.cut !== void 0 ? { cut: e.camera.cut } : {}, ...e.description ? { description: e.description } : {} });
5773
+ var done = (r) => {
5774
+ if (!r.ok) throw new Error(r.error);
5775
+ const { ok: _ok, ...rest } = r;
5776
+ return { ...rest, ...rest.notes.length ? {} : { notes: void 0 } };
5777
+ };
5778
+ function videoCameraMoveTools(controller, ctx) {
5779
+ return [
5780
+ {
5781
+ name: "list_video_camera_moves",
5782
+ title: "List camera moves for clips",
5783
+ description: "Lists the effects provider's camera moves, for a role or both: { available, moves: [{ id, name, role, durationMs, cut (0\u20131 of the way through where the view jumps), description }] }. Give an id to set_clip_camera_move; describe_effect gives a move's controls and presets. Without a provider that has camera moves it returns available: false and the reason.",
5784
+ inputSchema: { type: "object", properties: { role: ROLE }, additionalProperties: false },
5785
+ annotations: { title: "List camera moves for clips", ...ctx.annotations.read },
5786
+ handler: async (args) => {
5787
+ const provider = controller.effectsProvider;
5788
+ if (!provider?.capabilities.supportsCamera || !provider.cameraView)
5789
+ return { available: false, reason: provider ? `${provider.name} has no camera moves (capabilities.supportsCamera is not set).` : "No effects provider is registered, so there are no camera moves.", moves: [] };
5790
+ return { available: true, provider: provider.name, moves: (await controller.cameraMoves(args.role)).map(summary) };
5791
+ }
5792
+ },
5793
+ {
5794
+ name: "set_clip_camera_move",
5795
+ title: "Set a clip's camera move",
5796
+ description: `Plays a camera move from list_video_camera_moves on a clip's picture, the clip being the shot: role transition moves from "from" to "framing" over "ms" from the clip's start; role hold plays across the rest of the clip at "framing". The clip's moves are baked into its x, y, scale and rotation keys, one per frame at the project's fps, and a move that cuts keeps two keys at the cut so nothing tweens across it. A clip whose transform is keyed by hand is refused (clear those keys first). The move is checked first: a wrong-role id, an unknown preset or values outside its controls are refused with the moves or presets that fit. One undo step. Returns { clip, camera, keys (per property), cuts (clip ms), windows ({ in, during }: when each move plays), notes (what a clip transform cannot show: tilt, turn, blur) }. After trimming the clip, set the move again to re-bake it.`,
5797
+ inputSchema: {
5798
+ type: "object",
5799
+ properties: {
5800
+ clip: { type: "string", minLength: 1, description: "A clip id with a picture, from get_video_project." },
5801
+ role: ROLE,
5802
+ move: { type: "string", minLength: 1, description: "A camera move id of that role from list_video_camera_moves." },
5803
+ ms: { type: "number", minimum: 0, maximum: CAMERA_MOVE_MAX_MS, description: `How long a transition takes, ms (default the move's durationMs; at most the clip's length). Not for a hold, which plays across the rest of the clip.` },
5804
+ preset: { type: "string", minLength: 1, description: "A preset of the move by name, from describe_effect." },
5805
+ values: ctx.valuesSchema("Control values over the move's defaults (and the preset's), from describe_effect"),
5806
+ from: FRAMING("Where a transition starts."),
5807
+ framing: FRAMING("Where a transition arrives and a hold plays.")
5808
+ },
5809
+ required: ["clip", "role", "move"],
5810
+ additionalProperties: false
5811
+ },
5812
+ annotations: { title: "Set a clip's camera move", ...ctx.annotations.writeIdempotent },
5813
+ handler: async (args) => {
5814
+ ctx.requireProvider();
5815
+ const spec = { move: args.move, ...args.ms !== void 0 ? { ms: args.ms } : {}, ...args.preset !== void 0 ? { preset: args.preset } : {}, ...args.values !== void 0 ? { values: args.values } : {} };
5816
+ return done(await controller.setClipCameraMove(args.clip, args.role, spec, { from: args.from, framing: args.framing }));
5817
+ }
5818
+ },
5819
+ {
5820
+ name: "clear_clip_camera_move",
5821
+ title: "Clear a clip's camera move",
5822
+ description: "Removes a clip's camera move for a role and re-bakes the other one (which needs the provider); with no move left the clip's transform goes back to what it was before the moves. One undo step. Returns { clip, camera (null when none is left), keys, cuts, windows }.",
5823
+ inputSchema: {
5824
+ type: "object",
5825
+ properties: { clip: { type: "string", minLength: 1, description: "A clip id from get_video_project." }, role: ROLE },
5826
+ required: ["clip", "role"],
5827
+ additionalProperties: false
5828
+ },
5829
+ annotations: { title: "Clear a clip's camera move", ...ctx.annotations.destructive },
5830
+ handler: async (args) => done(await controller.clearClipCameraMove(args.clip, args.role))
5831
+ }
5832
+ ];
5833
+ }
5834
+
5529
5835
  // src/video-editor/effect-tools.ts
5530
5836
  function videoClipAppliedEffects(clip) {
5531
5837
  const out = [];
@@ -5616,6 +5922,7 @@ function createVideoEffectsToolProvider(controller, options = {}) {
5616
5922
  },
5617
5923
  subscribe: (notify) => controller.subscribe(() => notify()),
5618
5924
  extraTools: (ctx) => [
5925
+ ...videoCameraMoveTools(controller, ctx),
5619
5926
  {
5620
5927
  name: "add_video_effect_clip",
5621
5928
  title: "Add an effect clip",
@@ -6232,6 +6539,7 @@ export {
6232
6539
  applyVideoGrade,
6233
6540
  applyVideoLut,
6234
6541
  applyVideoLutToPixels,
6542
+ bakeVideoClipCamera,
6235
6543
  buildVideoAudioPeaks,
6236
6544
  checkVideoEffectPlace,
6237
6545
  checkVideoEffectValues,
@@ -6283,6 +6591,7 @@ export {
6283
6591
  parseVideoAssetProxies,
6284
6592
  parseVideoClip,
6285
6593
  parseVideoClipAnimation,
6594
+ parseVideoClipCamera,
6286
6595
  parseVideoClipEffects,
6287
6596
  parseVideoClipMask,
6288
6597
  parseVideoCubeLut,
@@ -6337,6 +6646,9 @@ export {
6337
6646
  videoAudioPeaksDuration,
6338
6647
  videoAuthorableEffects,
6339
6648
  videoCacheEvictions,
6649
+ videoCameraBase,
6650
+ videoCameraMoveTools,
6651
+ videoCameraView,
6340
6652
  videoChroma,
6341
6653
  videoClipAfter,
6342
6654
  videoClipAnimated,
@@ -6344,6 +6656,7 @@ export {
6344
6656
  videoClipAnimationSpans,
6345
6657
  videoClipAppliedEffects,
6346
6658
  videoClipBefore,
6659
+ videoClipCameraMove,
6347
6660
  videoClipEffectDocument,
6348
6661
  videoClipEffectUses,
6349
6662
  videoClipEnd,
@@ -6403,6 +6716,7 @@ export {
6403
6716
  videoHourFrames,
6404
6717
  videoIrisRadius,
6405
6718
  videoIssueKey,
6719
+ videoKeyTimesProblem,
6406
6720
  videoKeyframeBreakpoints,
6407
6721
  videoKeyframeFfmpegExpr,
6408
6722
  videoKeyframeRows,
@@ -6463,6 +6777,7 @@ export {
6463
6777
  videoTranscriptEdit,
6464
6778
  videoTranscriptTrack,
6465
6779
  videoTransformAt,
6780
+ videoTransformOfView,
6466
6781
  videoTransitionAudioCurve,
6467
6782
  videoTransitionEase,
6468
6783
  videoTransitionHasDirection,
@@ -17,7 +17,7 @@ export interface VideoKey {
17
17
  ease?: VideoEase;
18
18
  }
19
19
  export interface VideoKeyframes {
20
- /** In time order, at least one. */
20
+ /** In time order, at least one. Two keys at one time make a jump: the value just before, then the value from there on. */
21
21
  keys: VideoKey[];
22
22
  }
23
23
  /** A number, or keyframes for it. */
@@ -52,8 +52,15 @@ export declare function retimeVideoAnimatable<T extends VideoAnimatable | undefi
52
52
  * into two curves that trace the same path, so the two halves play exactly as the whole did.
53
53
  */
54
54
  export declare function splitVideoAnimatable<T extends VideoAnimatable | undefined>(prop: T, at: number): [T, T];
55
- /** The keys sorted by time, refusing two at the same time. */
56
- export declare function sortVideoKeys(keys: readonly VideoKey[]): VideoKey[];
55
+ /**
56
+ * The keys sorted by time. Two keys may share a time (a jump, as at a camera move's cut: the
57
+ * value just before, then the value from there on) unless `jumps` is false; three never can.
58
+ */
59
+ export declare function sortVideoKeys(keys: readonly VideoKey[], options?: {
60
+ jumps?: boolean;
61
+ }): VideoKey[];
62
+ /** What is wrong with sorted keys' times (more than two at a time, or two when `jumps` is false), or null. */
63
+ export declare function videoKeyTimesProblem(keys: readonly VideoKey[], jumps?: boolean): string | null;
57
64
  /**
58
65
  * Keys with one set (or replaced) at `t`: a plain value becomes keys holding it and the new one.
59
66
  * For a keyframe toggle or "add key at playhead".
@@ -17,7 +17,7 @@ export interface VideoKey {
17
17
  ease?: VideoEase;
18
18
  }
19
19
  export interface VideoKeyframes {
20
- /** In time order, at least one. */
20
+ /** In time order, at least one. Two keys at one time make a jump: the value just before, then the value from there on. */
21
21
  keys: VideoKey[];
22
22
  }
23
23
  /** A number, or keyframes for it. */
@@ -52,8 +52,15 @@ export declare function retimeVideoAnimatable<T extends VideoAnimatable | undefi
52
52
  * into two curves that trace the same path, so the two halves play exactly as the whole did.
53
53
  */
54
54
  export declare function splitVideoAnimatable<T extends VideoAnimatable | undefined>(prop: T, at: number): [T, T];
55
- /** The keys sorted by time, refusing two at the same time. */
56
- export declare function sortVideoKeys(keys: readonly VideoKey[]): VideoKey[];
55
+ /**
56
+ * The keys sorted by time. Two keys may share a time (a jump, as at a camera move's cut: the
57
+ * value just before, then the value from there on) unless `jumps` is false; three never can.
58
+ */
59
+ export declare function sortVideoKeys(keys: readonly VideoKey[], options?: {
60
+ jumps?: boolean;
61
+ }): VideoKey[];
62
+ /** What is wrong with sorted keys' times (more than two at a time, or two when `jumps` is false), or null. */
63
+ export declare function videoKeyTimesProblem(keys: readonly VideoKey[], jumps?: boolean): string | null;
57
64
  /**
58
65
  * Keys with one set (or replaced) at `t`: a plain value becomes keys holding it and the new one.
59
66
  * For a keyframe toggle or "add key at playhead".
@@ -1,8 +1,8 @@
1
1
  import { type VideoAnimatable, type VideoKeyframes } from './keyframes.cjs';
2
- import { type VideoClipMask, type VideoAsset, type VideoAssetProxy, type VideoClip, type VideoClipEffect, type VideoEffectRef, type VideoEffectValues, type VideoClipAnimation, type VideoGrade, type VideoGradeLut, type VideoMarker, type VideoProject, type VideoSequence, type VideoTrack, type VideoTranscriptWord, type VideoTransform, type VideoTransition } from './types.cjs';
2
+ import { type VideoClipMask, type VideoClipCamera, type VideoAsset, type VideoAssetProxy, type VideoClip, type VideoClipEffect, type VideoEffectRef, type VideoEffectValues, type VideoClipAnimation, type VideoGrade, type VideoGradeLut, type VideoMarker, type VideoProject, type VideoSequence, type VideoTrack, type VideoTranscriptWord, type VideoTransform, type VideoTransition } from './types.cjs';
3
3
  /**
4
- * Keyframes `{ keys: [{ t, v, ease }] }`: at least one key, in strictly increasing time (clip-relative
5
- * ms), each value within `range`.
4
+ * Keyframes `{ keys: [{ t, v, ease }] }`: at least one key, in time order (clip-relative ms; two
5
+ * at one time make a jump), each value within `range`.
6
6
  */
7
7
  export declare function parseVideoKeyframes(v: unknown, path: string, { min, max }?: {
8
8
  min?: number;
@@ -35,6 +35,8 @@ export declare function parseVideoClipEffects(v: unknown, path: string): VideoCl
35
35
  export declare function parseVideoTranscriptWords(v: unknown, path: string): VideoTranscriptWord[] | undefined;
36
36
  export declare function parseVideoAsset(v: unknown, path?: string): VideoAsset;
37
37
  export declare function parseVideoAssetProxies(v: unknown, path: string): VideoAssetProxy[] | undefined;
38
+ /** A clip's camera moves `{ in, during, from, framing, base }` (see `VideoClipCamera`). */
39
+ export declare function parseVideoClipCamera(v: unknown, path: string): VideoClipCamera | undefined;
38
40
  export declare function parseVideoClip(v: unknown, path?: string): VideoClip;
39
41
  export declare function parseVideoTrack(v: unknown, path?: string): VideoTrack;
40
42
  /** A nested sequence `{ id, name, tracks }` (see `VideoSequence`). */