@vestra-player/sdk 1.5.52 → 1.5.54

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.54
4
+
5
+ **Who must change: nobody, but check cleanup functions passed directly to `onDispose`.** Additive: `Plugin.stopReason`, the types `StopReason` and `DisposeInfo`, an optional argument for `onDispose` cleanups, `TestHost.previousCrash`, an optional `code` for `TestHost.failVideoShaders` and `stop({ reason })` / `disconnect()` from `startPlugin`; `apiVersion` stays `vestra/1`, no new capability, and existing code still compiles. One runtime difference: with this SDK in the player (or in `TestHost`) an `onDispose` cleanup is called with one argument, the `DisposeInfo`. A function that takes optional parameters of its own and is passed directly now receives it as its first argument: `plugin.onDispose(controller.abort.bind(controller))` aborts with the `DisposeInfo` as the abort reason, `onDispose(save)` with `save(force?: boolean)` gets an object as `force`. Wrap such a function (`() => controller.abort()`). The stop reasons are planned for player 0.6.4; `previous-crash` comes from player 0.6.3.
6
+
7
+ - **Why the plugin stops** (API request of the Shader Upscaling author). A plugin can tell an orderly stop from a lost player: `plugin.stopReason` is set when the stop begins, and every `onDispose` cleanup receives a `DisposeInfo` (`{ stopping, reason }`); a cleanup without parameters works as before. `stopping` is `false` for a child scope the plugin's own code disposed. Planned for player 0.6.4 (not released yet): the player sends `quit`, `disable`, `uninstall`, `reload`, `upgrade`, `idle` or `failed` with `runtime.stop`, and its runner passes `host-lost` when its input closes (or its output breaks) without a `runtime.stop` (the player crashed or was killed; requests are refused, clean up locally only). The runner hands the reason to the SDK (`PluginInstance.dispose({ reason })` in `@vestra-player/sdk/runtime`; a reason the SDK does not know reads as `undefined`).
8
+ - **Older players.** Players up to 0.6.3 give no reason for any stop, a lost player included. One whose own SDK is older than 1.5.54 (0.6.3 and earlier) calls the cleanup **without an argument** and has no `plugin.stopReason` (it reads `undefined`): the parameter is optional (`info?: DisposeInfo`), so read it as `info?.reason`.
9
+ - **`previous-crash`** (player 0.6.3) is documented on `VideoShaders`, `RenderOptions` and in the `PluginError` table: after a run of the player that ended while plugin shaders were drawing (a crash, the process being ended, a lost GPU device), the player refuses every non-empty shader list, the render options' `shaders` included, with `previous-crash` and a message naming the plugins; one such end keeps them off for the next start, two within a day until the GPU, its driver or the player changes, or for 7 days. Clearing still works, the player shows one OSD line itself, and plugins should not set the list again while it is refused (the SDK does not retry a `failed` handle). The render options send every value set so far with each `set`, the refused `shaders` included: include `shaders: []` in the next `set` to go on without them.
10
+ - **`TestHost`**: `previousCrash` (a message, default `null`) refuses non-empty `video.shaders` lists and render options `shaders` with `previous-crash` as the player does, after the files are checked and before the GPU check. `failVideoShaders(id, message, code?)` (`failed` by default, or `previous-crash`) and `failVideoRender("previous-crash", …)` are shortcuts that only set the handle's status: the real player refuses the request instead, which `previousCrash` plays. `startPlugin`'s `stop` still declares no parameter (`stop.length === 0`, `t.after(stop)`, `promise.then(stop)`) and takes an optional `{ reason }`; `disconnect()` stops the plugin as when the player is lost (`host-lost`; requests in flight and later ones are refused with `disabled`, as the runner does).
11
+ - **`TestHost` cleanup never fails a test.** `close()` (and so `stop()`) retries removing its temporary directory (`maxRetries: 5`, 100 ms apart) and leaves it to the system when Windows still refuses (an intermittent `ENOTEMPTY` on Windows CI, plugin repository run 41351136515).
12
+ - _Docs_: [The plugin model](docs/explanation/plugins.md#stopping), [Host and Node](docs/explanation/host-and-node.md#stopping-a-plugin), [Video and audio processing](docs/explanation/processing.md#shaders), [Test a plugin](docs/how-to/testing.md) and [Reference: testing](docs/reference/testing.md) describe them.
13
+
14
+ ## 1.5.53
15
+
16
+ **Who must change: nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability, no player version needed.
17
+
18
+ - **`BUILTIN_COMMANDS`** lists the Info plugin's (`vestra.info` 0.1.11, player 0.6.3) top-level **Check for Updates…** (`info.update`, no default key), right after About Vestra Player… (the player's agreement test failed on it). The built-in command table in [Reference: commands](docs/reference/commands.md) follows.
19
+
3
20
  ## 1.5.52
4
21
 
5
22
  **Who must change: nobody.** Additive: `WindowState` gains `maximized`, `apiVersion` stays `vestra/1`, no new capability. Players before 0.6.3 do not report it and it reads `false` there; a plugin that depends on it sets `"minPlayerVersion": "0.6.3"`. A test that deep-compares the whole `plugin.window.state` now also sees `maximized: false`.
package/dist/commands.js CHANGED
@@ -589,6 +589,7 @@ vestra.audio | audio.sync.reset | Reset ▸ Audio Sync | ?
589
589
  vestra.audio | audio.reset | Reset ▸ Audio Processing
590
590
  vestra.visualizer | reset-logo | Reset ▸ Idle Logo
591
591
  vestra.info | info.about | About Vestra Player… | F1
592
+ vestra.info | info.update | Check for Updates…
592
593
  host | quit | Quit | Cmd+Q
593
594
  host | enter-fullscreen | (Enter Fullscreen)
594
595
  host | exit-fullscreen | (Leave Fullscreen)
Binary file
@@ -19,7 +19,17 @@ export interface PluginInstance {
19
19
  * `RuntimeOptions.strictDeclarations` is set.
20
20
  */
21
21
  activated(): Promise<void>;
22
- dispose(): Promise<void>;
22
+ /**
23
+ * Stop the plugin: dispose the root scope, then stop the runtime.
24
+ * `info.reason` (SDK 1.5.54) is why: the runner passes `runtime.stop`'s
25
+ * `data.reason` (planned for player 0.6.4), or `"host-lost"` when its input closed
26
+ * without a `runtime.stop`. It reaches `plugin.stopReason` and every
27
+ * `onDispose` cleanup; a reason the SDK does not know reads as
28
+ * `undefined`. A second call runs no cleanup again and keeps the first reason.
29
+ */
30
+ dispose(info?: {
31
+ reason?: unknown;
32
+ }): Promise<void>;
23
33
  }
24
34
  export declare function createPlugin(transport: Transport, manifest: PluginManifest, directory: string, dataDirectory?: string,
25
35
  /** `plugin.app` (the runner passes the player's `VESTRA_APP_INFO`). */
@@ -1,4 +1,4 @@
1
- import { failure, Runtime } from "./runtime.js";
1
+ import { failure, Runtime, stopReason } from "./runtime.js";
2
2
  import { ScopeImpl } from "./scope.js";
3
3
  import { playerOf } from "./player.js";
4
4
  import { windowOf } from "./window.js";
@@ -27,7 +27,8 @@ options) {
27
27
  return {
28
28
  plugin: root,
29
29
  activated: () => runtime.activated(),
30
- dispose: async () => {
30
+ dispose: async (info) => {
31
+ runtime.stopping ??= { reason: stopReason(info?.reason) };
31
32
  await root.dispose();
32
33
  runtime.stop();
33
34
  },
@@ -1,5 +1,5 @@
1
1
  import type { Json, LocalServices, Transport, Wire } from "../wire.ts";
2
- import type { App, Disposable, Locale, PluginError, SystemInfo } from "./types.ts";
2
+ import type { App, Disposable, Locale, PluginError, StopReason, SystemInfo } from "./types.ts";
3
3
  /**
4
4
  * Why something listens, for the player's log line that says why a plugin
5
5
  * keeps running (`events.subscribe { why }`, player 0.5.21): computed
@@ -46,6 +46,8 @@ export interface RuntimeOptions {
46
46
  */
47
47
  strictDeclarations?: boolean;
48
48
  }
49
+ /** A stop reason the runner passed on, if this SDK knows it. */
50
+ export declare function stopReason(value: unknown): StopReason | undefined;
49
51
  export declare const same: (a: Json | undefined, b: Json | undefined) => boolean;
50
52
  /** One value in flight, only the newest waiting. Each push learns its fate. */
51
53
  export declare class Latest<T> {
@@ -73,6 +75,13 @@ export declare class Runtime {
73
75
  readonly views: Map<string, (callback: string, args: Json[]) => void>;
74
76
  readonly commands: Map<string, () => void>;
75
77
  readonly services: Map<string, unknown>;
78
+ /**
79
+ * Set when the player stops the plugin (`PluginInstance.dispose`), before
80
+ * the root scope is disposed: `DisposeInfo` and `plugin.stopReason`.
81
+ */
82
+ stopping: {
83
+ reason: StopReason | undefined;
84
+ } | undefined;
76
85
  /** `plugin.app`, from the start-up handshake; `system()` asks the player. */
77
86
  readonly app: App;
78
87
  /** `plugin.i18n.locale`, from the start-up handshake; English when unknown. */
@@ -96,6 +96,23 @@ const DECLARATION_REFUSALS = new Set([
96
96
  "conflict",
97
97
  "limit",
98
98
  ]);
99
+ /** The reasons `StopReason` names; anything else reads as `undefined`. */
100
+ const STOP_REASONS = new Set([
101
+ "quit",
102
+ "disable",
103
+ "uninstall",
104
+ "reload",
105
+ "upgrade",
106
+ "idle",
107
+ "failed",
108
+ "host-lost",
109
+ ]);
110
+ /** A stop reason the runner passed on, if this SDK knows it. */
111
+ export function stopReason(value) {
112
+ return typeof value === "string" && STOP_REASONS.has(value)
113
+ ? value
114
+ : undefined;
115
+ }
99
116
  export const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
100
117
  /** One value in flight, only the newest waiting. Each push learns its fate. */
101
118
  export class Latest {
@@ -191,6 +208,11 @@ export class Runtime {
191
208
  views = new Map();
192
209
  commands = new Map();
193
210
  services = new Map();
211
+ /**
212
+ * Set when the player stops the plugin (`PluginInstance.dispose`), before
213
+ * the root scope is disposed: `DisposeInfo` and `plugin.stopReason`.
214
+ */
215
+ stopping;
194
216
  /** `plugin.app`, from the start-up handshake; `system()` asks the player. */
195
217
  app;
196
218
  /** `plugin.i18n.locale`, from the start-up handshake; English when unknown. */
@@ -1,11 +1,12 @@
1
1
  import type { Change, Runtime } from "./runtime.ts";
2
- import type { Disposable, Plugin, PluginError, Scope } from "./types.ts";
2
+ import type { Disposable, DisposeInfo, Plugin, PluginError, Scope, StopReason } from "./types.ts";
3
3
  export declare class ScopeImpl implements Plugin {
4
4
  #private;
5
5
  readonly runtime: Runtime;
6
6
  constructor(runtime: Runtime, parent?: ScopeImpl);
7
7
  get id(): string;
8
8
  get dataDirectory(): string;
9
+ get stopReason(): StopReason | undefined;
9
10
  get disposed(): boolean;
10
11
  readonly commands: import("./types.ts").Commands;
11
12
  readonly when: import("./types.ts").When;
@@ -49,7 +50,7 @@ export declare class ScopeImpl implements Plugin {
49
50
  add<T extends Disposable>(item: T): T;
50
51
  /** Handles call this when disposed directly, so the scope forgets them. */
51
52
  forget(item: Disposable): void;
52
- onDispose(cleanup: () => void | Promise<void>): Disposable;
53
+ onDispose(cleanup: ((info?: DisposeInfo) => void | Promise<void>) | (() => void | Promise<void>)): Disposable;
53
54
  onError(listener: (error: PluginError) => void): Disposable;
54
55
  report(error: unknown): void;
55
56
  /** Invoke plugin code on its behalf: report failures, then refresh bindings. */
@@ -39,6 +39,9 @@ export class ScopeImpl {
39
39
  throw failure("unavailable", "the player provided no data directory");
40
40
  return directory;
41
41
  }
42
+ get stopReason() {
43
+ return this.runtime.stopping?.reason;
44
+ }
42
45
  get disposed() {
43
46
  return this.#disposed || (this.#parent?.disposed ?? false);
44
47
  }
@@ -102,7 +105,12 @@ export class ScopeImpl {
102
105
  return;
103
106
  done = true;
104
107
  this.forget(item);
105
- await cleanup();
108
+ const stopping = this.runtime.stopping;
109
+ await cleanup(Object.freeze(stopping
110
+ ? stopping.reason === undefined
111
+ ? { stopping: true }
112
+ : { stopping: true, reason: stopping.reason }
113
+ : { stopping: false }));
106
114
  },
107
115
  };
108
116
  return this.add(item);
@@ -18,7 +18,7 @@ import type { SubtitleState } from "../subtitles.ts";
18
18
  import { VIDEO_PROCESSING_LIMITS } from "../processing.ts";
19
19
  import type { RenderingMode, RenderingPreference, RenderingReason, RenderSettings, UpscalingDecodingCause, UpscalingPlugin, UpscalingReason, UpscalingVideoSize, ViewSettings } from "./types.ts";
20
20
  import type { MediaJob } from "../media.ts";
21
- import type { LanguagePreference, Locale, Plugin, TemplateArgs, TextValue, ThemeId } from "./types.ts";
21
+ import type { LanguagePreference, Locale, Plugin, StopReason, TemplateArgs, TextValue, ThemeId } from "./types.ts";
22
22
  import { LOCALES, LIST_SEPARATORS, TEXT_LIMITS } from "../i18n.ts";
23
23
  /** Throw one of these from `host.handle(...)` to simulate a definite refusal. */
24
24
  export { HostError };
@@ -1351,6 +1351,10 @@ export declare class TestHost implements Transport {
1351
1351
  * or switches to software rendering (`unsupported`, which also sets
1352
1352
  * `videoRender.gpu` to false and `rendering.reason` to `window-fallback`
1353
1353
  * with `message`). Pushed only when this plugin is the owner.
1354
+ * `previous-crash` (SDK 1.5.54) is a shortcut that only sets the handle's
1355
+ * status and reaches `onError`: the real player never pushes it, it
1356
+ * refuses the `set` request itself. To test that, set
1357
+ * {@link TestHost.previousCrash}.
1354
1358
  *
1355
1359
  * @example Make a shader fail to compile
1356
1360
  * The plugin's render options report `failed`, as when mpv rejects a
@@ -1372,7 +1376,7 @@ export declare class TestHost implements Transport {
1372
1376
  * @capability None.
1373
1377
  * @since 1.0.0
1374
1378
  */
1375
- failVideoRender(code: "failed" | "unsupported", message: string): void;
1379
+ failVideoRender(code: "failed" | "unsupported" | "previous-crash", message: string): void;
1376
1380
  /**
1377
1381
  * The shader channel (`plugin.video.shaders(id)`, SDK 1.5.39): the lists
1378
1382
  * the player holds for this plugin's handles by id (the real paths, after
@@ -1405,6 +1409,12 @@ export declare class TestHost implements Transport {
1405
1409
  * rendering (`unsupported`; use {@link TestHost.failVideoRender}, which
1406
1410
  * also tells the handles). Pushed only to a handle that holds shaders.
1407
1411
  *
1412
+ * `code` (SDK 1.5.54, default `failed`) is the error code pushed.
1413
+ * `previous-crash` is a shortcut that only sets the handle's status and
1414
+ * reaches `onError`: the real player (0.6.3) never pushes it, it refuses
1415
+ * the `set` request itself after an abnormal end. To test that, set
1416
+ * {@link TestHost.previousCrash}.
1417
+ *
1408
1418
  * @capability None.
1409
1419
  *
1410
1420
  * @example Make the plugin's shader fail to compile
@@ -1430,7 +1440,37 @@ export declare class TestHost implements Transport {
1430
1440
  *
1431
1441
  * @since 1.5.39
1432
1442
  */
1433
- failVideoShaders(id: string, message: string): void;
1443
+ failVideoShaders(id: string, message: string, code?: "failed" | "previous-crash"): void;
1444
+ /**
1445
+ * Play a run after an abnormal end with plugin shaders on (player 0.6.3):
1446
+ * while set, a non-empty `video.shaders` list and a render options
1447
+ * `set` with a non-empty `shaders` list are refused with
1448
+ * `previous-crash` and this message; clearing still works. `null`
1449
+ * (default): shaders apply.
1450
+ *
1451
+ * @example Check that the plugin stops retrying while shaders are off
1452
+ * The plugin's shader handle turns `failed` with `previous-crash`, as
1453
+ * in the player after a crash while plugin shaders were drawing. The
1454
+ * player refuses the list itself (and shows its own OSD line), so the
1455
+ * plugin should report it once and not set the list again.
1456
+ * ```ts
1457
+ * import assert from "node:assert/strict";
1458
+ * import { join } from "node:path";
1459
+ * import { writeFileSync } from "node:fs";
1460
+ * declare const host: TestHost;
1461
+ * // ---cut---
1462
+ * host.previousCrash = "plugin shaders are off for this start";
1463
+ * const file = join(plugin.dataDirectory, "sharpen.glsl");
1464
+ * writeFileSync(file, "//!HOOK MAIN\n");
1465
+ * const shaders = plugin.video.shaders("sharpen");
1466
+ * shaders.set([file]); // refused: the previous run ended abnormally
1467
+ * await host.settle();
1468
+ * assert.equal(shaders.status.state, "failed");
1469
+ * ```
1470
+ *
1471
+ * @since 1.5.54
1472
+ */
1473
+ previousCrash: string | null;
1434
1474
  /**
1435
1475
  * The player's processing (filter) state. Wire-level; may change in minor
1436
1476
  * versions. `resources` holds each `video.filter` / `audio.filter` chain the
@@ -1942,6 +1982,11 @@ export declare class TestHost implements Transport {
1942
1982
  /** Start time in seconds. */
1943
1983
  start: number;
1944
1984
  }[];
1985
+ /**
1986
+ * @internal `startPlugin`'s `disconnect()`: refuse every request, the
1987
+ * ones in flight included, as the runner does once the player is gone.
1988
+ */
1989
+ loseConnection(): void;
1945
1990
  /**
1946
1991
  * `plugin.dataDirectory`: a fresh empty temporary directory, removed with
1947
1992
  * the host on `stop()`. `plugin.storage.json` reads and writes real files
@@ -2754,7 +2799,11 @@ export declare class TestHost implements Transport {
2754
2799
  * @returns `host`: the {@link TestHost}; `plugin`: the object passed to
2755
2800
  * `activate` (call the plugin API on it directly); `stop()`: dispose the
2756
2801
  * plugin like the player stopping it (scopes released, stores flushed),
2757
- * wait until the host is idle and remove the host's temporary files.
2802
+ * wait until the host is idle and remove the host's temporary files;
2803
+ * `stop({ reason })` (SDK 1.5.54): `reason` is the `StopReason` the player
2804
+ * sends; `disconnect()` (SDK 1.5.54) stops it as when the player is lost
2805
+ * (`host-lost`, requests refused with `disabled`). Removing the
2806
+ * temporary files never fails a test.
2758
2807
  *
2759
2808
  * @example Test that a setting survives a restart
2760
2809
  * The test turns on the 10-step option, stops the plugin, starts it again
@@ -2843,6 +2892,25 @@ export declare function startPlugin(activate: (plugin: Plugin) => unknown, optio
2843
2892
  host: TestHost;
2844
2893
  /** The `plugin` object passed to `activate`. */
2845
2894
  plugin: Plugin;
2846
- /** Stop the plugin, wait for the host, and remove temporary files. */
2847
- stop(): Promise<void>;
2895
+ /**
2896
+ * Stop the plugin, wait for the host, and remove temporary files.
2897
+ * `stop({ reason })` (SDK 1.5.54): `reason` is the reason the player
2898
+ * gives (`plugin.stopReason`, the `onDispose` cleanups'
2899
+ * `info.reason`); `stop()`: none (`undefined`), as with players up to
2900
+ * 0.6.3. It still takes no declared parameter, so it can be passed as
2901
+ * a callback (`t.after(stop)`, `promise.then(stop)`).
2902
+ */
2903
+ stop: {
2904
+ (): Promise<void>;
2905
+ (options: {
2906
+ reason?: StopReason;
2907
+ }): Promise<void>;
2908
+ };
2909
+ /**
2910
+ * Lose the player (SDK 1.5.54): as when it crashes or is killed, the
2911
+ * runner stops the plugin with reason `host-lost`; requests still
2912
+ * waiting for their answer and every later request are refused with
2913
+ * `disabled`. Then waits and removes the temporary files like `stop`.
2914
+ */
2915
+ disconnect(): Promise<void>;
2848
2916
  }>;
@@ -1510,6 +1510,10 @@ export class TestHost {
1510
1510
  * or switches to software rendering (`unsupported`, which also sets
1511
1511
  * `videoRender.gpu` to false and `rendering.reason` to `window-fallback`
1512
1512
  * with `message`). Pushed only when this plugin is the owner.
1513
+ * `previous-crash` (SDK 1.5.54) is a shortcut that only sets the handle's
1514
+ * status and reaches `onError`: the real player never pushes it, it
1515
+ * refuses the `set` request itself. To test that, set
1516
+ * {@link TestHost.previousCrash}.
1513
1517
  *
1514
1518
  * @example Make a shader fail to compile
1515
1519
  * The plugin's render options report `failed`, as when mpv rejects a
@@ -1581,6 +1585,12 @@ export class TestHost {
1581
1585
  * rendering (`unsupported`; use {@link TestHost.failVideoRender}, which
1582
1586
  * also tells the handles). Pushed only to a handle that holds shaders.
1583
1587
  *
1588
+ * `code` (SDK 1.5.54, default `failed`) is the error code pushed.
1589
+ * `previous-crash` is a shortcut that only sets the handle's status and
1590
+ * reaches `onError`: the real player (0.6.3) never pushes it, it refuses
1591
+ * the `set` request itself after an abnormal end. To test that, set
1592
+ * {@link TestHost.previousCrash}.
1593
+ *
1584
1594
  * @capability None.
1585
1595
  *
1586
1596
  * @example Make the plugin's shader fail to compile
@@ -1606,14 +1616,44 @@ export class TestHost {
1606
1616
  *
1607
1617
  * @since 1.5.39
1608
1618
  */
1609
- failVideoShaders(id, message) {
1619
+ failVideoShaders(id, message, code = "failed") {
1610
1620
  if ((this.videoShaders.handles.get(id)?.length ?? 0) > 0)
1611
1621
  this.emit("video.shaders.status", {
1612
1622
  id,
1613
1623
  state: "failed",
1614
- error: { code: "failed", message },
1624
+ error: { code, message },
1615
1625
  });
1616
1626
  }
1627
+ /**
1628
+ * Play a run after an abnormal end with plugin shaders on (player 0.6.3):
1629
+ * while set, a non-empty `video.shaders` list and a render options
1630
+ * `set` with a non-empty `shaders` list are refused with
1631
+ * `previous-crash` and this message; clearing still works. `null`
1632
+ * (default): shaders apply.
1633
+ *
1634
+ * @example Check that the plugin stops retrying while shaders are off
1635
+ * The plugin's shader handle turns `failed` with `previous-crash`, as
1636
+ * in the player after a crash while plugin shaders were drawing. The
1637
+ * player refuses the list itself (and shows its own OSD line), so the
1638
+ * plugin should report it once and not set the list again.
1639
+ * ```ts
1640
+ * import assert from "node:assert/strict";
1641
+ * import { join } from "node:path";
1642
+ * import { writeFileSync } from "node:fs";
1643
+ * declare const host: TestHost;
1644
+ * // ---cut---
1645
+ * host.previousCrash = "plugin shaders are off for this start";
1646
+ * const file = join(plugin.dataDirectory, "sharpen.glsl");
1647
+ * writeFileSync(file, "//!HOOK MAIN\n");
1648
+ * const shaders = plugin.video.shaders("sharpen");
1649
+ * shaders.set([file]); // refused: the previous run ended abnormally
1650
+ * await host.settle();
1651
+ * assert.equal(shaders.status.state, "failed");
1652
+ * ```
1653
+ *
1654
+ * @since 1.5.54
1655
+ */
1656
+ previousCrash = null;
1617
1657
  /**
1618
1658
  * The player's processing (filter) state. Wire-level; may change in minor
1619
1659
  * versions. `resources` holds each `video.filter` / `audio.filter` chain the
@@ -2099,6 +2139,25 @@ export class TestHost {
2099
2139
  #overrides = new Map();
2100
2140
  #listeners = new Map();
2101
2141
  #inFlight = 0;
2142
+ /** `disconnect()` of `startPlugin`: the player is gone. */
2143
+ #lost = false;
2144
+ #lose = () => { };
2145
+ /** Rejects when the player is lost: requests in flight fail with it. */
2146
+ #gone = (() => {
2147
+ const gone = new Promise((_, reject) => {
2148
+ this.#lose = reject;
2149
+ });
2150
+ gone.catch(() => { });
2151
+ return gone;
2152
+ })();
2153
+ /**
2154
+ * @internal `startPlugin`'s `disconnect()`: refuse every request, the
2155
+ * ones in flight included, as the runner does once the player is gone.
2156
+ */
2157
+ loseConnection() {
2158
+ this.#lost = true;
2159
+ this.#lose(new HostError("disabled", "plugin stopped"));
2160
+ }
2102
2161
  #next = { snapshot: 0, job: 0, producer: 0, source: 0, resolution: 0 };
2103
2162
  #sourceBook = new Map();
2104
2163
  /** The pending resolutions' stamps and operations (beside `resolutions`). */
@@ -2573,6 +2632,10 @@ export class TestHost {
2573
2632
  * override from {@link TestHost.handle} or the default behaviour.
2574
2633
  */
2575
2634
  async request(method, params = {}) {
2635
+ // After `disconnect()`: refused as the runner refuses them once the
2636
+ // player is gone.
2637
+ if (this.#lost)
2638
+ throw new HostError("disabled", "plugin is stopping");
2576
2639
  const record = (params ?? {});
2577
2640
  // The plugin runner refuses a request over the message limit before
2578
2641
  // sending it (runtime/runner.ts): the player never sees it.
@@ -2583,10 +2646,15 @@ export class TestHost {
2583
2646
  this.#inFlight++;
2584
2647
  try {
2585
2648
  await tick();
2649
+ if (this.#lost)
2650
+ throw new HostError("disabled", "plugin stopped");
2586
2651
  const handler = this.#overrides.get(method) ?? this.#defaults[method];
2587
2652
  if (!handler)
2588
2653
  throw new HostError("unsupported", `test host has no ${method}`);
2589
- return (await handler(record));
2654
+ const work = Promise.resolve(handler(record));
2655
+ // `disconnect()` fails the requests still waiting, as the runner does.
2656
+ work.catch(() => { });
2657
+ return (await Promise.race([work, this.#gone]));
2590
2658
  }
2591
2659
  finally {
2592
2660
  this.#inFlight--;
@@ -3424,7 +3492,20 @@ export class TestHost {
3424
3492
  close() {
3425
3493
  for (const producer of this.producers.values())
3426
3494
  producer.server.close();
3427
- rmSync(this.#scratch, { recursive: true, force: true });
3495
+ // Cleanup never fails a test: on Windows a file a closing handle still
3496
+ // holds makes the removal fail now and then (`ENOTEMPTY`, `EBUSY`,
3497
+ // `EPERM`); retry, then leave the directory to the system's cleanup.
3498
+ try {
3499
+ rmSync(this.#scratch, {
3500
+ recursive: true,
3501
+ force: true,
3502
+ maxRetries: 5,
3503
+ retryDelay: 100,
3504
+ });
3505
+ }
3506
+ catch {
3507
+ // Left in the temporary directory.
3508
+ }
3428
3509
  }
3429
3510
  // ------------------------------------------------------------ simulated host
3430
3511
  #intent(intent) {
@@ -4575,6 +4656,12 @@ export class TestHost {
4575
4656
  return null;
4576
4657
  },
4577
4658
  "video.render.set": (p) => {
4659
+ // The player's crash guard comes first (`processing_video.rs`).
4660
+ const listed = p.values?.shaders;
4661
+ if (this.previousCrash !== null &&
4662
+ Array.isArray(listed) &&
4663
+ listed.length > 0)
4664
+ throw new HostError("previous-crash", this.previousCrash);
4578
4665
  if (!this.videoRender.gpu)
4579
4666
  throw new HostError("unsupported", "render options need GPU rendering; the player renders in software");
4580
4667
  const owner = this.videoRender.owner;
@@ -4610,6 +4697,9 @@ export class TestHost {
4610
4697
  if (listProblem?.startsWith("at most"))
4611
4698
  throw new HostError("limit", listProblem);
4612
4699
  const paths = list.map((path) => ownFile(path, this.fileRoots, "shader", VIDEO_PROCESSING_LIMITS.shaderBytes));
4700
+ // The player's crash guard, after the files are checked.
4701
+ if (paths.length > 0 && this.previousCrash !== null)
4702
+ throw new HostError("previous-crash", this.previousCrash);
4613
4703
  if (paths.length > 0 && !this.videoRender.gpu)
4614
4704
  throw new HostError("unsupported", "shaders need GPU rendering; the player renders in software");
4615
4705
  const mine = [...this.videoShaders.handles]
@@ -5455,7 +5545,11 @@ function tellResidency(host) {
5455
5545
  * @returns `host`: the {@link TestHost}; `plugin`: the object passed to
5456
5546
  * `activate` (call the plugin API on it directly); `stop()`: dispose the
5457
5547
  * plugin like the player stopping it (scopes released, stores flushed),
5458
- * wait until the host is idle and remove the host's temporary files.
5548
+ * wait until the host is idle and remove the host's temporary files;
5549
+ * `stop({ reason })` (SDK 1.5.54): `reason` is the `StopReason` the player
5550
+ * sends; `disconnect()` (SDK 1.5.54) stops it as when the player is lost
5551
+ * (`host-lost`, requests refused with `disabled`). Removing the
5552
+ * temporary files never fails a test.
5459
5553
  *
5460
5554
  * @example Test that a setting survives a restart
5461
5555
  * The test turns on the 10-step option, stops the plugin, starts it again
@@ -5552,14 +5646,43 @@ export async function startPlugin(activate, options = {}) {
5552
5646
  }
5553
5647
  await host.settle();
5554
5648
  tellResidency(host);
5649
+ // Zero declared parameters (`stop.length === 0`) and a `()` overload, so
5650
+ // `t.after(stop)`, `promise.then(stop)` and done-callback test runners
5651
+ // see the same function as before 1.5.54.
5652
+ const stop = async (...args) => {
5653
+ // Only an object with `reason` counts (a value `then` or a test
5654
+ // runner passes is ignored; an unknown reason reads as none).
5655
+ const first = args[0];
5656
+ const reason = typeof first === "object" && first !== null && "reason" in first
5657
+ ? first.reason
5658
+ : undefined;
5659
+ await instance.dispose({ reason });
5660
+ await host.settle();
5661
+ host.close();
5662
+ };
5555
5663
  return {
5556
5664
  /** The test host the plugin talks to. */
5557
5665
  host,
5558
5666
  /** The `plugin` object passed to `activate`. */
5559
5667
  plugin: instance.plugin,
5560
- /** Stop the plugin, wait for the host, and remove temporary files. */
5561
- async stop() {
5562
- await instance.dispose();
5668
+ /**
5669
+ * Stop the plugin, wait for the host, and remove temporary files.
5670
+ * `stop({ reason })` (SDK 1.5.54): `reason` is the reason the player
5671
+ * gives (`plugin.stopReason`, the `onDispose` cleanups'
5672
+ * `info.reason`); `stop()`: none (`undefined`), as with players up to
5673
+ * 0.6.3. It still takes no declared parameter, so it can be passed as
5674
+ * a callback (`t.after(stop)`, `promise.then(stop)`).
5675
+ */
5676
+ stop,
5677
+ /**
5678
+ * Lose the player (SDK 1.5.54): as when it crashes or is killed, the
5679
+ * runner stops the plugin with reason `host-lost`; requests still
5680
+ * waiting for their answer and every later request are refused with
5681
+ * `disabled`. Then waits and removes the temporary files like `stop`.
5682
+ */
5683
+ async disconnect() {
5684
+ host.loseConnection();
5685
+ await instance.dispose({ reason: "host-lost" });
5563
5686
  await host.settle();
5564
5687
  host.close();
5565
5688
  },
@@ -123,6 +123,7 @@ export interface Disposable {
123
123
  * | `resolution` | A file or web resolver's result could not be opened; `message` has the reason. |
124
124
  * | `revisionConflict` | {@link Packages}: the installed revision changed since it was read (another install or uninstall ran). Read again and retry; the SDK passes the revision it read, so this reaches plugins that act on old data. |
125
125
  * | `source-geometry`, `source-geometry-unsupported` | The source's picture size or rotation could not be applied: invalid decoder dimensions / a rotation that is not a multiple of 90°. |
126
+ * | `previous-crash` | {@link VideoShaders}, {@link RenderOptions} `shaders` (player 0.6.3): plugin shaders are off because the player ended abnormally while they were drawing; clearing still works. Do not retry while refused (the player shows its own OSD line). |
126
127
  * | `source-unavailable` | The source is not ready (still loading, or its time or native binding is not available yet). |
127
128
  * | `unknown-expired` | The player no longer knows the operation or snapshot (it expired or was released). The SDK maps it to `unknownExpired` results where it can; do not resubmit the operation blindly. |
128
129
  *
@@ -416,10 +417,36 @@ export interface Scope extends Disposable {
416
417
  * plugin.onDispose(() => clearInterval(timer));
417
418
  * ```
418
419
  *
420
+ * @example Save a cache at once when the player is lost
421
+ * The plugin writes its cache file at once when the player crashed or
422
+ * was killed, instead of waiting for the next periodic save. The cleanup
423
+ * may get a {@link DisposeInfo}: `stopping` says the plugin itself stops
424
+ * (not a child scope), `reason` why (planned for player 0.6.4). Players
425
+ * whose SDK is older than 1.5.54 call the cleanup without an argument,
426
+ * so read it with `?.`.
427
+ * ```ts
428
+ * declare function saveCacheNow(): void;
429
+ * // ---cut---
430
+ * plugin.onDispose((info) => {
431
+ * // The player is gone: ask it nothing, keep what is local.
432
+ * if (info?.reason === "host-lost") saveCacheNow();
433
+ * });
434
+ * ```
435
+ *
436
+ * @remarks
437
+ * From SDK 1.5.54 in the player (and in `TestHost`) the cleanup is called
438
+ * with one argument, a {@link DisposeInfo}: whether the plugin stops, and
439
+ * why. A player whose own SDK is older calls it without one (`info` is
440
+ * `undefined`). A cleanup that takes no argument works as before; a
441
+ * function with optional parameters of its own that is passed directly
442
+ * (`onDispose(controller.abort.bind(controller))`) now receives the
443
+ * `DisposeInfo` as its first argument: wrap it (`() => controller.abort()`)
444
+ * when that matters.
445
+ *
419
446
  * @capability None.
420
447
  * @since 1.0.0
421
448
  */
422
- onDispose(cleanup: () => void | Promise<void>): Disposable;
449
+ onDispose(cleanup: ((info?: DisposeInfo) => void | Promise<void>) | (() => void | Promise<void>)): Disposable;
423
450
  /**
424
451
  * Errors thrown by handlers that the SDK invoked (command `run`, window and
425
452
  * panel callbacks, `onChange` listeners) in this scope. Without a listener
@@ -547,6 +574,65 @@ export interface Plugin extends Scope {
547
574
  * none. See [How a plugin runs](../../docs/explanation/plugins.md#the-data-directory).
548
575
  */
549
576
  readonly dataDirectory: string;
577
+ /**
578
+ * Why the player stops this plugin (SDK 1.5.54; the reasons are planned
579
+ * for player 0.6.4): set when the stop begins, before any
580
+ * {@link Scope.onDispose} cleanup runs. `undefined` while the plugin
581
+ * runs, and for every stop by an older player (one whose own SDK is older
582
+ * than 1.5.54 does not have the property at all). See {@link StopReason}.
583
+ */
584
+ readonly stopReason: StopReason | undefined;
585
+ }
586
+ /**
587
+ * Why the player stopped the plugin ({@link DisposeInfo.reason},
588
+ * {@link Plugin.stopReason}).
589
+ *
590
+ * - `quit`: the player quits.
591
+ * - `disable`: the user disabled the plugin.
592
+ * - `uninstall`: the user uninstalls it.
593
+ * - `reload`: it is reloaded (from the plugin manager, or a development
594
+ * load with `--plugin`).
595
+ * - `upgrade`: a new version replaces it.
596
+ * - `idle`: lazy activation stops it after a while without use; its next
597
+ * use starts it again ([Idle plugins](../../docs/explanation/plugins.md#idle-plugins-are-stopped)).
598
+ * - `failed`: the player stops it for a fault (planned to cover an
599
+ * unreadable or oversized line, a malformed request, an input queue that
600
+ * overflowed, an uncaught exception in the shared process).
601
+ * - `host-lost`: the plugin's runner saw its pipe to the player close
602
+ * without a stop: the player crashed or was killed. Requests are refused
603
+ * with `disabled`; clean up locally only.
604
+ *
605
+ * Planned for player 0.6.4: the player sends the reason with
606
+ * `runtime.stop` and its runner reports `host-lost`; the exact list may
607
+ * still change with that release. Players up to 0.6.3 give no reason for
608
+ * any stop, a lost player included (`undefined`); so does a stop that
609
+ * does not come from the player (a `TestHost` `stop()` without a reason).
610
+ * A value this SDK does not know reads as `undefined`.
611
+ *
612
+ * @player 0.6.4 (planned); older players give no reason (`undefined`).
613
+ * @since 1.5.54
614
+ * @category Plugin
615
+ */
616
+ export type StopReason = "quit" | "disable" | "uninstall" | "reload" | "upgrade" | "idle" | "failed" | "host-lost";
617
+ /**
618
+ * What a {@link Scope.onDispose} cleanup is told (SDK 1.5.54). A player
619
+ * whose own SDK is older passes nothing, so the cleanup's parameter is
620
+ * optional.
621
+ *
622
+ * @since 1.5.54
623
+ * @category Plugin
624
+ */
625
+ export interface DisposeInfo {
626
+ /**
627
+ * `true` when the plugin itself stops (the player stops it), `false`
628
+ * when the plugin's own code disposed this scope or one of its parents.
629
+ */
630
+ readonly stopping: boolean;
631
+ /**
632
+ * Why the player stops the plugin (planned for player 0.6.4); `undefined` while
633
+ * `stopping` is false, and for every stop by an older player.
634
+ */
635
+ readonly reason?: StopReason;
550
636
  }
551
637
  /**
552
638
  * The state of a command's menu item, as the player shows it.
@@ -6716,7 +6802,13 @@ export interface RenderSettings {
6716
6802
  * the player's first answer to `render()` (reported to `onError` too), and so is
6717
6803
  * every `set`. Single owner, like {@link VideoView}. `set` never throws; the
6718
6804
  * result shows in `status`. For shaders that several plugins add at the
6719
- * same time, use {@link VideoShaders} (SDK 1.5.39).
6805
+ * same time, use {@link VideoShaders} (SDK 1.5.39). After a run of the
6806
+ * player that ended abnormally while plugin shaders were drawing, a `set`
6807
+ * whose merged values hold a non-empty `shaders` list is refused with
6808
+ * `previous-crash` (player 0.6.3; the rules are on {@link VideoShaders}).
6809
+ * Every `set` sends all the values set so far, the refused `shaders`
6810
+ * included, so include `shaders: []` in the next `set` to go on without
6811
+ * them.
6720
6812
  *
6721
6813
  * @capability processing.video, playback.read and events (like
6722
6814
  * {@link VideoFilter})
@@ -6745,7 +6837,15 @@ export interface RenderOptions extends Disposable {
6745
6837
  * `failed` with `unsupported` while the player renders in software;
6746
6838
  * `failed` with `failed` when mpv cannot compile a shader (pushed later;
6747
6839
  * while other plugins' {@link VideoShaders} are on too, the player
6748
- * cannot tell whose shader failed and says so in the message).
6840
+ * cannot tell whose shader failed and says so in the message);
6841
+ * `failed` with `previous-crash` (player 0.6.3) when a `set` with a
6842
+ * non-empty `shaders` list comes after a run of the player that ended
6843
+ * abnormally while plugin shaders were drawing (the rules are the same
6844
+ * as for {@link VideoShaders}). The handle keeps the refused list among
6845
+ * its values and every `set` sends them all, so a later `set` without
6846
+ * `shaders` is refused again: include `shaders: []` in it
6847
+ * (`set({ shaders: [], toneMapping: "bt.2390" })`); `dispose` always
6848
+ * works.
6749
6849
  */
6750
6850
  readonly status: FilterStatus;
6751
6851
  /**
@@ -6805,6 +6905,20 @@ export interface RenderOptions extends Disposable {
6805
6905
  * Players before 0.5.20 refuse the request (the handle is `failed`): declare
6806
6906
  * `"minPlayerVersion": "0.5.20"`.
6807
6907
  *
6908
+ * **After an abnormal end** (player 0.6.3): when the previous run of the
6909
+ * player ended while plugin shaders were drawing (a crash, the process
6910
+ * being ended, a lost GPU device), the player refuses every non-empty
6911
+ * list of every plugin with `previous-crash`, its message naming the
6912
+ * plugins whose shaders were on: one such end keeps them off for the next
6913
+ * start only; two within a day keep them off until the GPU, its driver or
6914
+ * the player changes, or for 7 days. Clearing (`[]`, `clear`,
6915
+ * `dispose`) still works. The player shows one OSD line about it itself;
6916
+ * the handle is `failed` with `previous-crash` and the SDK does not
6917
+ * retry it. Do not set the list again in a loop while it is refused, and
6918
+ * do not show an OSD line of your own for it; a later start of the player
6919
+ * applies lists again once the guard lets it. Players before 0.6.3 never
6920
+ * refuse with it.
6921
+ *
6808
6922
  * @example Add a sharpening shader the plugin ships
6809
6923
  * The video looks sharper on the GPU renderer; under software rendering
6810
6924
  * the OSD says why it is not. The shader file lives in the plugin's
@@ -6835,7 +6949,8 @@ export interface VideoShaders extends Disposable {
6835
6949
  *
6836
6950
  * @remarks
6837
6951
  * Never throws: a refused list shows in `status` (`invalid`, `missing`,
6838
- * `limit`, `unsupported`) and goes to {@link Scope.onError}, and what the
6952
+ * `limit`, `unsupported`, and `previous-crash` from player 0.6.3: see
6953
+ * the interface's remarks) and goes to {@link Scope.onError}, and what the
6839
6954
  * player held stays.
6840
6955
  *
6841
6956
  * @capability processing.video
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vestra Player SDK
2
2
 
3
- > @vestra-player/sdk 1.5.52: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls. The documentation is a tree of short pages read by path, like `go doc`: the same pages and API as https://vestrasys.github.io/vestra-player-sdk/.
3
+ > @vestra-player/sdk 1.5.54: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls. The documentation is a tree of short pages read by path, like `go doc`: the same pages and API as https://vestrasys.github.io/vestra-player-sdk/.
4
4
 
5
5
  ## Read the documentation
6
6
 
@@ -32,7 +32,7 @@
32
32
  - `/API/Media`: 6 entries: CaptureOptions, Snapshot, ExportOptions, JobState, ExportJob, MediaCapture
33
33
  - `/API/Packages`: 10 entries: PackageManifest, PackageSource, InstallOptions, PackageCandidate, PackageChangeStatus, PackageChange, PluginInfo, PluginDisplay, PluginWarning, Packages
34
34
  - `/API/Playback`: 18 entries: MarkKind, TimelineMark, TimelineLayer, Timeline, AudioTrack, PlayerState, AudioDevice, Chapter, Conditional, Media, Scrub, StreamFrame, StreamOptions, StreamStartError, Stream,…
35
- - `/API/Plugin`: 11 entries: Disposable, PluginError, Scope, Plugin, WindowState, MainWindow, AppInfo, App, SystemInfo, DisplayInfo, Json
35
+ - `/API/Plugin`: 13 entries: Disposable, PluginError, Scope, Plugin, StopReason, DisposeInfo, WindowState, MainWindow, AppInfo, App, SystemInfo, DisplayInfo, Json
36
36
  - `/API/Processing`: 18 entries: FilterStatus, VideoFilter, AudioFilter, ViewSettings, VideoView, RenderSettings, RenderOptions, VideoShaders, UpscalingReasonCode, UpscalingReason, UpscalingDecodingCause,…
37
37
  - `/API/Services`: 15 entries: Clipboard, Power, ToolResult, Tools, JsonStore, Storage, SessionStorage, System, Secrets, RenderingMode, RenderingPreference, RenderingReasonCode, RenderingReason, RenderingState,…
38
38
  - `/API/Sources`: 7 entries: ResolverOptions, WebResolverOptions, FileResolverOptions, ResolvedStream, Resolver, Resolvers, ResolvedMedia
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vestra-player/sdk",
3
- "version": "1.5.52",
3
+ "version": "1.5.54",
4
4
  "description": "Plugin API types, test host and plugin CLI for Vestra Player (apiVersion vestra/1)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {