@vestra-player/sdk 1.5.38 → 1.5.39

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.
@@ -9,11 +9,11 @@
9
9
  * object, so a built plugin has no runtime dependency on this package.
10
10
  */
11
11
  import type { Json } from "../wire.ts";
12
- import type { AudioNode, VideoNode } from "../processing.ts";
12
+ import type { AudioNode, ConvolutionPlane, VideoNode } from "../processing.ts";
13
13
  import type { SubtitleStyle } from "../subtitles.ts";
14
14
  import type { ResolvedMedia } from "../source-resolvers.ts";
15
15
  import type { MediaJob } from "../media.ts";
16
- export type { Json, AudioNode, VideoNode, SubtitleStyle, ResolvedMedia };
16
+ export type { Json, AudioNode, ConvolutionPlane, VideoNode, SubtitleStyle, ResolvedMedia, };
17
17
  /**
18
18
  * Anything that owns player-side or local resources: every handle the API
19
19
  * returns (commands, windows, panels, filters, snapshots, listeners…).
@@ -214,10 +214,11 @@ export interface Scope extends Disposable {
214
214
  readonly player: Player;
215
215
  /**
216
216
  * Video processing: this plugin's filter chain, the display layer, the
217
- * GPU render options and the GPU's video upscaler. The first three need
218
- * `processing.video` and `playback.read`; the filter chain also needs
219
- * `events` (without it the SDK never sees the media become ready and the
220
- * chain stays `waiting`). The upscaler needs `video.upscaling` only.
217
+ * GPU render options, this plugin's shaders and the GPU's video
218
+ * upscaler. The first four need `processing.video` and `playback.read`;
219
+ * the filter chain also needs `events` (without it the SDK never sees
220
+ * the media become ready and the chain stays `waiting`). The upscaler
221
+ * needs `video.upscaling` only.
221
222
  */
222
223
  readonly video: {
223
224
  /**
@@ -250,6 +251,24 @@ export interface Scope extends Disposable {
250
251
  * while the first handle is not disposed.
251
252
  */
252
253
  render(): RenderOptions;
254
+ /**
255
+ * This plugin's own GPU shaders named `id` (default `"shaders"`;
256
+ * needs `processing.video`; SDK 1.5.39, player 0.5.20). Unlike
257
+ * {@link Scope.video | render()}, every plugin may hold shader
258
+ * handles at the same time; the player joins their lists. Nothing is
259
+ * sent until the first `set()`.
260
+ *
261
+ * @param id - Local name of the handle, 1–128 bytes, unique within
262
+ * the plugin.
263
+ * @returns A {@link VideoShaders} owned by this scope.
264
+ * @throws `capability` (throws) The manifest does not declare
265
+ * `processing.video`.
266
+ * @throws `invalid` (throws) `id` is empty, longer than 128 bytes or
267
+ * has control characters.
268
+ * @throws `Error` without a code (throws) A second handle with the
269
+ * same `id` while the first is not disposed.
270
+ */
271
+ shaders(id?: string): VideoShaders;
253
272
  /**
254
273
  * The GPU's video upscaler (NVIDIA RTX Video Super Resolution on
255
274
  * Windows; needs `video.upscaling`, player 0.5.16): whether it works
@@ -4055,8 +4074,10 @@ export interface SystemInfo {
4055
4074
  * Absent (`undefined`) on players before 0.5.12, which do not report it:
4056
4075
  * the renderer is then unknown, not software.
4057
4076
  *
4058
- * Today the player renders on the GPU on macOS and in software on
4059
- * Windows, Linux and without a user interface (see
4077
+ * Today the player renders on the GPU on macOS, on Windows when the
4078
+ * user turned on Video ▸ GPU Rendering (Experimental) (since version 0.5.14;
4079
+ * off by default), and in software otherwise: on Windows by default, on
4080
+ * Linux and without a user interface (see
4060
4081
  * [Platforms](../../docs/reference/platforms.md)); test this field
4061
4082
  * rather than {@link SystemInfo.os}.
4062
4083
  *
@@ -4954,9 +4975,11 @@ export interface SequenceProvider {
4954
4975
  */
4955
4976
  export interface PlaybackStats {
4956
4977
  /**
4957
- * The rendering path: "libmpv · GPU 渲染(OpenGL)" (GPU, macOS default) or
4958
- * "libmpv · 软件渲染" (software: Windows, Linux, headless, fallback).
4959
- * The strings are the player's display text.
4978
+ * The rendering path: "libmpv · GPU 渲染(OpenGL)" (GPU on macOS, the
4979
+ * default there), "libmpv · GPU 渲染(Direct3D)" (GPU on Windows, when
4980
+ * turned on; version 0.5.14 and later) or "libmpv · 软件渲染" (software: Windows by
4981
+ * default, Linux, headless, fallback). The strings are the player's
4982
+ * display text in the interface language (shown here in Chinese).
4960
4983
  */
4961
4984
  backend: string;
4962
4985
  /** The playback session number. */
@@ -6357,7 +6380,11 @@ export interface RenderSettings {
6357
6380
  targetPeak: number;
6358
6381
  /** Per-scene peak estimation (where the GPU supports it). */
6359
6382
  peakDetect: boolean;
6360
- /** mpv user shaders (`.glsl`): absolute paths of existing files, at most 16, in order. */
6383
+ /**
6384
+ * mpv user shaders (`.glsl`): absolute paths of existing files, at most
6385
+ * 16, in order. They run before the shaders of every plugin's
6386
+ * {@link VideoShaders} handles.
6387
+ */
6361
6388
  shaders: string[];
6362
6389
  /** Motion interpolation, resampled to the display's refresh rate. */
6363
6390
  interpolation: boolean;
@@ -6367,10 +6394,12 @@ export interface RenderSettings {
6367
6394
  * (`processing.video`). Single owner, like {@link VideoView}; the two are
6368
6395
  * independent handles.
6369
6396
  *
6370
- * Platforms: only macOS renders on the GPU. Under software rendering
6371
- * (Windows, Linux, headless, fallback) every `set` fails with
6372
- * `unsupported`; {@link SystemInfo.rendering} tells which renderer draws
6373
- * without creating the handle. macOS OpenGL 4.1 has no
6397
+ * Platforms: macOS renders on the GPU by default; Windows does since
6398
+ * version 0.5.14 of the player when the user turns on Video ▸ GPU Rendering
6399
+ * (Experimental) (off by default, {@link Rendering}). Under software
6400
+ * rendering (Windows by default, Linux, headless, fallback) every `set`
6401
+ * fails with `unsupported`; {@link SystemInfo.rendering} tells which
6402
+ * renderer draws without creating the handle. macOS OpenGL 4.1 has no
6374
6403
  * compute shaders, so `peakDetect` is accepted but has no effect; output
6375
6404
  * is always SDR.
6376
6405
  *
@@ -6379,7 +6408,8 @@ export interface RenderSettings {
6379
6408
  * 250-nit display; under software rendering the plugin hides its HDR menu.
6380
6409
  * `set` merges the values into the plugin's render options and the
6381
6410
  * player reports the result through `onStatus`: `failed` with
6382
- * `unsupported` means the player renders in software (Windows, Linux).
6411
+ * `unsupported` means the player renders in software (Linux, or Windows
6412
+ * while GPU rendering is off).
6383
6413
  * ```ts
6384
6414
  * declare function hideHdrMenu(): void;
6385
6415
  * // ---cut---
@@ -6392,11 +6422,13 @@ export interface RenderSettings {
6392
6422
  *
6393
6423
  * @remarks
6394
6424
  * GPU renderer options (tone mapping, peak detection, shaders,
6395
- * interpolation). Only the GPU renderer has them, which today means macOS;
6425
+ * interpolation). Only the GPU renderer has them (macOS; Windows with GPU
6426
+ * rendering turned on, version 0.5.14 and later);
6396
6427
  * under software rendering the handle is `failed` with `unsupported` after
6397
6428
  * the player's first answer to `render()` (reported to `onError` too), and so is
6398
6429
  * every `set`. Single owner, like {@link VideoView}. `set` never throws; the
6399
- * result shows in `status`.
6430
+ * result shows in `status`. For shaders that several plugins add at the
6431
+ * same time, use {@link VideoShaders} (SDK 1.5.39).
6400
6432
  *
6401
6433
  * @capability processing.video, playback.read and events (like
6402
6434
  * {@link VideoFilter})
@@ -6423,7 +6455,9 @@ export interface RenderOptions extends Disposable {
6423
6455
  set(value: Partial<RenderSettings>): void;
6424
6456
  /**
6425
6457
  * `failed` with `unsupported` while the player renders in software;
6426
- * `failed` with `failed` when mpv cannot compile a shader (pushed later).
6458
+ * `failed` with `failed` when mpv cannot compile a shader (pushed later;
6459
+ * while other plugins' {@link VideoShaders} are on too, the player
6460
+ * cannot tell whose shader failed and says so in the message).
6427
6461
  */
6428
6462
  readonly status: FilterStatus;
6429
6463
  /**
@@ -6446,6 +6480,152 @@ export interface RenderOptions extends Disposable {
6446
6480
  */
6447
6481
  onStatus(listener: (status: FilterStatus) => void): Disposable;
6448
6482
  }
6483
+ /**
6484
+ * This plugin's own GPU shaders, from `plugin.video.shaders(id)`
6485
+ * (`processing.video`, SDK 1.5.39, player 0.5.20): mpv user shaders
6486
+ * (`.glsl`, `//!HOOK` …) that the GPU renderer runs on every frame.
6487
+ *
6488
+ * @remarks
6489
+ * Any number of plugins hold shader handles at the same time (unlike
6490
+ * {@link RenderOptions}, which has one owner). The player joins every
6491
+ * handle's list in a stable order, after the render options' own
6492
+ * `shaders`: built-in plugins first, then installed plugins in the order
6493
+ * they were first installed, then development loads (`--plugin`); ties by
6494
+ * plugin id, and a plugin's handles by `id`. At most 16 shaders across all
6495
+ * handles (`limit` beyond, nothing changes); the render options keep their
6496
+ * own 16.
6497
+ *
6498
+ * Files must be this plugin's own: absolute paths inside its package
6499
+ * directory or {@link Plugin.dataDirectory} after following links, each
6500
+ * at most 16 MiB. Elsewhere: `invalid`; a file that does not exist:
6501
+ * `missing`. The player reads the files when the list is set.
6502
+ *
6503
+ * GPU rendering only (macOS; Windows with Video ▸ GPU Rendering
6504
+ * (Experimental) turned on): under software rendering a non-empty list is
6505
+ * `failed` with `unsupported`, and a switch to software during the run
6506
+ * pushes `unsupported` to every handle with shaders and removes every
6507
+ * list (they no longer count against the 16); set them again once the
6508
+ * player draws on the GPU (after a restart). Shaders act when a
6509
+ * frame is drawn, so processed snapshots and exports never contain them
6510
+ * (use a {@link VideoNode} for that). When mpv cannot compile a shader the
6511
+ * handle turns `failed` with `failed` (pushed later); mpv does not say
6512
+ * which file failed, so while several plugins' shaders are on, each of
6513
+ * them hears it and the message says the player cannot tell whose.
6514
+ *
6515
+ * `set` sends the whole list (last write wins); `clear` and `dispose`
6516
+ * withdraw it; the player removes a stopped plugin's shaders itself.
6517
+ * Players before 0.5.20 refuse the request (the handle is `failed`): declare
6518
+ * `"minPlayerVersion": "0.5.20"`.
6519
+ *
6520
+ * @example Add a sharpening shader the plugin ships
6521
+ * The video looks sharper on the GPU renderer; under software rendering
6522
+ * the OSD says why it is not. The shader file lives in the plugin's
6523
+ * package, and the player puts it after other plugins' shaders that come
6524
+ * earlier in its order.
6525
+ * ```ts
6526
+ * import { join } from "node:path";
6527
+ * declare const packageDirectory: string;
6528
+ * // ---cut---
6529
+ * const shaders = plugin.video.shaders("sharpen");
6530
+ * shaders.onStatus((s) => {
6531
+ * // `unsupported`: software rendering; `failed`: mpv could not compile it.
6532
+ * if (s.state === "failed") void plugin.osd.show("Sharpen: {}", [s.error.message]);
6533
+ * });
6534
+ * shaders.set([join(packageDirectory, "shaders", "sharpen.glsl")]);
6535
+ * ```
6536
+ *
6537
+ * @capability processing.video
6538
+ * @player 0.5.20; older players refuse the request.
6539
+ * @see [Video and audio processing](../../docs/concepts/processing.md)
6540
+ * @since 1.5.39
6541
+ * @category Processing
6542
+ */
6543
+ export interface VideoShaders extends Disposable {
6544
+ /**
6545
+ * Replace this handle's shader list (last write wins); `[]` withdraws
6546
+ * it.
6547
+ *
6548
+ * @remarks
6549
+ * Never throws: a refused list shows in `status` (`invalid`, `missing`,
6550
+ * `limit`, `unsupported`) and goes to {@link Scope.onError}, and what the
6551
+ * player held stays.
6552
+ *
6553
+ * @capability processing.video
6554
+ * @param paths - Absolute paths of shader files in this plugin's package
6555
+ * or data directory, in the order they run.
6556
+ *
6557
+ * @example Switch between two shader presets
6558
+ * The plugin offers a light and a strong preset; picking one replaces
6559
+ * the handle's list in one request. The files were written to the
6560
+ * plugin's data directory when the user imported them.
6561
+ * ```ts
6562
+ * import { join } from "node:path";
6563
+ * // ---cut---
6564
+ * const presets: Record<string, string[]> = {
6565
+ * light: [join(plugin.dataDirectory, "denoise.glsl")],
6566
+ * strong: [join(plugin.dataDirectory, "denoise.glsl"), join(plugin.dataDirectory, "sharpen.glsl")],
6567
+ * };
6568
+ * plugin.video.shaders("presets").set(presets.strong!);
6569
+ * ```
6570
+ *
6571
+ * @since 1.5.39
6572
+ */
6573
+ set(paths: readonly string[]): void;
6574
+ /**
6575
+ * Withdraw this handle's shaders (the same as `set([])`).
6576
+ *
6577
+ * @capability processing.video
6578
+ *
6579
+ * @example Turn the plugin's shaders off from a menu item
6580
+ * A Video menu item turns the plugin's shader on and off. Clearing
6581
+ * keeps the handle, so the next pick sets the list again.
6582
+ * ```ts
6583
+ * declare const file: string;
6584
+ * // ---cut---
6585
+ * const shaders = plugin.video.shaders();
6586
+ * let on = false;
6587
+ * plugin.commands.add({
6588
+ * id: "toggle-shader",
6589
+ * path: ["@video", "Sharpen (Shader)"],
6590
+ * checked: () => on,
6591
+ * run: () => {
6592
+ * on = !on;
6593
+ * // Clearing keeps the handle; the next pick sets the list again.
6594
+ * if (on) shaders.set([file]);
6595
+ * else shaders.clear();
6596
+ * },
6597
+ * });
6598
+ * ```
6599
+ *
6600
+ * @since 1.5.39
6601
+ */
6602
+ clear(): void;
6603
+ /**
6604
+ * Current status: `waiting` before ready media, `applying`, `applied`,
6605
+ * or `failed` with the reason (see the remarks).
6606
+ */
6607
+ readonly status: FilterStatus;
6608
+ /**
6609
+ * The status changed, including later pushes (a compile failure, a
6610
+ * switch to software rendering).
6611
+ *
6612
+ * @capability processing.video
6613
+ *
6614
+ * @example Report a shader that does not compile
6615
+ * The OSD shows mpv's message when the plugin's shader fails to compile.
6616
+ * The player pushes that failure after the list was accepted, so it
6617
+ * arrives through `onStatus`, not as a refused `set`.
6618
+ * ```ts
6619
+ * const shaders = plugin.video.shaders();
6620
+ * shaders.onStatus((status) => {
6621
+ * if (status.state === "failed") void plugin.osd.show("Shader: {}", [status.error.message]);
6622
+ * });
6623
+ * ```
6624
+ *
6625
+ * @since 1.5.39
6626
+ */
6627
+ onStatus(listener: (status: FilterStatus) => void): Disposable;
6628
+ }
6449
6629
  /**
6450
6630
  * Why the GPU's video upscaler does not work, as a stable kebab-case code.
6451
6631
  * A newer player may add codes: compare with the ones you handle and show
@@ -9295,10 +9475,10 @@ export type RenderingPreference = "auto" | "gpu" | "software";
9295
9475
  *
9296
9476
  * | Code | Meaning |
9297
9477
  * | --- | --- |
9298
- * | `default` | Nothing ruled the GPU out; the platform's default. |
9478
+ * | `default` | Nothing ruled the GPU out; the platform's default (macOS: GPU; Windows since version 0.5.14: software until the user turns on GPU rendering). |
9299
9479
  * | `pending` | GPU rendering is starting. |
9300
9480
  * | `headless` | The player runs without a user interface. |
9301
- * | `no-gpu-backend` | This player has no GPU renderer on this system (Windows today). |
9481
+ * | `no-gpu-backend` | This player has no GPU renderer on this system (Linux today; Windows has one since version 0.5.14, off by default, which reports `default`). |
9302
9482
  * | `env` | The environment chose it (`VESTRA_RENDER`, `VESTRA_SOFTWARE_RENDER`). |
9303
9483
  * | `preference` | The user chose software rendering ({@link Rendering.setPreference}); GPU chosen and available reports `default`. |
9304
9484
  * | `denylist` | The graphics driver is on the player's denylist. |
@@ -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
@@ -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>;
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", {