@pixerate/editor 0.223.9 → 0.224.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.
@@ -3,11 +3,12 @@ import {
3
3
  } from "../chunk-N4ZXW3WB.js";
4
4
  import {
5
5
  createEffectAuthoringSession
6
- } from "../chunk-WMVEYSOA.js";
6
+ } from "../chunk-WA4IS2EY.js";
7
7
  import {
8
8
  EffectRendererPool
9
9
  } from "../chunk-EHLOCVWB.js";
10
10
  import {
11
+ CAMERA_MOVE_ROLES,
11
12
  EFFECT_ERROR_CODES,
12
13
  EFFECT_KINDS,
13
14
  EFFECT_USES,
@@ -22,17 +23,21 @@ import {
22
23
  countEffectTags,
23
24
  createEffectPixels,
24
25
  createEffectToolProvider,
26
+ effectOffered,
25
27
  effectValueErrors,
26
28
  encodeEffectFloat,
27
29
  hasEffectTags,
30
+ isCameraView,
28
31
  isEffectError,
29
32
  isEffectPixels,
30
33
  linearToSrgb,
31
34
  normaliseEffectTag,
32
35
  resolveEffectValues,
36
+ sampleCameraViews,
37
+ sampleEffectCameraMove,
33
38
  searchEffectSummaries,
34
39
  srgbToLinear
35
- } from "../chunk-6G2NLMVK.js";
40
+ } from "../chunk-HKPLKISO.js";
36
41
  import "../chunk-V2ZCJRDM.js";
37
42
  import "../chunk-RNSANXPT.js";
38
43
  import {
@@ -62,6 +67,20 @@ function checkSummary(e, issues) {
62
67
  if (typeof e.loop !== "boolean") at("loop is not a boolean");
63
68
  if (e.duration !== void 0 && !(e.duration > 0)) at("duration must be a positive number of ms when present");
64
69
  if (e.backgroundOpacity !== void 0 && (typeof e.backgroundOpacity !== "string" || !e.backgroundOpacity)) at("backgroundOpacity must name a control when present");
70
+ if (e.kind === "camera") {
71
+ if (e.uses?.length !== 1 || e.uses[0] !== "camera") at('a camera effect must have exactly the use "camera"');
72
+ const m = e.camera;
73
+ if (!m) at("a camera effect needs camera: { role, durationMs }");
74
+ else {
75
+ if (!CAMERA_MOVE_ROLES.includes(m.role)) at(`camera.role "${m.role}" is not one of ${CAMERA_MOVE_ROLES.join(", ")}`);
76
+ if (!(Number.isFinite(m.durationMs) && m.durationMs >= 0)) at("camera.durationMs must be a number of ms \u2265 0");
77
+ if (m.cut !== void 0 && !(m.cut >= 0 && m.cut <= 1)) at("camera.cut must be 0\u20131 when present");
78
+ if (e.duration !== void 0 && e.duration !== m.durationMs) at(`duration ${e.duration} differs from camera.durationMs ${m.durationMs}`);
79
+ }
80
+ } else {
81
+ if (e.uses?.includes("camera")) at('only camera effects may have the use "camera"');
82
+ if (e.camera !== void 0) at("only camera effects may have camera");
83
+ }
65
84
  }
66
85
  function checkDescription(id, d, issues) {
67
86
  const at = (message) => issues.push({ effectId: id, message });
@@ -102,7 +121,8 @@ async function checkRender(provider, d, size, issues) {
102
121
  try {
103
122
  renderer = await provider.createRenderer(d.id, { width: size, height: size });
104
123
  } catch (e) {
105
- if (!(d.kind === "vector" && codeOf(e) === "unsupported")) at(`createRenderer failed: ${codeOf(e)}`);
124
+ if (!((d.kind === "vector" || d.kind === "camera") && codeOf(e) === "unsupported")) at(`createRenderer failed: ${codeOf(e)}`);
125
+ else if (!(e instanceof Error && e.message.trim())) at("createRenderer refused without saying why");
106
126
  return;
107
127
  }
108
128
  try {
@@ -118,6 +138,51 @@ async function checkRender(provider, d, size, issues) {
118
138
  const after = await rejectsWith("disposed", () => renderer.render({ time: 0, inputs }));
119
139
  if (after) at(`render after dispose ${after}`);
120
140
  }
141
+ var sameView = (a, b) => JSON.stringify(a) === JSON.stringify(b);
142
+ var near = (a, b) => ["zoom", "scrollX", "scrollY", "tilt", "turn", "roll", "perspective", "blur", "blurAngle"].every((k) => Math.abs((a[k] ?? 0) - (b[k] ?? 0)) <= 1e-6 * Math.max(1, Math.abs(a[k] ?? 0)));
143
+ var STAGE = { width: 320, height: 180 };
144
+ var WIDE = { zoom: 1, scrollX: 0, scrollY: 0 };
145
+ var CLOSE = { zoom: 2, scrollX: 0, scrollY: 0 };
146
+ async function checkCamera(provider, d, issues) {
147
+ const at = (message) => issues.push({ effectId: d.id, message });
148
+ const camera = d.camera;
149
+ const to = camera.role === "hold" ? WIDE : CLOSE;
150
+ const ms = camera.durationMs || 1e3;
151
+ const ctx = (u) => ({ from: WIDE, to, u, ms, stage: STAGE });
152
+ const views = [];
153
+ for (const u of [0, 0.25, 0.5, 0.75, 1]) {
154
+ try {
155
+ const view = provider.cameraView(d.id, ctx(u));
156
+ if (view instanceof Promise) {
157
+ at("cameraView must be synchronous; it returned a Promise");
158
+ return;
159
+ }
160
+ if (!isCameraView(view)) at(`cameraView at u ${u} is not a CameraView (zoom > 0, finite scrollX and scrollY): ${JSON.stringify(view)}`);
161
+ else views.push(view);
162
+ const again = provider.cameraView(d.id, ctx(u));
163
+ if (isCameraView(view) && !sameView(view, again)) at(`cameraView is not pure: two calls at u ${u} gave different views`);
164
+ } catch (e) {
165
+ at(`cameraView at u ${u} failed: ${codeOf(e)}`);
166
+ }
167
+ }
168
+ const bad = await rejectsWith("invalid_values", () => provider.cameraView(d.id, ctx(0.5), { __not_a_control__: 1 }));
169
+ if (bad) at(`cameraView with an unknown control ${bad}`);
170
+ if (provider.sampleCameraMove && views.length === 5) {
171
+ try {
172
+ const sample = await provider.sampleCameraMove(d.id, { from: WIDE, to, ms, stage: STAGE, fps: 10 });
173
+ const keys = sample?.keys;
174
+ if (!Array.isArray(keys) || keys.length < 2) at("sampleCameraMove returned fewer than two keys");
175
+ else {
176
+ if (keys.some((k, i) => !isCameraView(k.view) || !Number.isFinite(k.t) || i > 0 && k.t < keys[i - 1].t)) at("sampleCameraMove keys must be CameraViews in time order");
177
+ if (keys[0].t !== 0 || keys[keys.length - 1].t !== ms) at(`sampleCameraMove keys must run from 0 to ${ms} ms, got ${keys[0].t} to ${keys[keys.length - 1].t}`);
178
+ else if (!near(keys[0].view, views[0]) || !near(keys[keys.length - 1].view, views[4])) at("sampleCameraMove disagrees with cameraView at the start or end of the move");
179
+ if (camera.cut !== void 0 && sample.cut === void 0) at("sampleCameraMove gave no cut for a move that cuts");
180
+ }
181
+ } catch (e) {
182
+ at(`sampleCameraMove failed: ${codeOf(e)}`);
183
+ }
184
+ }
185
+ }
121
186
  async function checkEffectProvider(provider, options = {}) {
122
187
  const issues = [];
123
188
  const top = (message) => issues.push({ message });
@@ -129,6 +194,9 @@ async function checkEffectProvider(provider, options = {}) {
129
194
  if (caps?.supportsAuthoring !== void 0 && typeof caps.supportsAuthoring !== "boolean") top("capabilities.supportsAuthoring is not a boolean");
130
195
  if (caps?.supportsVector && typeof provider.applyVectorEffect !== "function") top("supportsVector is set but applyVectorEffect is missing");
131
196
  if (caps?.supportsAuthoring && typeof provider.openAuthoring !== "function") top("supportsAuthoring is set but openAuthoring is missing");
197
+ if (caps?.supportsCamera !== void 0 && typeof caps.supportsCamera !== "boolean") top("capabilities.supportsCamera is not a boolean");
198
+ if (caps?.supportsCamera && typeof provider.cameraView !== "function") top("supportsCamera is set but cameraView is missing");
199
+ if (provider.sampleCameraMove && typeof provider.cameraView !== "function") top("sampleCameraMove is present but cameraView is missing");
132
200
  const list = await provider.list();
133
201
  const ids = /* @__PURE__ */ new Set();
134
202
  for (const e of list) {
@@ -136,6 +204,7 @@ async function checkEffectProvider(provider, options = {}) {
136
204
  ids.add(e.id);
137
205
  checkSummary(e, issues);
138
206
  }
207
+ if (!caps?.supportsCamera && list.some((e) => e.kind === "camera")) top("the catalog lists camera effects but capabilities.supportsCamera is not set");
139
208
  const all = await provider.search("");
140
209
  if (all.length !== list.length) top(`search("") returned ${all.length} effects, list() ${list.length}`);
141
210
  const firstUse = list[0]?.uses[0];
@@ -147,6 +216,14 @@ async function checkEffectProvider(provider, options = {}) {
147
216
  if (unknown) top(`describe of an unknown id ${unknown}`);
148
217
  const unknownRenderer = await rejectsWith("unknown_effect", () => provider.createRenderer("__no_such_effect__", { width: 4, height: 4 }));
149
218
  if (unknownRenderer) top(`createRenderer of an unknown id ${unknownRenderer}`);
219
+ if (caps?.supportsCamera && provider.cameraView) {
220
+ const probe = { from: WIDE, to: CLOSE, u: 0.5, ms: 1e3, stage: STAGE };
221
+ const unknownCamera = await rejectsWith("unknown_effect", () => provider.cameraView("__no_such_effect__", probe));
222
+ if (unknownCamera) top(`cameraView of an unknown id ${unknownCamera}`);
223
+ const notCamera = list.find((e) => e.kind !== "camera");
224
+ const wrongKind = notCamera && await rejectsWith("unsupported", () => provider.cameraView(notCamera.id, probe));
225
+ if (wrongKind) issues.push({ effectId: notCamera.id, message: `cameraView of a ${notCamera.kind} effect ${wrongKind}` });
226
+ }
150
227
  const size = options.size ?? 8;
151
228
  for (const id of options.ids ?? [...ids]) {
152
229
  let d;
@@ -158,6 +235,7 @@ async function checkEffectProvider(provider, options = {}) {
158
235
  }
159
236
  checkDescription(id, d, issues);
160
237
  if (options.render !== false && d.kind !== "motion") await checkRender(provider, d, size, issues);
238
+ if (d.kind === "camera" && d.camera && caps.supportsCamera && provider.cameraView) await checkCamera(provider, d, issues);
161
239
  if ((d.kind === "vector" || d.kind === "motion") && caps.supportsVector && provider.applyVectorEffect) {
162
240
  try {
163
241
  const m = await provider.applyVectorEffect(id, '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect id="r" width="10" height="10"/></svg>');
@@ -172,7 +250,7 @@ async function checkEffectProvider(provider, options = {}) {
172
250
  }
173
251
  if (caps?.supportsAuthoring && options.authoring !== false && typeof provider.openAuthoring === "function") {
174
252
  const targetEffect = (await Promise.all((options.ids ?? [...ids]).map((id) => provider.describe(id)))).find(
175
- (d) => d.kind !== "motion" && d.kind !== "vector"
253
+ (d) => d.kind !== "motion" && d.kind !== "vector" && d.kind !== "camera"
176
254
  );
177
255
  if (targetEffect) {
178
256
  try {
@@ -394,6 +472,71 @@ var DESCRIPTIONS = [
394
472
  inputs: []
395
473
  }
396
474
  ];
475
+ var CAMERA_DESCRIPTIONS = [
476
+ {
477
+ id: "fake.glide",
478
+ name: "Glide",
479
+ kind: "camera",
480
+ category: "camera",
481
+ uses: ["camera"],
482
+ description: "Moves the camera smoothly from one framing to the next.",
483
+ tags: ["move", "smooth"],
484
+ transparent: false,
485
+ loop: false,
486
+ duration: 800,
487
+ camera: { role: "transition", durationMs: 800 },
488
+ controls: {
489
+ type: "object",
490
+ additionalProperties: false,
491
+ properties: { curve: { type: "string", enum: ["linear", "smooth"], description: "Speed curve: linear, or smooth (ease in and out). Default smooth." } }
492
+ },
493
+ defaults: { curve: "smooth" },
494
+ presets: [],
495
+ inputs: []
496
+ },
497
+ {
498
+ id: "fake.whip",
499
+ name: "Whip pan",
500
+ kind: "camera",
501
+ category: "camera",
502
+ uses: ["camera"],
503
+ description: "Whips the camera away from one framing, cuts halfway in a blur, and lands on the next.",
504
+ tags: ["move", "fast", "cut"],
505
+ transparent: false,
506
+ loop: false,
507
+ duration: 400,
508
+ camera: { role: "transition", durationMs: 400, cut: 0.5 },
509
+ controls: {
510
+ type: "object",
511
+ additionalProperties: false,
512
+ properties: { blur: { type: "number", minimum: 0, maximum: 100, description: "Motion blur at the cut, in stage pixels. Default 40." } }
513
+ },
514
+ defaults: { blur: 40 },
515
+ presets: [{ name: "Clean", values: { blur: 0 } }],
516
+ inputs: []
517
+ },
518
+ {
519
+ id: "fake.drift",
520
+ name: "Drift",
521
+ kind: "camera",
522
+ category: "camera",
523
+ uses: ["camera"],
524
+ description: "Zooms slowly into the middle of the framing while the shot holds.",
525
+ tags: ["ken burns", "slow"],
526
+ transparent: false,
527
+ loop: false,
528
+ duration: 4e3,
529
+ camera: { role: "hold", durationMs: 4e3 },
530
+ controls: {
531
+ type: "object",
532
+ additionalProperties: false,
533
+ properties: { scale: { type: "number", minimum: 1, maximum: 2, description: "Zoom at the end of the hold, as a multiple of the framing's zoom. Default 1.15." } }
534
+ },
535
+ defaults: { scale: 1.15 },
536
+ presets: [],
537
+ inputs: []
538
+ }
539
+ ];
397
540
  var summaryOf = ({ controls: _c, defaults: _d, presets: _p, inputs: _i, ...summary }) => summary;
398
541
  var hexToRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
399
542
  var smooth = (t) => t * t * (3 - 2 * t);
@@ -478,6 +621,35 @@ function renderPixels(d, v, request, width, height) {
478
621
  }
479
622
  return out;
480
623
  }
624
+ var lerp = (a, b, t) => a + (b - a) * t;
625
+ var viewAt = (cx, cy, zoom, ctx) => ({ zoom, scrollX: ctx.stage.width / (2 * zoom) - cx, scrollY: ctx.stage.height / (2 * zoom) - cy });
626
+ var centreOf = (v, ctx) => [ctx.stage.width / (2 * v.zoom) - v.scrollX, ctx.stage.height / (2 * v.zoom) - v.scrollY];
627
+ function cameraViewOf(d, ctx, v) {
628
+ const { from, to, u } = ctx;
629
+ const [x0, y0] = centreOf(from, ctx);
630
+ const [x1, y1] = centreOf(to, ctx);
631
+ switch (d.id) {
632
+ case "fake.glide": {
633
+ const k = v.curve === "smooth" ? smooth(u) : u;
634
+ return viewAt(lerp(x0, x1, k), lerp(y0, y1, k), lerp(from.zoom, to.zoom, k), ctx);
635
+ }
636
+ case "fake.whip": {
637
+ const out = u < 0.5;
638
+ const at = out ? from : to;
639
+ const off = out ? Math.pow(u * 2, 3) : 1 - (1 - Math.pow(1 - (u - 0.5) * 2, 3));
640
+ const dist = Math.hypot(x1 - x0, y1 - y0);
641
+ const [dx, dy] = dist > 1e-6 ? [(x1 - x0) / dist, (y1 - y0) / dist] : [1, 0];
642
+ const reach = ctx.stage.width / at.zoom * off * (out ? 1 : -1);
643
+ const [cx, cy] = centreOf(at, ctx);
644
+ const blur = v.blur * off;
645
+ return { ...viewAt(cx + dx * reach, cy + dy * reach, at.zoom, ctx), ...blur > 0 ? { blur, blurAngle: Math.atan2(dy, dx) * 180 / Math.PI } : {} };
646
+ }
647
+ case "fake.drift":
648
+ return viewAt(x0, y0, from.zoom * lerp(1, v.scale, smooth(u)), ctx);
649
+ default:
650
+ throw new EffectUnsupportedError(`"${d.id}" is a ${d.kind} effect, not a camera move.`, d.id);
651
+ }
652
+ }
481
653
  function toFloat(p) {
482
654
  const f = new Float32Array(p.data.length);
483
655
  for (let i = 0; i < f.length; i += 4) {
@@ -508,16 +680,24 @@ function createFakeEffectProvider(options = {}) {
508
680
  supportsHostImages: false,
509
681
  supportsVector: true,
510
682
  supportsAuthoring,
683
+ ...options.camera ? { supportsCamera: true } : {},
511
684
  maxSize: 8192,
512
685
  ...options.capabilities
513
686
  };
514
- const byId = new Map(DESCRIPTIONS.map((d) => [d.id, d]));
687
+ const all = options.camera ? [...DESCRIPTIONS, ...CAMERA_DESCRIPTIONS] : DESCRIPTIONS;
688
+ const byId = new Map(all.map((d) => [d.id, d]));
515
689
  const get = (id) => {
516
690
  const d = byId.get(id);
517
691
  if (!d) throw new UnknownEffectError(id, "the fake provider");
518
692
  return d;
519
693
  };
520
- const summaries = DESCRIPTIONS.map(summaryOf);
694
+ const summaries = all.map(summaryOf);
695
+ const camera = (id, ctx, values) => {
696
+ const d = get(id);
697
+ if (d.kind !== "camera") throw new EffectUnsupportedError(`"${id}" is a ${d.kind} effect, not a camera move.`, id);
698
+ if (!(ctx.u >= 0 && ctx.u <= 1)) throw new EffectUnsupportedError(`u must be 0\u20131 through the move, got ${ctx.u}.`, id);
699
+ return cameraViewOf(d, ctx, resolveEffectValues(d, values));
700
+ };
521
701
  const provider = {
522
702
  id: "fake",
523
703
  name: "Fake effects",
@@ -528,6 +708,7 @@ function createFakeEffectProvider(options = {}) {
528
708
  describe: (id) => structuredClone(get(id)),
529
709
  createRenderer(id, ro) {
530
710
  const d = get(id);
711
+ if (d.kind === "camera") throw new EffectUnsupportedError(`"${id}" is a camera move: it moves a scene's camera and draws no pixels. Use cameraView.`, id);
531
712
  if (d.kind !== "shader") throw new EffectUnsupportedError(`"${id}" is a ${d.kind} effect; use applyVectorEffect.`, id);
532
713
  const { width, height } = ro;
533
714
  const depth = ro.depth ?? 8;
@@ -578,6 +759,14 @@ function createFakeEffectProvider(options = {}) {
578
759
  loop: false
579
760
  };
580
761
  },
762
+ ...options.camera ? {
763
+ cameraView: camera,
764
+ sampleCameraMove: (id, request) => {
765
+ const d = get(id);
766
+ const { from, to, ms, stage, fps, values } = request;
767
+ return sampleCameraViews((u) => camera(id, { from, to, u, ms, stage }, values), { ms, fps, cut: d.camera?.cut });
768
+ }
769
+ } : {},
581
770
  ...supportsAuthoring ? {
582
771
  openAuthoring: (from) => createEffectAuthoringSession(from, { provider })
583
772
  } : {}
@@ -593,7 +782,8 @@ var EFFECT_USE_LABELS = {
593
782
  overlay: "Overlay",
594
783
  title: "Title",
595
784
  motion: "Motion",
596
- interactive: "Interactive"
785
+ interactive: "Interactive",
786
+ camera: "Camera"
597
787
  };
598
788
  var HEX2 = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
599
789
  var typeOf = (s) => Array.isArray(s.type) ? s.type.find((t) => t !== "null") : s.type;
@@ -1102,6 +1292,7 @@ function createEffectAuthoringToolProvider(host, options) {
1102
1292
  };
1103
1293
  }
1104
1294
  export {
1295
+ CAMERA_MOVE_ROLES,
1105
1296
  EFFECT_ERROR_CODES,
1106
1297
  EFFECT_KINDS,
1107
1298
  EFFECT_USES,
@@ -1125,6 +1316,7 @@ export {
1125
1316
  effectControlField,
1126
1317
  effectFieldValue,
1127
1318
  effectFontFamilies,
1319
+ effectOffered,
1128
1320
  effectParameterFields,
1129
1321
  effectSampleInputs,
1130
1322
  effectSamplePicture,
@@ -1132,6 +1324,7 @@ export {
1132
1324
  encodeEffectFloat,
1133
1325
  groupEffectsByCategory,
1134
1326
  hasEffectTags,
1327
+ isCameraView,
1135
1328
  isEffectError,
1136
1329
  isEffectFontControl,
1137
1330
  isEffectPixels,
@@ -1139,6 +1332,8 @@ export {
1139
1332
  normaliseEffectTag,
1140
1333
  renderEffectThumbnail,
1141
1334
  resolveEffectValues,
1335
+ sampleCameraViews,
1336
+ sampleEffectCameraMove,
1142
1337
  searchEffectSummaries,
1143
1338
  srgbToLinear
1144
1339
  };
@@ -9,8 +9,11 @@
9
9
  import type { JsonSchema } from '../tools/schema.cjs';
10
10
  import type { FlairSvgAnimation } from '../animation-editor/flair.cjs';
11
11
  import type { EffectAuthoringSession, EffectAuthoringSource } from './authoring.cjs';
12
- /** How an effect is drawn: a pixel shader, an animated vector picture, or motion applied to someone else's vector art. */
13
- export type EffectKind = 'shader' | 'vector' | 'motion';
12
+ /**
13
+ * How an effect is drawn: a pixel shader, an animated vector picture, motion applied to someone
14
+ * else's vector art, or a camera move, which moves a scene's camera and draws no pixels.
15
+ */
16
+ export type EffectKind = 'shader' | 'vector' | 'motion' | 'camera';
14
17
  /**
15
18
  * What an effect is for. An effect may have several uses.
16
19
  *
@@ -21,10 +24,33 @@ export type EffectKind = 'shader' | 'vector' | 'motion';
21
24
  * - `title`: draws type or a graphic meant as a title.
22
25
  * - `motion`: animates vector art (`applyVectorEffect`).
23
26
  * - `interactive`: reacts to pointer, scroll or audio input at run time.
27
+ * - `camera`: moves a scene's camera (`cameraView`). Only `camera` effects have it, and they have
28
+ * no other use, so editors that draw pixels never offer them.
24
29
  */
25
- export type EffectUse = 'filter' | 'transition' | 'generator' | 'overlay' | 'title' | 'motion' | 'interactive';
30
+ export type EffectUse = 'filter' | 'transition' | 'generator' | 'overlay' | 'title' | 'motion' | 'interactive' | 'camera';
26
31
  export declare const EFFECT_KINDS: readonly EffectKind[];
27
32
  export declare const EFFECT_USES: readonly EffectUse[];
33
+ /**
34
+ * What a camera move does to a shot.
35
+ *
36
+ * - `transition`: moves the camera from one framing (`from`) to the next (`to`): a glide, a whip
37
+ * pan, a punch zoom, a cut.
38
+ * - `hold`: moves the camera within one framing while the shot holds (`from` and `to` are the
39
+ * same): a Ken Burns drift, a crash zoom.
40
+ */
41
+ export type CameraMoveRole = 'transition' | 'hold';
42
+ export declare const CAMERA_MOVE_ROLES: readonly CameraMoveRole[];
43
+ /** How a `camera` effect moves (`EffectSummary.camera`). */
44
+ export interface EffectCameraMove {
45
+ role: CameraMoveRole;
46
+ /** Length of the move at its default values (ms); 0 for a cut. A hold plays across the shot's hold instead. */
47
+ durationMs: number;
48
+ /**
49
+ * Where the view jumps, 0–1 through the move (0.5 for a whip pan or punch zoom that cuts
50
+ * halfway). Keyframes either side of it must not be tweened across. Absent for a smooth move.
51
+ */
52
+ cut?: number;
53
+ }
28
54
  /** One entry of a provider's catalog. */
29
55
  export interface EffectSummary {
30
56
  /** Stable id, saved in documents. Never reused for a different effect. */
@@ -48,6 +74,8 @@ export interface EffectSummary {
48
74
  * background of its own to fade (an overlay is already transparent).
49
75
  */
50
76
  backgroundOpacity?: string;
77
+ /** How a `camera` effect moves. Present exactly when `kind` is `camera`. */
78
+ camera?: EffectCameraMove;
51
79
  }
52
80
  export interface EffectSearchOptions {
53
81
  kind?: EffectKind;
@@ -161,6 +189,77 @@ export type EffectVectorTarget = string | {
161
189
  svg: string;
162
190
  nodeIds?: readonly string[];
163
191
  };
192
+ /**
193
+ * Where a scene's camera looks (the shape of Flair's `View`). The scene is drawn at
194
+ * (point + scroll) × zoom, then turned by `tilt`, `turn` and `roll` round the middle of the stage
195
+ * and seen in perspective, then blurred by `blur` while the camera moves. Positions are in the
196
+ * scene's own units, so a framing is `stage.width / zoom` wide.
197
+ */
198
+ export interface CameraView {
199
+ /** Scale of the scene on the stage; > 0. */
200
+ zoom: number;
201
+ /** Scene offset before scaling, in scene units. */
202
+ scrollX: number;
203
+ scrollY: number;
204
+ /** Tips the scene back round its horizontal axis (CSS `rotateX`), degrees, −75 to 75. Default 0. */
205
+ tilt?: number;
206
+ /** Swings the scene round its vertical axis (CSS `rotateY`), degrees, −75 to 75. Default 0. */
207
+ turn?: number;
208
+ /** Rolls the scene round the line of sight (CSS `rotateZ`), degrees clockwise, −180 to 180. Default 0. */
209
+ roll?: number;
210
+ /** Distance of the lens from the scene as a multiple of the stage width; smaller is more dramatic. Default the provider's own. */
211
+ perspective?: number;
212
+ /** Motion blur in stage pixels while the camera moves; 0 or absent when still. */
213
+ blur?: number;
214
+ /** Direction the blur streaks, degrees on screen (0 horizontal, 90 vertical); absent when it blurs every way (a zoom or a turn). */
215
+ blurAngle?: number;
216
+ }
217
+ /** The stage a camera looks at, in pixels. */
218
+ export interface CameraStage {
219
+ width: number;
220
+ height: number;
221
+ }
222
+ /** Where a camera move is: what `cameraView` takes. */
223
+ export interface CameraMoveContext {
224
+ /** The framing the move leaves; for a hold, the framing it moves within (the same as `to`). */
225
+ from: CameraView;
226
+ /** The framing the move arrives at. */
227
+ to: CameraView;
228
+ /** How far through the move, 0–1. */
229
+ u: number;
230
+ /** Length of the whole move (ms): its `durationMs`, the editor's override, or for a hold the shot's hold. */
231
+ ms: number;
232
+ stage: CameraStage;
233
+ }
234
+ /** What `sampleCameraMove` takes: the move's framings, length and stage, and how often to key it. */
235
+ export interface CameraMoveSampleRequest {
236
+ from: CameraView;
237
+ to: CameraView;
238
+ /** Length of the move (ms). */
239
+ ms: number;
240
+ stage: CameraStage;
241
+ /** Keys per second; > 0. */
242
+ fps: number;
243
+ /** Control values; missing controls take their defaults. */
244
+ values?: EffectValues;
245
+ }
246
+ /** One key of a sampled camera move. */
247
+ export interface CameraKeyframe {
248
+ /** Ms from the start of the move. */
249
+ t: number;
250
+ view: CameraView;
251
+ }
252
+ /**
253
+ * A camera move as keyframes, so an editor can bake it without calling `cameraView` each frame.
254
+ * Keys are in time order from `t` 0 to `ms`, one per frame. At a cut two keys share the cut's
255
+ * `t`: the view just before it, then the view after; tweening between consecutive keys never
256
+ * crosses the jump.
257
+ */
258
+ export interface CameraMoveSample {
259
+ keys: readonly CameraKeyframe[];
260
+ /** Ms of the cut, when the move has one. */
261
+ cut?: number;
262
+ }
164
263
  /** What a provider can do, so editors can degrade cleanly. */
165
264
  export interface EffectProviderCapabilities {
166
265
  /** Renders off the main thread. */
@@ -177,6 +276,8 @@ export interface EffectProviderCapabilities {
177
276
  maxSize?: number;
178
277
  /** Implements `openAuthoring`: effects can be edited, not only used. */
179
278
  supportsAuthoring?: boolean;
279
+ /** Lists `camera` effects and implements `cameraView` (and maybe `sampleCameraMove`). */
280
+ supportsCamera?: boolean;
180
281
  }
181
282
  export type MaybePromise<T> = T | Promise<T>;
182
283
  /**
@@ -196,7 +297,10 @@ export interface EffectProvider {
196
297
  /** Effects matching every word of `query` (in name, id, category, tags or description), best first. */
197
298
  search(query: string, options?: EffectSearchOptions): MaybePromise<readonly EffectSummary[]>;
198
299
  describe(id: string): MaybePromise<EffectDescription>;
199
- /** A pixel renderer. A `vector` effect may refuse with `EffectUnsupportedError` where it cannot lay out SVG (a Worker). */
300
+ /**
301
+ * A pixel renderer. A `vector` effect may refuse with `EffectUnsupportedError` where it cannot
302
+ * lay out SVG (a Worker); a `camera` effect draws no pixels and may refuse with one saying so.
303
+ */
200
304
  createRenderer(id: string, options: EffectRendererOptions): MaybePromise<EffectRenderer>;
201
305
  /**
202
306
  * Vector and motion effects as an animated SVG. Present when `capabilities.supportsVector`. A
@@ -210,4 +314,17 @@ export interface EffectProvider {
210
314
  * `UnknownEffectError`; an effect the provider cannot edit throws `EffectUnsupportedError`.
211
315
  */
212
316
  openAuthoring?(from: EffectAuthoringSource): MaybePromise<EffectAuthoringSession>;
317
+ /**
318
+ * Where a camera move puts the camera `ctx.u` of the way through it, with its motion blur.
319
+ * Present when `capabilities.supportsCamera`. Synchronous and pure: the same id, context and
320
+ * values always give the same view, so editors may call it per frame or cache it. Unknown ids
321
+ * throw `UnknownEffectError`, invalid values `EffectControlError`, and an effect that is not a
322
+ * camera move (or a `u` outside 0–1) `EffectUnsupportedError`.
323
+ */
324
+ cameraView?(id: string, ctx: CameraMoveContext, values?: EffectValues): CameraView;
325
+ /**
326
+ * A camera move as keyframes (see `CameraMoveSample`). Optional even with `supportsCamera`;
327
+ * `sampleEffectCameraMove` falls back to `cameraView` per frame. Errors as for `cameraView`.
328
+ */
329
+ sampleCameraMove?(id: string, request: CameraMoveSampleRequest): MaybePromise<CameraMoveSample>;
213
330
  }