@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.
@@ -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,9 +82,12 @@ 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";
89
+ import { evaluate } from "./when.js";
90
+ import { WAKE_EVENTS } from "./runtime.js";
88
91
  import { THEMES } from "./appearance.js";
89
92
  import { Catalog, format as formatTemplate, HOST_GROUPS, HOST_ZH_HANS, LOCALES, fitColumns, isTextValue, LIST_SEPARATORS, loadPackageCatalog, menuKeys, renderTemplateArgs, renderText, resolveTexts, TEXT_LIMITS, textKey, textProblem, } from "../i18n.js";
90
93
  /** Throw one of these from `host.handle(...)` to simulate a definite refusal. */
@@ -107,6 +110,8 @@ export { SECRET_LIMITS };
107
110
  export { SESSION_STORAGE_LIMITS };
108
111
  /** The player's limits on loaded subtitle files, which the test host enforces as well. */
109
112
  export { SUBTITLE_LIMITS };
113
+ /** The player's bounds on convolution, lut3d and shader-channel requests, which the test host enforces as well. */
114
+ export { VIDEO_PROCESSING_LIMITS };
110
115
  /** The names bindings are made of. */
111
116
  export { BINDING_INPUTS };
112
117
  /** The colours of the player's palettes, by `Theme` token. */
@@ -188,6 +193,70 @@ const sessionCost = (key, value) => Buffer.byteLength(key) +
188
193
  SESSION_STORAGE_LIMITS.entryBytes;
189
194
  const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
190
195
  const tick = () => new Promise((resolve) => setImmediate(resolve));
196
+ /**
197
+ * The player's `playback/plugin_files.rs`: a file the plugin names for its
198
+ * video processing is an absolute path inside one of `roots` after
199
+ * following links, a regular file of at most `max` bytes. Returns the real
200
+ * path or throws the player's error.
201
+ */
202
+ function ownFile(path, roots, what, max) {
203
+ if (!isAbsolute(path))
204
+ throw new HostError("invalid", `${what} path must be absolute: ${path}`);
205
+ let real;
206
+ try {
207
+ real = realpathSync(path);
208
+ }
209
+ catch {
210
+ throw new HostError("missing", `${what} not found: ${path}`);
211
+ }
212
+ const inside = roots.some((root) => {
213
+ let base;
214
+ try {
215
+ base = realpathSync(root);
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ const rel = relative(base, real);
221
+ return rel === "" || (!/^\.\.([\\/]|$)/.test(rel) && !isAbsolute(rel));
222
+ });
223
+ if (!inside)
224
+ throw new HostError("invalid", `${what} must be in the plugin's package or data directory: ${path}`);
225
+ const stat = statSync(real);
226
+ if (!stat.isFile())
227
+ throw new HostError("invalid", `${what} is not a file: ${path}`);
228
+ if (stat.size > max)
229
+ throw new HostError("invalid", `${what} is larger than ${max / 1024 / 1024} MiB: ${path}`);
230
+ return real;
231
+ }
232
+ /** The `LUT_3D_SIZE` check of `plugin_files::lut` on the first 64 KiB. */
233
+ function cubeProblem(file) {
234
+ const buffer = Buffer.alloc(64 * 1024);
235
+ const fd = openSync(file, "r");
236
+ let read;
237
+ try {
238
+ read = readSync(fd, buffer, 0, buffer.length, 0);
239
+ }
240
+ finally {
241
+ closeSync(fd);
242
+ }
243
+ for (const line of buffer.subarray(0, read).toString("utf8").split("\n")) {
244
+ const [word, value] = line.trim().split(/\s+/);
245
+ if (word === "LUT_3D_SIZE") {
246
+ const size = Number(value);
247
+ return Number.isInteger(size) &&
248
+ size >= 2 &&
249
+ size <= VIDEO_PROCESSING_LIMITS.lutSize
250
+ ? null
251
+ : `LUT_3D_SIZE must be 2 to ${VIDEO_PROCESSING_LIMITS.lutSize}`;
252
+ }
253
+ if (word === "LUT_1D_SIZE")
254
+ return "a 1D LUT; lut3d needs LUT_3D_SIZE";
255
+ if (word !== undefined && word !== "" && Number.isFinite(Number(word)))
256
+ break;
257
+ }
258
+ return "no LUT_3D_SIZE line before the data";
259
+ }
191
260
  function initialSource() {
192
261
  return {
193
262
  kind: "media",
@@ -343,6 +412,16 @@ export class TestHost {
343
412
  * @since 1.5.38
344
413
  */
345
414
  listening = new Set();
415
+ /**
416
+ * Why the plugin listens to each event of {@link TestHost.listening}:
417
+ * every listener and every binding that reads its state, as the
418
+ * player's log says them (`a listener added at main.ts:12`,
419
+ * `command "Save" enabled is a function that reads plugin.player.state
420
+ * (main.ts:40) — use plugin.when`), in the order they came.
421
+ *
422
+ * @since 1.5.40
423
+ */
424
+ listeningWhy = new Map();
346
425
  /**
347
426
  * The player's source facts (phase, position, duration, paused, rate,
348
427
  * audio tracks, loop, ...). Wire-level shape; change it with
@@ -1311,6 +1390,78 @@ export class TestHost {
1311
1390
  state: "failed",
1312
1391
  error: { code, message },
1313
1392
  });
1393
+ // The player tells every shader handle too (`failVideoShaders`).
1394
+ // and, like the player, removes their lists.
1395
+ if (code === "unsupported") {
1396
+ for (const [id, paths] of this.videoShaders.handles)
1397
+ if (paths.length > 0)
1398
+ this.emit("video.shaders.status", {
1399
+ id,
1400
+ state: "failed",
1401
+ error: { code, message },
1402
+ });
1403
+ this.videoShaders.handles.clear();
1404
+ }
1405
+ }
1406
+ /**
1407
+ * The shader channel (`plugin.video.shaders(id)`, SDK 1.5.39): the lists
1408
+ * the player holds for this plugin's handles by id (the real paths, after
1409
+ * following links), and how many shaders other plugins hold, which count
1410
+ * against {@link VIDEO_PROCESSING_LIMITS}`.shaders` (`limit`). Files must
1411
+ * be under {@link TestHost.fileRoots}; under software rendering
1412
+ * (`videoRender.gpu = false`) a non-empty list fails with `unsupported`.
1413
+ *
1414
+ * @since 1.5.39
1415
+ */
1416
+ videoShaders = { handles: new Map(), others: 0 };
1417
+ /**
1418
+ * Where the plugin's own files may be: its package directory and its
1419
+ * data directory ({@link startPlugin} sets both when the list is still
1420
+ * empty). `lut3d` files and shader-channel files outside them fail with
1421
+ * `invalid`, as in the player.
1422
+ *
1423
+ * @since 1.5.39
1424
+ */
1425
+ fileRoots = [];
1426
+ /**
1427
+ * What the player pushes to a shader handle when mpv cannot compile a
1428
+ * shader (code `failed`; the player cannot tell whose shader failed, so
1429
+ * every handle with shaders hears it) or the player switches to software
1430
+ * rendering (`unsupported`; use {@link TestHost.failVideoRender}, which
1431
+ * also tells the handles). Pushed only to a handle that holds shaders.
1432
+ *
1433
+ * @capability None.
1434
+ *
1435
+ * @example Make the plugin's shader fail to compile
1436
+ * The plugin's shader handle reports `failed`, as when mpv rejects a
1437
+ * shader. The host pushes the failure only to a handle that holds
1438
+ * shaders, which its accepted `set` made it; the SDK updates the
1439
+ * handle's `status` when the push arrives, so the test settles first.
1440
+ * ```ts
1441
+ * import assert from "node:assert/strict";
1442
+ * import { join } from "node:path";
1443
+ * import { writeFileSync } from "node:fs";
1444
+ * declare const host: TestHost;
1445
+ * // ---cut---
1446
+ * const file = join(plugin.dataDirectory, "sharpen.glsl");
1447
+ * writeFileSync(file, "//!HOOK MAIN\n");
1448
+ * const shaders = plugin.video.shaders("sharpen");
1449
+ * shaders.set([file]); // the handle now holds a shader
1450
+ * await host.settle();
1451
+ * host.failVideoShaders("sharpen", "shader compile failed: ERROR: 0:3: ...");
1452
+ * await host.settle();
1453
+ * assert.equal(shaders.status.state, "failed");
1454
+ * ```
1455
+ *
1456
+ * @since 1.5.39
1457
+ */
1458
+ failVideoShaders(id, message) {
1459
+ if ((this.videoShaders.handles.get(id)?.length ?? 0) > 0)
1460
+ this.emit("video.shaders.status", {
1461
+ id,
1462
+ state: "failed",
1463
+ error: { code: "failed", message },
1464
+ });
1314
1465
  }
1315
1466
  /**
1316
1467
  * The player's processing (filter) state. Wire-level; may change in minor
@@ -1610,9 +1761,9 @@ export class TestHost {
1610
1761
  * import activate from "../main.ts";
1611
1762
  *
1612
1763
  * const host = new TestHost();
1613
- * host.videoRender.gpu = false; // software draws now (Windows)
1764
+ * host.videoRender.gpu = false; // software draws now (Linux)
1614
1765
  * host.rendering.startedWith = "software";
1615
- * host.rendering.reason = { code: "no-gpu-backend", message: "No GPU renderer on Windows" };
1766
+ * host.rendering.reason = { code: "no-gpu-backend", message: "this player has no GPU renderer on Linux yet" };
1616
1767
  * host.rendering.gpu = { available: false, reason: host.rendering.reason };
1617
1768
  * const { stop } = await startPlugin(activate, {
1618
1769
  * directory: new URL("..", import.meta.url),
@@ -1620,7 +1771,7 @@ export class TestHost {
1620
1771
  * });
1621
1772
  * const item = host.command("gpu-rendering");
1622
1773
  * assert.equal(item.state.enabled, false);
1623
- * assert.equal(item.state.reason, "No GPU renderer on Windows");
1774
+ * assert.equal(item.state.reason, "this player has no GPU renderer on Linux yet");
1624
1775
  * await stop();
1625
1776
  * ```
1626
1777
  *
@@ -3386,6 +3537,79 @@ export class TestHost {
3386
3537
  },
3387
3538
  };
3388
3539
  }
3540
+ /** A command's state as the player shows it: its conditions evaluated. */
3541
+ #commandShown(sent, when) {
3542
+ if (!when)
3543
+ return sent;
3544
+ const source = this.source;
3545
+ const capabilities = source.capabilities;
3546
+ const facts = {
3547
+ phase: source.phase,
3548
+ paused: source.paused,
3549
+ kind: source.kind === "producer" ? "stream" : "media",
3550
+ can: {
3551
+ pause: capabilities.pause,
3552
+ seek: capabilities.seek,
3553
+ rate: capabilities.rate,
3554
+ restart: capabilities.restart,
3555
+ close: capabilities.close,
3556
+ snapshot: capabilities.snapshotSource,
3557
+ // As the player: the flags merged into the source state.
3558
+ next: source.sequence?.next === true,
3559
+ previous: source.sequence?.previous === true,
3560
+ },
3561
+ fullscreen: this.mainWindow.fullscreen,
3562
+ theme: this.appearance,
3563
+ language: this.language,
3564
+ };
3565
+ const shown = { ...sent };
3566
+ if (when.enabled) {
3567
+ shown.enabled = evaluate(when.enabled, facts);
3568
+ if (shown.enabled && !shown.busy)
3569
+ shown.reason = null;
3570
+ }
3571
+ if (when.checked)
3572
+ shown.checked = evaluate(when.checked, facts);
3573
+ return shown;
3574
+ }
3575
+ /**
3576
+ * Why the player would keep this plugin running while nothing uses it
3577
+ * (lazy activation, player 0.5.21), one line per reason, as the player's
3578
+ * log says it: an event it listens to that is not one the player wakes
3579
+ * it for (with what listens and where it was added), its mounted panel,
3580
+ * its open windows. Empty: the player may stop it while idle and start
3581
+ * it again on its next use. Filter chains, streams, jobs and the like
3582
+ * (which also keep a plugin running) are not listed.
3583
+ *
3584
+ * @capability None.
3585
+ * @since 1.5.40
3586
+ *
3587
+ * @example Check that a plugin can be stopped while idle
3588
+ * The test fails if the plugin keeps running for no reason after it
3589
+ * starts, and names the reason (a command whose `enabled` reads
3590
+ * `plugin.player.state`, say: give it a `plugin.when` condition). The
3591
+ * player stops such a plugin while nothing uses it and starts it again
3592
+ * on its next use, so it costs no process meanwhile.
3593
+ * ```ts
3594
+ * import assert from "node:assert/strict";
3595
+ * declare const host: TestHost;
3596
+ * // ---cut---
3597
+ * assert.deepEqual(host.keepsRunning, []);
3598
+ * ```
3599
+ */
3600
+ get keepsRunning() {
3601
+ const reasons = [];
3602
+ for (const event of this.listening)
3603
+ if (!WAKE_EVENTS.includes(event))
3604
+ for (const why of this.listeningWhy.get(event) ?? ["a listener"])
3605
+ reasons.push(`listens to ${event}: ${why}`);
3606
+ if (this.panel)
3607
+ reasons.push("a mounted panel");
3608
+ for (const [id, window] of this.windows)
3609
+ if (window.visible)
3610
+ reasons.push(`an open window (${id})`);
3611
+ return reasons;
3612
+ }
3389
3613
  #defaults = {
3390
3614
  "events.subscribe": (p) => {
3391
3615
  const event = String(p.event);
@@ -3394,10 +3618,15 @@ export class TestHost {
3394
3618
  this.listening.delete(event);
3395
3619
  else
3396
3620
  this.listening.add(event);
3621
+ if (p.mirror !== true && Array.isArray(p.why))
3622
+ this.listeningWhy.set(event, p.why.map(String));
3623
+ else
3624
+ this.listeningWhy.delete(event);
3397
3625
  },
3398
3626
  "events.unsubscribe": (p) => {
3399
3627
  this.subscriptions.delete(String(p.event));
3400
3628
  this.listening.delete(String(p.event));
3629
+ this.listeningWhy.delete(String(p.event));
3401
3630
  },
3402
3631
  "commands.register": (p) => {
3403
3632
  const id = String(p.id);
@@ -3438,12 +3667,19 @@ export class TestHost {
3438
3667
  for (const segment of [declaration.title, ...(declaration.path ?? [])])
3439
3668
  this.#declared(segment, "a menu text");
3440
3669
  this.#checkReason(p.state);
3670
+ let sent = p.state;
3441
3671
  this.commands.set(id, {
3442
3672
  declaration,
3443
3673
  get display() {
3444
3674
  return host.#display(declaration.path ?? [], declaration.title, this.state.reason);
3445
3675
  },
3446
- state: p.state,
3676
+ ...(p.when ? { when: p.when } : {}),
3677
+ get state() {
3678
+ return host.#commandShown(sent, this.when);
3679
+ },
3680
+ set state(state) {
3681
+ sent = state;
3682
+ },
3447
3683
  revision: Number(p.revision),
3448
3684
  async run() {
3449
3685
  if (this.state.busy || !this.state.enabled)
@@ -3465,6 +3701,10 @@ export class TestHost {
3465
3701
  this.#checkReason(p.state);
3466
3702
  command.revision = Number(p.revision);
3467
3703
  command.state = p.state;
3704
+ if (p.when)
3705
+ command.when = p.when;
3706
+ else
3707
+ delete command.when;
3468
3708
  return { revision: command.revision };
3469
3709
  },
3470
3710
  "ui.open": (p) => {
@@ -3985,6 +4225,34 @@ export class TestHost {
3985
4225
  this.setVideoRenderOwner(null);
3986
4226
  return null;
3987
4227
  },
4228
+ // The player's capability layer and `video_shaders.rs`.
4229
+ "video.shaders.set": (p) => {
4230
+ const idProblem = shaderIdProblem(p.id);
4231
+ if (idProblem)
4232
+ throw new HostError("invalid", idProblem);
4233
+ if (!Array.isArray(p.paths) ||
4234
+ p.paths.some((path) => typeof path !== "string"))
4235
+ throw new HostError("invalid", "paths must be an array of paths");
4236
+ const id = p.id;
4237
+ const list = p.paths;
4238
+ const listProblem = shaderListProblem(list);
4239
+ if (listProblem?.startsWith("at most"))
4240
+ throw new HostError("limit", listProblem);
4241
+ const paths = list.map((path) => ownFile(path, this.fileRoots, "shader", VIDEO_PROCESSING_LIMITS.shaderBytes));
4242
+ if (paths.length > 0 && !this.videoRender.gpu)
4243
+ throw new HostError("unsupported", "shaders need GPU rendering; the player renders in software");
4244
+ const mine = [...this.videoShaders.handles]
4245
+ .filter(([other]) => other !== id)
4246
+ .reduce((sum, [, held]) => sum + held.length, 0);
4247
+ const others = mine + this.videoShaders.others;
4248
+ if (others + paths.length > VIDEO_PROCESSING_LIMITS.shaders)
4249
+ throw new HostError("limit", `at most ${VIDEO_PROCESSING_LIMITS.shaders} shaders across all plugins: ${others} are in use, this list has ${paths.length}`);
4250
+ if (paths.length > 0)
4251
+ this.videoShaders.handles.set(id, paths);
4252
+ else
4253
+ this.videoShaders.handles.delete(id);
4254
+ return { state: this.source.phase === "ready" ? "applied" : "waiting" };
4255
+ },
3988
4256
  "timeline.set": (p) => {
3989
4257
  if (p.sourceId !== undefined &&
3990
4258
  p.sourceId !== this.source.stamp.generation)
@@ -4212,6 +4480,30 @@ export class TestHost {
4212
4480
  status: "failed",
4213
4481
  error: { code: "invalid", message: (problem ?? blend ?? delay) },
4214
4482
  });
4483
+ // `lut3d` files: the plugin's own `.cube` files (`plugin_files.rs`).
4484
+ if (stage === "video")
4485
+ for (const node of (p.nodes ?? []))
4486
+ if (node.kind === "lut3d") {
4487
+ let cube;
4488
+ try {
4489
+ const real = ownFile(node.file, this.fileRoots, "lut3d file", VIDEO_PROCESSING_LIMITS.lutBytes);
4490
+ cube = /\.cube$/i.test(real)
4491
+ ? cubeProblem(real)
4492
+ : "lut3d file must be a .cube file";
4493
+ }
4494
+ catch (error) {
4495
+ const { code, message } = error;
4496
+ return finish({ status: "failed", error: { code, message } });
4497
+ }
4498
+ if (cube)
4499
+ return finish({
4500
+ status: "failed",
4501
+ error: {
4502
+ code: "invalid",
4503
+ message: `lut3d file ${node.file}: ${cube}`,
4504
+ },
4505
+ });
4506
+ }
4215
4507
  if (this.source.phase !== "ready")
4216
4508
  return finish({
4217
4509
  status: "failed",
@@ -4750,6 +5042,24 @@ export class TestHost {
4750
5042
  },
4751
5043
  };
4752
5044
  }
5045
+ /** Lines `vestra-plugin test` printed already (one per plugin and reasons). */
5046
+ const told = new Set();
5047
+ /**
5048
+ * Under `vestra-plugin test` (`VESTRA_PLUGIN_RESIDENCY=1`): say once per
5049
+ * test file why the plugin would keep running after it starts.
5050
+ */
5051
+ function tellResidency(host) {
5052
+ if (process.env.VESTRA_PLUGIN_RESIDENCY !== "1")
5053
+ return;
5054
+ const reasons = host.keepsRunning;
5055
+ if (!reasons.length)
5056
+ return;
5057
+ const line = `${host.manifestId} keeps running while idle (lazy activation): ${reasons.join("; ")}`;
5058
+ if (told.has(line))
5059
+ return;
5060
+ told.add(line);
5061
+ console.log(`# ${line}`);
5062
+ }
4753
5063
  /**
4754
5064
  * Activate a plugin against a test host, the way the player's runner does:
4755
5065
  * build the `plugin` object, call `activate(plugin)`, wait for every
@@ -4844,6 +5154,8 @@ export async function startPlugin(activate, options = {}) {
4844
5154
  for (const [key, value] of options.sessionStorage ?? [])
4845
5155
  host.sessionStorage.set(key, sortedJson(value));
4846
5156
  mkdirSync(data, { recursive: true });
5157
+ if (host.fileRoots.length === 0)
5158
+ host.fileRoots = [directory, data];
4847
5159
  const instance = await createPlugin(host, manifest, directory, data, host.app);
4848
5160
  await activate(instance.plugin);
4849
5161
  // Like the player: a position or resolver another plugin holds is not an
@@ -4859,6 +5171,7 @@ export async function startPlugin(activate, options = {}) {
4859
5171
  }
4860
5172
  }
4861
5173
  await host.settle();
5174
+ tellResidency(host);
4862
5175
  return {
4863
5176
  /** The test host the plugin talks to. */
4864
5177
  host,