@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.39
4
+
5
+ **Who must change: nobody.** Additions only, `apiVersion` stays `vestra/1`, no new capability. The new API needs player 0.5.20 (`"minPlayerVersion": "0.5.20"`): older players refuse the new node kinds with `invalid` and the shader request as unknown, which shows as a `failed` handle. A plugin that uses none of it sees no difference; `plugin.video.render()` (its `shaders` included) behaves as before.
6
+
7
+ - **Shader channel `plugin.video.shaders(id?)`** (player 0.5.20, `processing.video`): this plugin's own mpv user shaders, with any number of plugins holding handles at the same time (`render()` keeps its single owner). `set(paths)` sends the handle's whole list (last write wins), `clear()` and `dispose()` withdraw it, `status`/`onStatus` as for filters. The player joins every list after the render options' own `shaders`, in a stable order: built-in plugins, then installed plugins in the order they were first installed, then development loads; ties by plugin id, a plugin's handles by id. At most 16 shaders across all handles (`limit`; the render options keep their own 16). Files must be the plugin's own (package or data directory after following links, at most 16 MiB; `invalid` elsewhere, `missing` when absent). GPU rendering only (`unsupported` under software rendering; when the player switches to software during the run every handle hears `unsupported` and its list is removed, to be set again once the player draws on the GPU); never in snapshots or exports. A compile failure is pushed to every handle with shaders not yet told; whenever more than one holder has shaders on (other handles or `render().shaders`), the message says the player cannot tell whose shader failed. Handle ids, like `lut3d` paths, refuse C0 and C1 control characters. New type `VideoShaders`.
8
+ - **`convolution` and `lut3d` video nodes** (player 0.5.20): `{ kind: "convolution", planes }` takes one integer kernel per plane (`ConvolutionPlane`: `mode` `square` with 9, 25 or 49 entries, or `row`/`column` with an odd count up to 49; `rdiv`, `bias`), strictly checked; `{ kind: "lut3d", file, interpolation }` recolours with a `.cube` file from the plugin's package or data directory (`LUT_3D_SIZE` 2–65, 16 MiB, no `[` or `]` in the path). Both run in the filter chain like the other nodes, so they apply to processed snapshots and exports, and they need frames in memory (copy-back decoding). New type `ConvolutionPlane`.
9
+ - **`VIDEO_PROCESSING_LIMITS`** (`@vestra-player/sdk/testing`): the bounds above (including `lutPathBytes`, 4096), in the generated table of _Limits_.
10
+ - **`TestHost`**: `videoShaders` (this plugin's lists by id, `others` for other plugins' count), `fileRoots` (the package and data directory `startPlugin` sets), `failVideoShaders(id, message)`; `failVideoRender("unsupported")` also tells the shader handles and removes their lists; `processing.replace` checks `lut3d` files like the player.
11
+ - Corrected: the reference no longer says only macOS renders on the GPU. Windows has GPU rendering since player 0.5.14 (Video ▸ GPU Rendering (Experimental), off by default): `RenderOptions`, `SystemInfo.rendering`, `PlaybackStats.backend`, `RenderingReasonCode` (`default`, `no-gpu-backend`) and the `TestHost.rendering` example say so.
12
+
3
13
  ## 1.5.38
4
14
 
5
15
  **Who must change: nobody.** Additions only, `apiVersion` stays `vestra/1`. The marks below are read by player 0.5.18 and later; older players ignore them, and a plugin built with an older SDK keeps working (the player builds the `plugin` object with its own SDK). What may change for a plugin is the player's lazy activation, off by default in player 0.5.18 and turned on in a later release: a plugin that holds nothing only its process can serve is stopped while idle and started on its next use, so state kept only in variables is lost between uses (keep it in `plugin.storage.session`).
@@ -118,6 +118,7 @@ export const API_REQUESTS = Object.freeze({
118
118
  ],
119
119
  "Plugin.video.view": [{ method: "video.view.set" }],
120
120
  "Plugin.video.render": [{ method: "video.render.set" }],
121
+ "Plugin.video.shaders": [{ method: "video.shaders.set" }],
121
122
  "Plugin.audio.filter": [
122
123
  { method: "processing.replace", params: { stage: "audio" } },
123
124
  ],
@@ -191,6 +191,7 @@ export const HOST_CAPABILITIES = {
191
191
  { name: "video.view.release", needs: "processing.video" },
192
192
  { name: "video.render.set", needs: "processing.video" },
193
193
  { name: "video.render.release", needs: "processing.video" },
194
+ { name: "video.shaders.set", needs: "processing.video" },
194
195
  ],
195
196
  name: "processing.video",
196
197
  requires: ["playback.read"],
@@ -18,6 +18,7 @@ export { BINDING_INPUTS } from "./plugin/bindings.ts";
18
18
  export { OSD_LIMITS } from "./plugin/osd.ts";
19
19
  export { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./plugin/services.ts";
20
20
  export { SUBTITLE_LIMITS } from "./plugin/subtitles.ts";
21
+ export { VIDEO_PROCESSING_LIMITS } from "./processing.ts";
21
22
  export { THEME_PALETTES } from "./palettes.ts";
22
23
  export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, renderText, scanSlint, TEXT_LIMITS, textKey, textProblem, } from "./i18n.ts";
23
24
  export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.ts";
@@ -16,6 +16,7 @@ export { BINDING_INPUTS } from "./plugin/bindings.js";
16
16
  export { OSD_LIMITS } from "./plugin/osd.js";
17
17
  export { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./plugin/services.js";
18
18
  export { SUBTITLE_LIMITS } from "./plugin/subtitles.js";
19
+ export { VIDEO_PROCESSING_LIMITS } from "./processing.js";
19
20
  export { THEME_PALETTES } from "./palettes.js";
20
21
  export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, renderText, scanSlint, TEXT_LIMITS, textKey, textProblem, } from "./i18n.js";
21
22
  export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.js";
@@ -15,6 +15,7 @@ export declare class ScopeImpl implements Plugin {
15
15
  readonly video: {
16
16
  view: () => import("./types.ts").VideoView;
17
17
  render: () => import("./types.ts").RenderOptions;
18
+ shaders: (id?: string) => import("./types.ts").VideoShaders;
18
19
  upscaling: import("./types.ts").VideoUpscaling;
19
20
  filter(id: string): import("./types.ts").VideoFilter;
20
21
  };
@@ -10,6 +10,7 @@ import { i18n } from "./language.js";
10
10
  import { player } from "./player.js";
11
11
  import { audioFilters, videoFilters } from "./filters.js";
12
12
  import { videoRender, videoView } from "./view.js";
13
+ import { videoShaders } from "./shaders.js";
13
14
  import { subtitles } from "./subtitles.js";
14
15
  import { mediaCapture } from "./media.js";
15
16
  import { resolvers } from "./resolvers.js";
@@ -49,6 +50,7 @@ export class ScopeImpl {
49
50
  ...videoFilters(this),
50
51
  view: () => videoView(this),
51
52
  render: () => videoRender(this),
53
+ shaders: (id) => videoShaders(this, id),
52
54
  upscaling: videoUpscaling(this),
53
55
  };
54
56
  audio = audioFilters(this);
@@ -0,0 +1,9 @@
1
+ import type { ScopeImpl } from "./scope.ts";
2
+ import type { VideoShaders } from "./types.ts";
3
+ /** The handle id when `shaders()` is called without one. */
4
+ export declare const DEFAULT_SHADERS_ID = "shaders";
5
+ /** Why `paths` would be refused here (`null` when it may be sent). */
6
+ export declare function shaderListProblem(paths: unknown): string | null;
7
+ /** Why a handle id would be refused (`null` when valid). */
8
+ export declare function shaderIdProblem(id: unknown): string | null;
9
+ export declare function videoShaders(scope: ScopeImpl, id?: string): VideoShaders;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The shader channel `plugin.video.shaders(id)` (SDK 1.5.39, player
3
+ * 0.5.20): this plugin's own mpv user shaders, joined by the player with
4
+ * every other plugin's in a stable order. Unlike `video.render()` any
5
+ * number of plugins hold one at a time; each handle sends its whole list
6
+ * (last write wins) and the player answers `applied`, `waiting` (no ready
7
+ * media), or a failure.
8
+ */
9
+ import { isAbsolute } from "node:path";
10
+ import { failure, Latest, quiet } from "./runtime.js";
11
+ import { playerOf } from "./player.js";
12
+ import { hasControl, VIDEO_PROCESSING_LIMITS } from "../processing.js";
13
+ /** The handle id when `shaders()` is called without one. */
14
+ export const DEFAULT_SHADERS_ID = "shaders";
15
+ /** Why `paths` would be refused here (`null` when it may be sent). */
16
+ export function shaderListProblem(paths) {
17
+ if (!Array.isArray(paths) || paths.some((p) => typeof p !== "string"))
18
+ return "shaders must be an array of paths";
19
+ if (paths.length > VIDEO_PROCESSING_LIMITS.shaders)
20
+ return `at most ${VIDEO_PROCESSING_LIMITS.shaders} shaders across all plugins`;
21
+ const relative = paths.find((path) => !isAbsolute(path));
22
+ if (relative !== undefined)
23
+ return `shader path must be absolute: ${relative}`;
24
+ return null;
25
+ }
26
+ /** Why a handle id would be refused (`null` when valid). */
27
+ export function shaderIdProblem(id) {
28
+ return typeof id === "string" &&
29
+ id.length > 0 &&
30
+ Buffer.byteLength(id) <= VIDEO_PROCESSING_LIMITS.shaderIdBytes &&
31
+ !hasControl(id)
32
+ ? null
33
+ : `shader handle id must be 1 to ${VIDEO_PROCESSING_LIMITS.shaderIdBytes} bytes without control characters`;
34
+ }
35
+ class ShaderHandle {
36
+ #scope;
37
+ #id;
38
+ #desired = [];
39
+ /** Something non-empty was sent: `dispose` withdraws it. */
40
+ #sent = false;
41
+ #status = { state: "waiting" };
42
+ #disposed = false;
43
+ #source = "";
44
+ #listeners = new Set();
45
+ #latest;
46
+ #watch;
47
+ #pushed;
48
+ constructor(scope, id) {
49
+ this.#scope = scope;
50
+ this.#id = id;
51
+ this.#latest = new Latest(async (paths) => {
52
+ if (paths.length > 0)
53
+ this.#sent = true;
54
+ const { state } = await scope.runtime.host.video.shadersSet(id, paths);
55
+ if (this.#desired === paths)
56
+ this.#report({ state });
57
+ });
58
+ // `waiting` becomes `applied` when media becomes ready: ask again.
59
+ this.#watch = playerOf(scope.runtime).watch((raw) => {
60
+ const source = `${raw.stamp.generation}:${raw.phase}`;
61
+ if (source === this.#source)
62
+ return;
63
+ const first = this.#source === "";
64
+ this.#source = source;
65
+ if (!first && this.#status.state === "waiting" && this.#sent)
66
+ this.#send();
67
+ });
68
+ this.#pushed = scope.runtime.raw("video.shaders.status", (data) => {
69
+ const { id: target, error } = data;
70
+ if (this.#disposed || target !== this.#id || !error)
71
+ return;
72
+ this.#fail(failure(error.code ?? "failed", error.message));
73
+ });
74
+ scope.add(this);
75
+ }
76
+ get status() {
77
+ return this.#status;
78
+ }
79
+ onStatus(listener) {
80
+ this.#listeners.add(listener);
81
+ return this.#scope.onDispose(() => {
82
+ this.#listeners.delete(listener);
83
+ });
84
+ }
85
+ set(paths) {
86
+ if (this.#disposed)
87
+ return;
88
+ const problem = shaderListProblem(paths);
89
+ if (problem) {
90
+ // Refused at once; what the player holds stays.
91
+ this.#fail(failure(problem.startsWith("at most") ? "limit" : "invalid", problem));
92
+ return;
93
+ }
94
+ this.#desired = [...paths];
95
+ this.#send();
96
+ }
97
+ clear() {
98
+ this.set([]);
99
+ }
100
+ #send() {
101
+ const paths = this.#desired;
102
+ this.#report({ state: "applying" });
103
+ quiet(this.#latest.push(paths).catch((error) => {
104
+ if (this.#desired === paths && !this.#disposed)
105
+ this.#fail(error);
106
+ }));
107
+ }
108
+ #fail(error) {
109
+ this.#report({ state: "failed", error });
110
+ this.#scope.report(error);
111
+ }
112
+ #report(status) {
113
+ if (JSON.stringify(status) === JSON.stringify(this.#status) &&
114
+ status.state !== "failed")
115
+ return;
116
+ this.#status = status;
117
+ for (const listener of this.#listeners)
118
+ this.#scope.run(listener, status);
119
+ this.#scope.update();
120
+ }
121
+ async dispose() {
122
+ if (this.#disposed)
123
+ return;
124
+ this.#disposed = true;
125
+ this.#scope.forget(this);
126
+ this.#latest.stop();
127
+ await this.#watch.dispose();
128
+ await this.#pushed.dispose();
129
+ this.#scope.runtime.services.delete(`video.shaders:${this.#id}`);
130
+ // Withdraw this handle's shaders; refused (and moot) once stopped: the
131
+ // player removes a stopped plugin's shaders itself.
132
+ if (this.#sent)
133
+ await this.#scope.runtime.host.video
134
+ .shadersSet(this.#id, [])
135
+ .catch(() => { });
136
+ }
137
+ }
138
+ export function videoShaders(scope, id) {
139
+ const runtime = scope.runtime;
140
+ if (!runtime.can("processing.video"))
141
+ throw failure("capability", "processing.video must be declared in manifest");
142
+ const name = id ?? DEFAULT_SHADERS_ID;
143
+ const problem = shaderIdProblem(name);
144
+ if (problem)
145
+ throw failure("invalid", problem);
146
+ const service = `video.shaders:${name}`;
147
+ if (runtime.services.has(service))
148
+ throw new Error(`a shader handle "${name}" exists; reuse it`);
149
+ runtime.services.set(service, true);
150
+ const handle = new ShaderHandle(scope, name);
151
+ return {
152
+ set: (paths) => handle.set(paths),
153
+ clear: () => handle.clear(),
154
+ get status() {
155
+ return handle.status;
156
+ },
157
+ onStatus: (listener) => handle.onStatus(listener),
158
+ dispose: () => handle.dispose(),
159
+ };
160
+ }
@@ -15,6 +15,7 @@ import { IMAGE_LIMITS, UI_VALUE_LIMITS } from "./values.ts";
15
15
  import type { CommandDeclaration } from "../commands.ts";
16
16
  import type { SourceState } from "../source.ts";
17
17
  import type { SubtitleState } from "../subtitles.ts";
18
+ import { VIDEO_PROCESSING_LIMITS } from "../processing.ts";
18
19
  import type { RenderingMode, RenderingPreference, RenderingReason, RenderSettings, UpscalingReason, ViewSettings } from "./types.ts";
19
20
  import type { MediaJob } from "../media.ts";
20
21
  import type { LanguagePreference, Locale, Plugin, TemplateArgs, TextValue, ThemeId } from "./types.ts";
@@ -41,6 +42,8 @@ export { SECRET_LIMITS };
41
42
  export { SESSION_STORAGE_LIMITS };
42
43
  /** The player's limits on loaded subtitle files, which the test host enforces as well. */
43
44
  export { SUBTITLE_LIMITS };
45
+ /** The player's bounds on convolution, lut3d and shader-channel requests, which the test host enforces as well. */
46
+ export { VIDEO_PROCESSING_LIMITS };
44
47
  /** The names bindings are made of. */
45
48
  export { BINDING_INPUTS };
46
49
  /** The colours of the player's palettes, by `Theme` token. */
@@ -1341,6 +1344,64 @@ export declare class TestHost implements Transport {
1341
1344
  * @since 1.0.0
1342
1345
  */
1343
1346
  failVideoRender(code: "failed" | "unsupported", message: string): void;
1347
+ /**
1348
+ * The shader channel (`plugin.video.shaders(id)`, SDK 1.5.39): the lists
1349
+ * the player holds for this plugin's handles by id (the real paths, after
1350
+ * following links), and how many shaders other plugins hold, which count
1351
+ * against {@link VIDEO_PROCESSING_LIMITS}`.shaders` (`limit`). Files must
1352
+ * be under {@link TestHost.fileRoots}; under software rendering
1353
+ * (`videoRender.gpu = false`) a non-empty list fails with `unsupported`.
1354
+ *
1355
+ * @since 1.5.39
1356
+ */
1357
+ readonly videoShaders: {
1358
+ /** This plugin's lists by handle id; a cleared handle has none. */
1359
+ handles: Map<string, string[]>;
1360
+ /** Shaders other plugins hold (0–16). */
1361
+ others: number;
1362
+ };
1363
+ /**
1364
+ * Where the plugin's own files may be: its package directory and its
1365
+ * data directory ({@link startPlugin} sets both when the list is still
1366
+ * empty). `lut3d` files and shader-channel files outside them fail with
1367
+ * `invalid`, as in the player.
1368
+ *
1369
+ * @since 1.5.39
1370
+ */
1371
+ fileRoots: string[];
1372
+ /**
1373
+ * What the player pushes to a shader handle when mpv cannot compile a
1374
+ * shader (code `failed`; the player cannot tell whose shader failed, so
1375
+ * every handle with shaders hears it) or the player switches to software
1376
+ * rendering (`unsupported`; use {@link TestHost.failVideoRender}, which
1377
+ * also tells the handles). Pushed only to a handle that holds shaders.
1378
+ *
1379
+ * @capability None.
1380
+ *
1381
+ * @example Make the plugin's shader fail to compile
1382
+ * The plugin's shader handle reports `failed`, as when mpv rejects a
1383
+ * shader. The host pushes the failure only to a handle that holds
1384
+ * shaders, which its accepted `set` made it; the SDK updates the
1385
+ * handle's `status` when the push arrives, so the test settles first.
1386
+ * ```ts
1387
+ * import assert from "node:assert/strict";
1388
+ * import { join } from "node:path";
1389
+ * import { writeFileSync } from "node:fs";
1390
+ * declare const host: TestHost;
1391
+ * // ---cut---
1392
+ * const file = join(plugin.dataDirectory, "sharpen.glsl");
1393
+ * writeFileSync(file, "//!HOOK MAIN\n");
1394
+ * const shaders = plugin.video.shaders("sharpen");
1395
+ * shaders.set([file]); // the handle now holds a shader
1396
+ * await host.settle();
1397
+ * host.failVideoShaders("sharpen", "shader compile failed: ERROR: 0:3: ...");
1398
+ * await host.settle();
1399
+ * assert.equal(shaders.status.state, "failed");
1400
+ * ```
1401
+ *
1402
+ * @since 1.5.39
1403
+ */
1404
+ failVideoShaders(id: string, message: string): void;
1344
1405
  /**
1345
1406
  * The player's processing (filter) state. Wire-level; may change in minor
1346
1407
  * versions. `resources` holds each `video.filter` / `audio.filter` chain the
@@ -1681,9 +1742,9 @@ export declare class TestHost implements Transport {
1681
1742
  * import activate from "../main.ts";
1682
1743
  *
1683
1744
  * const host = new TestHost();
1684
- * host.videoRender.gpu = false; // software draws now (Windows)
1745
+ * host.videoRender.gpu = false; // software draws now (Linux)
1685
1746
  * host.rendering.startedWith = "software";
1686
- * host.rendering.reason = { code: "no-gpu-backend", message: "No GPU renderer on Windows" };
1747
+ * host.rendering.reason = { code: "no-gpu-backend", message: "this player has no GPU renderer on Linux yet" };
1687
1748
  * host.rendering.gpu = { available: false, reason: host.rendering.reason };
1688
1749
  * const { stop } = await startPlugin(activate, {
1689
1750
  * directory: new URL("..", import.meta.url),
@@ -1691,7 +1752,7 @@ export declare class TestHost implements Transport {
1691
1752
  * });
1692
1753
  * const item = host.command("gpu-rendering");
1693
1754
  * assert.equal(item.state.enabled, false);
1694
- * assert.equal(item.state.reason, "No GPU renderer on Windows");
1755
+ * assert.equal(item.state.reason, "this player has no GPU renderer on Linux yet");
1695
1756
  * await stop();
1696
1757
  * ```
1697
1758
  *
@@ -64,12 +64,12 @@
64
64
  * @packageDocumentation
65
65
  * @module @vestra-player/sdk/testing
66
66
  */
67
- import { existsSync, readFileSync } from "node:fs";
67
+ import { existsSync, openSync, readFileSync, readSync, closeSync, realpathSync, } from "node:fs";
68
68
  import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
69
69
  import { createServer } from "node:net";
70
70
  import { randomBytes } from "node:crypto";
71
71
  import { tmpdir } from "node:os";
72
- import { extname, isAbsolute, join } from "node:path";
72
+ import { extname, isAbsolute, join, relative } from "node:path";
73
73
  import { fileURLToPath } from "node:url";
74
74
  import { HostError } from "../wire.js";
75
75
  import { localSocketPath } from "./local.js";
@@ -82,7 +82,8 @@ import { BINDING_INPUTS } from "./bindings.js";
82
82
  import { THEME_PALETTES } from "../palettes.js";
83
83
  import { IMAGE_LIMITS, UI_VALUE_LIMITS, valueLimitProblem } from "./values.js";
84
84
  import { validStep } from "./player.js";
85
- import { nodeProblem } from "../processing.js";
85
+ import { nodeProblem, VIDEO_PROCESSING_LIMITS } from "../processing.js";
86
+ import { shaderIdProblem, shaderListProblem } from "./shaders.js";
86
87
  import { RENDER_DEFAULTS, renderProblem, VIEW_DEFAULTS, viewProblem, } from "./view.js";
87
88
  import { createPlugin } from "./index.js";
88
89
  import { THEMES } from "./appearance.js";
@@ -107,6 +108,8 @@ export { SECRET_LIMITS };
107
108
  export { SESSION_STORAGE_LIMITS };
108
109
  /** The player's limits on loaded subtitle files, which the test host enforces as well. */
109
110
  export { SUBTITLE_LIMITS };
111
+ /** The player's bounds on convolution, lut3d and shader-channel requests, which the test host enforces as well. */
112
+ export { VIDEO_PROCESSING_LIMITS };
110
113
  /** The names bindings are made of. */
111
114
  export { BINDING_INPUTS };
112
115
  /** The colours of the player's palettes, by `Theme` token. */
@@ -188,6 +191,70 @@ const sessionCost = (key, value) => Buffer.byteLength(key) +
188
191
  SESSION_STORAGE_LIMITS.entryBytes;
189
192
  const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
190
193
  const tick = () => new Promise((resolve) => setImmediate(resolve));
194
+ /**
195
+ * The player's `playback/plugin_files.rs`: a file the plugin names for its
196
+ * video processing is an absolute path inside one of `roots` after
197
+ * following links, a regular file of at most `max` bytes. Returns the real
198
+ * path or throws the player's error.
199
+ */
200
+ function ownFile(path, roots, what, max) {
201
+ if (!isAbsolute(path))
202
+ throw new HostError("invalid", `${what} path must be absolute: ${path}`);
203
+ let real;
204
+ try {
205
+ real = realpathSync(path);
206
+ }
207
+ catch {
208
+ throw new HostError("missing", `${what} not found: ${path}`);
209
+ }
210
+ const inside = roots.some((root) => {
211
+ let base;
212
+ try {
213
+ base = realpathSync(root);
214
+ }
215
+ catch {
216
+ return false;
217
+ }
218
+ const rel = relative(base, real);
219
+ return rel === "" || (!/^\.\.([\\/]|$)/.test(rel) && !isAbsolute(rel));
220
+ });
221
+ if (!inside)
222
+ throw new HostError("invalid", `${what} must be in the plugin's package or data directory: ${path}`);
223
+ const stat = statSync(real);
224
+ if (!stat.isFile())
225
+ throw new HostError("invalid", `${what} is not a file: ${path}`);
226
+ if (stat.size > max)
227
+ throw new HostError("invalid", `${what} is larger than ${max / 1024 / 1024} MiB: ${path}`);
228
+ return real;
229
+ }
230
+ /** The `LUT_3D_SIZE` check of `plugin_files::lut` on the first 64 KiB. */
231
+ function cubeProblem(file) {
232
+ const buffer = Buffer.alloc(64 * 1024);
233
+ const fd = openSync(file, "r");
234
+ let read;
235
+ try {
236
+ read = readSync(fd, buffer, 0, buffer.length, 0);
237
+ }
238
+ finally {
239
+ closeSync(fd);
240
+ }
241
+ for (const line of buffer.subarray(0, read).toString("utf8").split("\n")) {
242
+ const [word, value] = line.trim().split(/\s+/);
243
+ if (word === "LUT_3D_SIZE") {
244
+ const size = Number(value);
245
+ return Number.isInteger(size) &&
246
+ size >= 2 &&
247
+ size <= VIDEO_PROCESSING_LIMITS.lutSize
248
+ ? null
249
+ : `LUT_3D_SIZE must be 2 to ${VIDEO_PROCESSING_LIMITS.lutSize}`;
250
+ }
251
+ if (word === "LUT_1D_SIZE")
252
+ return "a 1D LUT; lut3d needs LUT_3D_SIZE";
253
+ if (word !== undefined && word !== "" && Number.isFinite(Number(word)))
254
+ break;
255
+ }
256
+ return "no LUT_3D_SIZE line before the data";
257
+ }
191
258
  function initialSource() {
192
259
  return {
193
260
  kind: "media",
@@ -1311,6 +1378,78 @@ export class TestHost {
1311
1378
  state: "failed",
1312
1379
  error: { code, message },
1313
1380
  });
1381
+ // The player tells every shader handle too (`failVideoShaders`).
1382
+ // and, like the player, removes their lists.
1383
+ if (code === "unsupported") {
1384
+ for (const [id, paths] of this.videoShaders.handles)
1385
+ if (paths.length > 0)
1386
+ this.emit("video.shaders.status", {
1387
+ id,
1388
+ state: "failed",
1389
+ error: { code, message },
1390
+ });
1391
+ this.videoShaders.handles.clear();
1392
+ }
1393
+ }
1394
+ /**
1395
+ * The shader channel (`plugin.video.shaders(id)`, SDK 1.5.39): the lists
1396
+ * the player holds for this plugin's handles by id (the real paths, after
1397
+ * following links), and how many shaders other plugins hold, which count
1398
+ * against {@link VIDEO_PROCESSING_LIMITS}`.shaders` (`limit`). Files must
1399
+ * be under {@link TestHost.fileRoots}; under software rendering
1400
+ * (`videoRender.gpu = false`) a non-empty list fails with `unsupported`.
1401
+ *
1402
+ * @since 1.5.39
1403
+ */
1404
+ videoShaders = { handles: new Map(), others: 0 };
1405
+ /**
1406
+ * Where the plugin's own files may be: its package directory and its
1407
+ * data directory ({@link startPlugin} sets both when the list is still
1408
+ * empty). `lut3d` files and shader-channel files outside them fail with
1409
+ * `invalid`, as in the player.
1410
+ *
1411
+ * @since 1.5.39
1412
+ */
1413
+ fileRoots = [];
1414
+ /**
1415
+ * What the player pushes to a shader handle when mpv cannot compile a
1416
+ * shader (code `failed`; the player cannot tell whose shader failed, so
1417
+ * every handle with shaders hears it) or the player switches to software
1418
+ * rendering (`unsupported`; use {@link TestHost.failVideoRender}, which
1419
+ * also tells the handles). Pushed only to a handle that holds shaders.
1420
+ *
1421
+ * @capability None.
1422
+ *
1423
+ * @example Make the plugin's shader fail to compile
1424
+ * The plugin's shader handle reports `failed`, as when mpv rejects a
1425
+ * shader. The host pushes the failure only to a handle that holds
1426
+ * shaders, which its accepted `set` made it; the SDK updates the
1427
+ * handle's `status` when the push arrives, so the test settles first.
1428
+ * ```ts
1429
+ * import assert from "node:assert/strict";
1430
+ * import { join } from "node:path";
1431
+ * import { writeFileSync } from "node:fs";
1432
+ * declare const host: TestHost;
1433
+ * // ---cut---
1434
+ * const file = join(plugin.dataDirectory, "sharpen.glsl");
1435
+ * writeFileSync(file, "//!HOOK MAIN\n");
1436
+ * const shaders = plugin.video.shaders("sharpen");
1437
+ * shaders.set([file]); // the handle now holds a shader
1438
+ * await host.settle();
1439
+ * host.failVideoShaders("sharpen", "shader compile failed: ERROR: 0:3: ...");
1440
+ * await host.settle();
1441
+ * assert.equal(shaders.status.state, "failed");
1442
+ * ```
1443
+ *
1444
+ * @since 1.5.39
1445
+ */
1446
+ failVideoShaders(id, message) {
1447
+ if ((this.videoShaders.handles.get(id)?.length ?? 0) > 0)
1448
+ this.emit("video.shaders.status", {
1449
+ id,
1450
+ state: "failed",
1451
+ error: { code: "failed", message },
1452
+ });
1314
1453
  }
1315
1454
  /**
1316
1455
  * The player's processing (filter) state. Wire-level; may change in minor
@@ -1610,9 +1749,9 @@ export class TestHost {
1610
1749
  * import activate from "../main.ts";
1611
1750
  *
1612
1751
  * const host = new TestHost();
1613
- * host.videoRender.gpu = false; // software draws now (Windows)
1752
+ * host.videoRender.gpu = false; // software draws now (Linux)
1614
1753
  * host.rendering.startedWith = "software";
1615
- * host.rendering.reason = { code: "no-gpu-backend", message: "No GPU renderer on Windows" };
1754
+ * host.rendering.reason = { code: "no-gpu-backend", message: "this player has no GPU renderer on Linux yet" };
1616
1755
  * host.rendering.gpu = { available: false, reason: host.rendering.reason };
1617
1756
  * const { stop } = await startPlugin(activate, {
1618
1757
  * directory: new URL("..", import.meta.url),
@@ -1620,7 +1759,7 @@ export class TestHost {
1620
1759
  * });
1621
1760
  * const item = host.command("gpu-rendering");
1622
1761
  * assert.equal(item.state.enabled, false);
1623
- * assert.equal(item.state.reason, "No GPU renderer on Windows");
1762
+ * assert.equal(item.state.reason, "this player has no GPU renderer on Linux yet");
1624
1763
  * await stop();
1625
1764
  * ```
1626
1765
  *
@@ -3985,6 +4124,34 @@ export class TestHost {
3985
4124
  this.setVideoRenderOwner(null);
3986
4125
  return null;
3987
4126
  },
4127
+ // The player's capability layer and `video_shaders.rs`.
4128
+ "video.shaders.set": (p) => {
4129
+ const idProblem = shaderIdProblem(p.id);
4130
+ if (idProblem)
4131
+ throw new HostError("invalid", idProblem);
4132
+ if (!Array.isArray(p.paths) ||
4133
+ p.paths.some((path) => typeof path !== "string"))
4134
+ throw new HostError("invalid", "paths must be an array of paths");
4135
+ const id = p.id;
4136
+ const list = p.paths;
4137
+ const listProblem = shaderListProblem(list);
4138
+ if (listProblem?.startsWith("at most"))
4139
+ throw new HostError("limit", listProblem);
4140
+ const paths = list.map((path) => ownFile(path, this.fileRoots, "shader", VIDEO_PROCESSING_LIMITS.shaderBytes));
4141
+ if (paths.length > 0 && !this.videoRender.gpu)
4142
+ throw new HostError("unsupported", "shaders need GPU rendering; the player renders in software");
4143
+ const mine = [...this.videoShaders.handles]
4144
+ .filter(([other]) => other !== id)
4145
+ .reduce((sum, [, held]) => sum + held.length, 0);
4146
+ const others = mine + this.videoShaders.others;
4147
+ if (others + paths.length > VIDEO_PROCESSING_LIMITS.shaders)
4148
+ throw new HostError("limit", `at most ${VIDEO_PROCESSING_LIMITS.shaders} shaders across all plugins: ${others} are in use, this list has ${paths.length}`);
4149
+ if (paths.length > 0)
4150
+ this.videoShaders.handles.set(id, paths);
4151
+ else
4152
+ this.videoShaders.handles.delete(id);
4153
+ return { state: this.source.phase === "ready" ? "applied" : "waiting" };
4154
+ },
3988
4155
  "timeline.set": (p) => {
3989
4156
  if (p.sourceId !== undefined &&
3990
4157
  p.sourceId !== this.source.stamp.generation)
@@ -4212,6 +4379,30 @@ export class TestHost {
4212
4379
  status: "failed",
4213
4380
  error: { code: "invalid", message: (problem ?? blend ?? delay) },
4214
4381
  });
4382
+ // `lut3d` files: the plugin's own `.cube` files (`plugin_files.rs`).
4383
+ if (stage === "video")
4384
+ for (const node of (p.nodes ?? []))
4385
+ if (node.kind === "lut3d") {
4386
+ let cube;
4387
+ try {
4388
+ const real = ownFile(node.file, this.fileRoots, "lut3d file", VIDEO_PROCESSING_LIMITS.lutBytes);
4389
+ cube = /\.cube$/i.test(real)
4390
+ ? cubeProblem(real)
4391
+ : "lut3d file must be a .cube file";
4392
+ }
4393
+ catch (error) {
4394
+ const { code, message } = error;
4395
+ return finish({ status: "failed", error: { code, message } });
4396
+ }
4397
+ if (cube)
4398
+ return finish({
4399
+ status: "failed",
4400
+ error: {
4401
+ code: "invalid",
4402
+ message: `lut3d file ${node.file}: ${cube}`,
4403
+ },
4404
+ });
4405
+ }
4215
4406
  if (this.source.phase !== "ready")
4216
4407
  return finish({
4217
4408
  status: "failed",
@@ -4844,6 +5035,8 @@ export async function startPlugin(activate, options = {}) {
4844
5035
  for (const [key, value] of options.sessionStorage ?? [])
4845
5036
  host.sessionStorage.set(key, sortedJson(value));
4846
5037
  mkdirSync(data, { recursive: true });
5038
+ if (host.fileRoots.length === 0)
5039
+ host.fileRoots = [directory, data];
4847
5040
  const instance = await createPlugin(host, manifest, directory, data, host.app);
4848
5041
  await activate(instance.plugin);
4849
5042
  // Like the player: a position or resolver another plugin holds is not an