@vestra-player/sdk 1.5.38 → 1.5.40

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.
@@ -0,0 +1,106 @@
1
+ import { LOCALES } from "../i18n.js";
2
+ import { THEMES } from "./appearance.js";
3
+ const PHASES = [
4
+ "empty",
5
+ "loading",
6
+ "ready",
7
+ "ended",
8
+ "error",
9
+ ];
10
+ const CONTROLS = [
11
+ "pause",
12
+ "seek",
13
+ "rate",
14
+ "restart",
15
+ "close",
16
+ "snapshot",
17
+ "next",
18
+ "previous",
19
+ ];
20
+ class ConditionImpl {
21
+ #json;
22
+ constructor(json) {
23
+ this.#json = json;
24
+ Object.freeze(this);
25
+ }
26
+ and(...others) {
27
+ return new ConditionImpl({
28
+ all: [this.#json, ...others.map(conditionJson)],
29
+ });
30
+ }
31
+ or(...others) {
32
+ return new ConditionImpl({
33
+ any: [this.#json, ...others.map(conditionJson)],
34
+ });
35
+ }
36
+ not() {
37
+ return new ConditionImpl({ not: this.#json });
38
+ }
39
+ toJSON() {
40
+ return this.#json;
41
+ }
42
+ static json(condition) {
43
+ return condition.#json;
44
+ }
45
+ }
46
+ /** Whether `value` is a condition made by `plugin.when`. */
47
+ export const isCondition = (value) => value instanceof ConditionImpl;
48
+ /** A condition's wire form; throws for anything else. */
49
+ export function conditionJson(condition) {
50
+ if (!(condition instanceof ConditionImpl))
51
+ throw new TypeError("a condition comes from plugin.when");
52
+ return ConditionImpl.json(condition);
53
+ }
54
+ const make = (json) => new ConditionImpl(json);
55
+ /** The one `plugin.when` (conditions hold no plugin state). */
56
+ export const WHEN = Object.freeze({
57
+ open: make({ not: { phase: ["empty"] } }),
58
+ ready: make({ phase: ["ready"] }),
59
+ playing: make({ all: [{ phase: ["ready"] }, { paused: false }] }),
60
+ paused: make({ all: [{ phase: ["ready"] }, { paused: true }] }),
61
+ stream: make({ kind: "stream" }),
62
+ fullscreen: make({ fullscreen: true }),
63
+ phase(...phases) {
64
+ if (!phases.length || phases.some((phase) => !PHASES.includes(phase)))
65
+ throw new TypeError(`phase is one or more of ${PHASES.join(", ")}`);
66
+ return make({ phase: [...phases] });
67
+ },
68
+ can(control) {
69
+ if (!CONTROLS.includes(control))
70
+ throw new TypeError(`can is one of ${CONTROLS.join(", ")}`);
71
+ return make({ can: control });
72
+ },
73
+ theme(theme) {
74
+ if (!THEMES.includes(theme))
75
+ throw new TypeError(`theme is ${THEMES.map((t) => `"${t}"`).join(" or ")}`);
76
+ return make({ theme });
77
+ },
78
+ language(language) {
79
+ if (language !== "system" &&
80
+ !LOCALES.includes(language))
81
+ throw new TypeError(`language is "system" or one of ${LOCALES.map((l) => `"${l}"`).join(", ")}`);
82
+ return make({ language });
83
+ },
84
+ });
85
+ /** Evaluate a wire condition as the player does (for the test host). */
86
+ export function evaluate(json, facts) {
87
+ if ("phase" in json)
88
+ return json.phase.includes(facts.phase);
89
+ if ("paused" in json)
90
+ return facts.paused === json.paused;
91
+ if ("kind" in json)
92
+ return facts.kind === json.kind;
93
+ if ("can" in json)
94
+ return facts.can[json.can] === true;
95
+ if ("fullscreen" in json)
96
+ return facts.fullscreen === json.fullscreen;
97
+ if ("theme" in json)
98
+ return facts.theme === json.theme;
99
+ if ("language" in json)
100
+ return facts.language === json.language;
101
+ if ("all" in json)
102
+ return json.all.every((each) => evaluate(each, facts));
103
+ if ("any" in json)
104
+ return json.any.some((each) => evaluate(each, facts));
105
+ return !evaluate(json.not, facts);
106
+ }
@@ -1,7 +1,3 @@
1
- /**
2
- * Processing chains for the player's video and audio filters. Applied means
3
- * the backend accepted the chain, not that a frame with it is visible yet.
4
- */
5
1
  import type { Transport } from "./wire.ts";
6
2
  /**
7
3
  * One node of a {@link VideoFilter} chain, by `kind`. Values outside the
@@ -165,7 +161,114 @@ export type VideoNode =
165
161
  kind: "frameBlend";
166
162
  /** Output frame rate, integer 24–120. */
167
163
  fps: number;
164
+ }
165
+ /**
166
+ * A convolution matrix of your own per plane (FFmpeg `convolution`, SDK
167
+ * 1.5.39, player 0.5.20): sharpen, blur, emboss or detect edges with a
168
+ * 3×3, 5×5 or 7×7 kernel, or a row or column kernel. `planes[i]` is
169
+ * plane `i` of the decoded picture: for the usual YUV video 0 is luma
170
+ * (brightness), 1 and 2 are the colour planes, 3 is alpha. A missing
171
+ * entry or `null` leaves that plane as it is; at least one entry has a
172
+ * matrix. Players before 0.5.20 refuse the kind with `invalid`.
173
+ */
174
+ | {
175
+ /** The node type. */
176
+ kind: "convolution";
177
+ /** 1–4 entries, by plane; `null` passes the plane through. */
178
+ planes: (ConvolutionPlane | null)[];
179
+ }
180
+ /**
181
+ * Recolour the picture with a 3D look-up table (FFmpeg `lut3d`, SDK
182
+ * 1.5.39, player 0.5.20): a `.cube` file the plugin ships in its package
183
+ * or wrote to its {@link Plugin.dataDirectory}. The player reads the file
184
+ * when the chain is set (and again for each new source, snapshot or
185
+ * export), so it must stay there while the chain uses it. The table is
186
+ * applied to RGB; the player converts the picture to RGB and back.
187
+ * Players before 0.5.20 refuse the kind with `invalid`.
188
+ */
189
+ | {
190
+ /** The node type. */
191
+ kind: "lut3d";
192
+ /**
193
+ * Absolute path of a `.cube` file (`LUT_3D_SIZE` 2–65, at most
194
+ * 16 MiB) inside the plugin's package directory or data directory
195
+ * after following links; a path with `[` or `]` is refused. Outside
196
+ * those: `invalid`; a file that does not exist: `missing`.
197
+ */
198
+ file: string;
199
+ /** How colours between the table's points are computed; `tetrahedral` is FFmpeg's default. */
200
+ interpolation: "nearest" | "trilinear" | "tetrahedral" | "pyramid" | "prism";
201
+ };
202
+ /**
203
+ * One plane's kernel of a `convolution` {@link VideoNode}: each output
204
+ * sample is `(sum of matrix × neighbourhood) × rdiv + bias`, clipped to the
205
+ * sample range.
206
+ *
207
+ * @since 1.5.39
208
+ * @category Processing
209
+ */
210
+ export type ConvolutionPlane = {
211
+ /**
212
+ * `square`: `matrix` is a 3×3, 5×5 or 7×7 kernel, row by row (9, 25 or
213
+ * 49 entries); `row` / `column`: a horizontal / vertical kernel with an
214
+ * odd number of entries up to 49.
215
+ */
216
+ mode: "square" | "row" | "column";
217
+ /** Integers −1024–1024 (FFmpeg reads whole numbers only). */
218
+ matrix: number[];
219
+ /**
220
+ * Multiplier 0–256; 0 divides by the sum of the matrix (by 1 when it
221
+ * sums to 0), which keeps the brightness of a blur.
222
+ */
223
+ rdiv: number;
224
+ /** Added after the multiplication, 0–65535 in the plane's sample units (128 is mid grey for 8-bit video). */
225
+ bias: number;
168
226
  };
227
+ /**
228
+ * The player's bounds for the video primitives of SDK 1.5.39 (player
229
+ * 0.5.20): `convolution` and `lut3d` {@link VideoNode}s and the shader
230
+ * channel (`plugin.video.shaders()`). Values beyond them fail with
231
+ * `invalid`; more shaders than `shaders` fail with `limit`.
232
+ *
233
+ * @example Refuse a shader list the player would not take
234
+ * The plugin checks a shader list against the player's limit before it
235
+ * sets it, and says so on the OSD instead of waiting for a `limit` failure.
236
+ * The limit counts every plugin's shaders, so the player still decides.
237
+ * ```ts
238
+ * import type { Plugin } from "@vestra-player/sdk";
239
+ * declare const plugin: Plugin;
240
+ * declare const files: string[];
241
+ * // ---cut---
242
+ * import { VIDEO_PROCESSING_LIMITS } from "@vestra-player/sdk/testing";
243
+ *
244
+ * if (files.length > VIDEO_PROCESSING_LIMITS.shaders)
245
+ * void plugin.osd.show("Too many shaders");
246
+ * else plugin.video.shaders().set(files);
247
+ * ```
248
+ *
249
+ * @since 1.5.39
250
+ * @category Testing
251
+ */
252
+ export declare const VIDEO_PROCESSING_LIMITS: Readonly<{
253
+ /** Shaders across every plugin's `plugin.video.shaders()` handles (16). */
254
+ shaders: 16;
255
+ /** Bytes of one shader file (16 MiB). */
256
+ shaderBytes: number;
257
+ /** Bytes of a shader handle's id (128). */
258
+ shaderIdBytes: 128;
259
+ /** Bytes of one `lut3d` `.cube` file (16 MiB). */
260
+ lutBytes: number;
261
+ /** Largest `LUT_3D_SIZE` of a `lut3d` file, points per axis (65). */
262
+ lutSize: 65;
263
+ /** Bytes of a `lut3d` file path (4096). */
264
+ lutPathBytes: 4096;
265
+ /** Largest magnitude of a `convolution` matrix entry (1024). */
266
+ matrixValue: 1024;
267
+ /** Largest `convolution` `rdiv` (256). */
268
+ rdiv: 256;
269
+ /** Largest `convolution` `bias` (65535). */
270
+ bias: 65535;
271
+ }>;
169
272
  /**
170
273
  * One node of an {@link AudioFilter} chain, by `kind`. Values outside the
171
274
  * ranges fail with `invalid`; "live" fields change in place while playing.
@@ -236,6 +339,17 @@ export type AudioNode =
236
339
  /** `stereo` leaves it unchanged. */
237
340
  mode: "stereo" | "left" | "right" | "mono" | "swap";
238
341
  };
342
+ /**
343
+ * Whether `text` has a control character as Rust's `char::is_control`
344
+ * sees one (C0, DEL and C1: U+0000–U+001F, U+007F–U+009F).
345
+ */
346
+ export declare function hasControl(text: string): boolean;
347
+ /**
348
+ * The form of a `lut3d` path (the player's `effects.rs lut_path`); where
349
+ * the file is and what it holds the player checks when the request arrives
350
+ * (the test host too, `TestHost.fileRoots`).
351
+ */
352
+ export declare function lutPathProblem(file: unknown): string | null;
239
353
  /**
240
354
  * Why `nodes` would be refused (`null` when valid): unknown kinds or fields,
241
355
  * values out of range, more than 64 nodes. Used by the test host.
@@ -1,3 +1,53 @@
1
+ /**
2
+ * Processing chains for the player's video and audio filters. Applied means
3
+ * the backend accepted the chain, not that a frame with it is visible yet.
4
+ */
5
+ import { isAbsolute } from "node:path";
6
+ /**
7
+ * The player's bounds for the video primitives of SDK 1.5.39 (player
8
+ * 0.5.20): `convolution` and `lut3d` {@link VideoNode}s and the shader
9
+ * channel (`plugin.video.shaders()`). Values beyond them fail with
10
+ * `invalid`; more shaders than `shaders` fail with `limit`.
11
+ *
12
+ * @example Refuse a shader list the player would not take
13
+ * The plugin checks a shader list against the player's limit before it
14
+ * sets it, and says so on the OSD instead of waiting for a `limit` failure.
15
+ * The limit counts every plugin's shaders, so the player still decides.
16
+ * ```ts
17
+ * import type { Plugin } from "@vestra-player/sdk";
18
+ * declare const plugin: Plugin;
19
+ * declare const files: string[];
20
+ * // ---cut---
21
+ * import { VIDEO_PROCESSING_LIMITS } from "@vestra-player/sdk/testing";
22
+ *
23
+ * if (files.length > VIDEO_PROCESSING_LIMITS.shaders)
24
+ * void plugin.osd.show("Too many shaders");
25
+ * else plugin.video.shaders().set(files);
26
+ * ```
27
+ *
28
+ * @since 1.5.39
29
+ * @category Testing
30
+ */
31
+ export const VIDEO_PROCESSING_LIMITS = Object.freeze({
32
+ /** Shaders across every plugin's `plugin.video.shaders()` handles (16). */
33
+ shaders: 16,
34
+ /** Bytes of one shader file (16 MiB). */
35
+ shaderBytes: 16 * 1024 * 1024,
36
+ /** Bytes of a shader handle's id (128). */
37
+ shaderIdBytes: 128,
38
+ /** Bytes of one `lut3d` `.cube` file (16 MiB). */
39
+ lutBytes: 16 * 1024 * 1024,
40
+ /** Largest `LUT_3D_SIZE` of a `lut3d` file, points per axis (65). */
41
+ lutSize: 65,
42
+ /** Bytes of a `lut3d` file path (4096). */
43
+ lutPathBytes: 4096,
44
+ /** Largest magnitude of a `convolution` matrix entry (1024). */
45
+ matrixValue: 1024,
46
+ /** Largest `convolution` `rdiv` (256). */
47
+ rdiv: 256,
48
+ /** Largest `convolution` `bias` (65535). */
49
+ bias: 65535,
50
+ });
1
51
  const BOOL = [true, false];
2
52
  /** Value ranges, the same as the host's `src/host/src/playback/effects.rs` (the host is authoritative). */
3
53
  const RULES = {
@@ -25,6 +75,12 @@ const RULES = {
25
75
  },
26
76
  reframe: { mode: ["crop", "pad"], aspect: [0.25, 4], x: [-1, 1], y: [-1, 1] },
27
77
  frameBlend: { fps: [24, 120] },
78
+ // Checked by `convolutionProblem` / `lutProblem` below.
79
+ convolution: { planes: [] },
80
+ lut3d: {
81
+ file: [],
82
+ interpolation: ["nearest", "trilinear", "tetrahedral", "pyramid", "prism"],
83
+ },
28
84
  volume: { gainDb: [-60, 0] },
29
85
  equalizer: { frequency: [20, 20000], q: [1], gainDb: [-6, 6] },
30
86
  treble: { frequency: [3000], slope: [0.5], gainDb: [0, 6] },
@@ -43,7 +99,77 @@ const VIDEO = new Set([
43
99
  "deinterlace",
44
100
  "reframe",
45
101
  "frameBlend",
102
+ "convolution",
103
+ "lut3d",
46
104
  ]);
105
+ /** `convolution` nodes: the player's rules (`effects.rs ConvolutionPlane`). */
106
+ function convolutionProblem(planes) {
107
+ if (!Array.isArray(planes) || planes.length < 1 || planes.length > 4)
108
+ return "convolution planes must have 1 to 4 entries";
109
+ if (planes.every((plane) => plane === null))
110
+ return "convolution needs at least one plane with a matrix";
111
+ const limits = VIDEO_PROCESSING_LIMITS;
112
+ for (const [index, plane] of planes.entries()) {
113
+ if (plane === null)
114
+ continue;
115
+ if (typeof plane !== "object" || Array.isArray(plane))
116
+ return `convolution plane ${index} must be an object or null`;
117
+ const fields = Object.keys(plane);
118
+ const unknown = fields.find((field) => !["mode", "matrix", "rdiv", "bias"].includes(field));
119
+ if (unknown)
120
+ return `convolution plane ${index}: unknown field ${unknown}`;
121
+ const { mode, matrix, rdiv, bias } = plane;
122
+ if (!["square", "row", "column"].includes(mode))
123
+ return `convolution plane ${index}: mode must be square, row or column`;
124
+ if (!Array.isArray(matrix))
125
+ return `convolution plane ${index}: matrix must be an array of integers`;
126
+ const sized = mode === "square"
127
+ ? [9, 25, 49].includes(matrix.length)
128
+ : matrix.length % 2 === 1 && matrix.length <= 49;
129
+ if (!sized)
130
+ return `convolution plane ${index}: a square matrix has 9, 25 or 49 entries, a row or column an odd number up to 49 (got ${matrix.length})`;
131
+ if (matrix.some((value) => !Number.isInteger(value) || Math.abs(value) > limits.matrixValue))
132
+ return `convolution plane ${index}: matrix entries must be integers in -${limits.matrixValue}..${limits.matrixValue}`;
133
+ const outside = (value, max) => typeof value !== "number" ||
134
+ !Number.isFinite(value) ||
135
+ value < 0 ||
136
+ value > max;
137
+ if (outside(rdiv, limits.rdiv))
138
+ return `convolution plane ${index} rdiv must be finite in 0..=${limits.rdiv}`;
139
+ if (outside(bias, limits.bias))
140
+ return `convolution plane ${index} bias must be finite in 0..=${limits.bias}`;
141
+ }
142
+ return null;
143
+ }
144
+ /**
145
+ * Whether `text` has a control character as Rust's `char::is_control`
146
+ * sees one (C0, DEL and C1: U+0000–U+001F, U+007F–U+009F).
147
+ */
148
+ export function hasControl(text) {
149
+ return [...text].some((c) => {
150
+ const code = c.codePointAt(0);
151
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f);
152
+ });
153
+ }
154
+ /**
155
+ * The form of a `lut3d` path (the player's `effects.rs lut_path`); where
156
+ * the file is and what it holds the player checks when the request arrives
157
+ * (the test host too, `TestHost.fileRoots`).
158
+ */
159
+ export function lutPathProblem(file) {
160
+ const max = VIDEO_PROCESSING_LIMITS.lutPathBytes;
161
+ if (typeof file !== "string" ||
162
+ Buffer.byteLength(file) > max ||
163
+ hasControl(file))
164
+ return `lut3d file must be a path of at most ${max} bytes without control characters`;
165
+ if (/[[\]]/.test(file))
166
+ return "lut3d file path must not contain [ or ]";
167
+ if (!isAbsolute(file))
168
+ return "lut3d file must be an absolute path";
169
+ if (!/\.cube$/i.test(file))
170
+ return "lut3d file must be a .cube file";
171
+ return null;
172
+ }
47
173
  /**
48
174
  * Why `nodes` would be refused (`null` when valid): unknown kinds or fields,
49
175
  * values out of range, more than 64 nodes. Used by the test host.
@@ -59,7 +185,20 @@ export function nodeProblem(stage, nodes) {
59
185
  for (const field of fields)
60
186
  if (!(field in rule))
61
187
  return `${node.kind}: unknown field ${field}`;
188
+ if (node.kind === "convolution") {
189
+ const problem = convolutionProblem(node.planes);
190
+ if (problem)
191
+ return problem;
192
+ continue;
193
+ }
194
+ if (node.kind === "lut3d") {
195
+ const problem = lutPathProblem(node.file);
196
+ if (problem)
197
+ return problem;
198
+ }
62
199
  for (const [field, allowed] of Object.entries(rule)) {
200
+ if (allowed.length === 0)
201
+ continue;
63
202
  const value = node[field];
64
203
  const ok = allowed.length === 2 &&
65
204
  typeof allowed[0] === "number" &&
package/dist/wire.d.ts CHANGED
@@ -376,11 +376,11 @@ export declare function createWire(transport: Transport): {
376
376
  operation: (token: import("./processing.ts").ProcessingToken) => Promise<import("./processing.ts").ProcessingOperation>;
377
377
  };
378
378
  commands: {
379
- register: (id: string, revision: number, declaration: import("./commands.ts").CommandDeclaration, state: import("./commands.ts").CommandState) => Promise<{
379
+ register: (id: string, revision: number, declaration: import("./commands.ts").CommandDeclaration, state: import("./commands.ts").CommandState, when?: import("./commands.ts").CommandWhen) => Promise<{
380
380
  revision: number;
381
381
  }>;
382
382
  unregister: (id: string) => Promise<void>;
383
- update: (id: string, revision: number, state: import("./commands.ts").CommandState) => Promise<{
383
+ update: (id: string, revision: number, state: import("./commands.ts").CommandState, when?: import("./commands.ts").CommandWhen) => Promise<{
384
384
  revision: number;
385
385
  }>;
386
386
  };
@@ -481,6 +481,13 @@ export declare function createWire(transport: Transport): {
481
481
  state: "applied" | "waiting";
482
482
  }>;
483
483
  renderRelease: () => Promise<void>;
484
+ /**
485
+ * One shader channel handle's list (`plugin.video.shaders(id)`, player
486
+ * 0.5.20); `[]` removes it.
487
+ */
488
+ shadersSet: (id: string, paths: string[]) => Promise<{
489
+ state: "applied" | "waiting";
490
+ }>;
484
491
  /** The GPU's video upscaler (`plugin.video.upscaling`, player 0.5.16). */
485
492
  upscalingState: () => Promise<WireUpscalingState>;
486
493
  upscalingSet: (enabled: boolean) => Promise<WireUpscalingState>;
@@ -608,14 +615,14 @@ export declare function createWire(transport: Transport): {
608
615
  * only the SDK keeps a copy of the state (player 0.5.18; older
609
616
  * players ignore it); `listen` changes it.
610
617
  */
611
- subscribe(event: HostEvent, listener: (data: unknown) => void, mirror?: boolean): Promise<() => Promise<void>>;
618
+ subscribe(event: HostEvent, listener: (data: unknown) => void, mirror?: boolean, why?: string[]): Promise<() => Promise<void>>;
612
619
  /**
613
620
  * Tell the player whether a subscribed event has a real listener
614
621
  * (`active`) or only the SDK's copy of the state: `events.subscribe`
615
622
  * again, which replaces the mark (player 0.5.18; older players keep
616
623
  * one subscription per name and ignore the mark).
617
624
  */
618
- listen: (event: HostEvent, active: boolean) => Promise<void>;
625
+ listen: (event: HostEvent, active: boolean, why?: string[]) => Promise<void>;
619
626
  };
620
627
  ui: {
621
628
  /** Player 0.2.0: the remote images a Markdown document shows. */
package/dist/wire.js CHANGED
@@ -138,6 +138,11 @@ export function createWire(transport) {
138
138
  /** The GPU renderer's options (`plugin.video.render()`). */
139
139
  renderSet: (values) => transport.request("video.render.set", { values }),
140
140
  renderRelease: () => transport.request("video.render.release"),
141
+ /**
142
+ * One shader channel handle's list (`plugin.video.shaders(id)`, player
143
+ * 0.5.20); `[]` removes it.
144
+ */
145
+ shadersSet: (id, paths) => transport.request("video.shaders.set", { id, paths }),
141
146
  /** The GPU's video upscaler (`plugin.video.upscaling`, player 0.5.16). */
142
147
  upscalingState: () => transport.request("video.upscaling.state"),
143
148
  upscalingSet: (enabled) => transport.request("video.upscaling.set", {
@@ -260,13 +265,19 @@ export function createWire(transport) {
260
265
  * only the SDK keeps a copy of the state (player 0.5.18; older
261
266
  * players ignore it); `listen` changes it.
262
267
  */
263
- async subscribe(event, listener, mirror = false) {
268
+ async subscribe(event, listener, mirror = false,
269
+ /** What listens (player 0.5.21 logs it; older players ignore it). */
270
+ why) {
264
271
  const off = transport.on(event, listener);
265
272
  let subscription = subscriptions.get(event);
266
273
  if (!subscription) {
267
274
  subscription = {
268
275
  count: 0,
269
- ready: transport.request("events.subscribe", mirror ? { event, mirror: true } : { event }),
276
+ ready: transport.request("events.subscribe", mirror
277
+ ? { event, mirror: true }
278
+ : why?.length
279
+ ? { event, why }
280
+ : { event }),
270
281
  };
271
282
  subscriptions.set(event, subscription);
272
283
  }
@@ -297,7 +308,11 @@ export function createWire(transport) {
297
308
  * again, which replaces the mark (player 0.5.18; older players keep
298
309
  * one subscription per name and ignore the mark).
299
310
  */
300
- listen: (event, active) => transport.request("events.subscribe", active ? { event } : { event, mirror: true }),
311
+ listen: (event, active, why) => transport.request("events.subscribe", active
312
+ ? why?.length
313
+ ? { event, why }
314
+ : { event }
315
+ : { event, mirror: true }),
301
316
  },
302
317
  ui: {
303
318
  /** Player 0.2.0: the remote images a Markdown document shows. */