@vestra-player/sdk 1.5.37 → 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,24 @@
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
+
13
+ ## 1.5.38
14
+
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`).
16
+
17
+ - **Mirror subscriptions** (player 0.5.18): the SDK's own subscriptions that keep `plugin.player.state`, `plugin.window.state`, `plugin.timeline.marks`, `plugin.subtitles.state`, `plugin.appearance`, `plugin.resolvers.fileTypes` and panel positions current are sent as `events.subscribe { event, mirror: true }`. When the plugin adds a listener (an `onChange`, `onEnd`, `onFileTypesChange`) or a binding that reads that state (a command's `enabled`, `checked`, `busy` or `reason` function, a view's `bind`), the SDK sends `events.subscribe { event }` again; when the last one goes, `events.subscribe { event, mirror: true }`. A change made while the plugin activates is sent before it reports ready, so the player sees it when it classifies the plugin. The player keeps a plugin whose subscriptions are all mirrors parkable; the plugin sees no difference.
18
+ - **`PluginInfo.process`** (player 0.5.18, optional): for a running plugin, `"running"` while its process runs, `"idle"` while the player stopped it because nothing used it (its entries stay and its next use starts it). `state` stays `"running"`, and a change of `process` alone does not fire `packages.onChange`.
19
+ - **`TestHost.listening`**: the subscribed events the plugin really listens to (`subscriptions` without the mirrors).
20
+ - _Plugins_ (concepts) has a new section, _Idle plugins are stopped_: what stays while a plugin is stopped, what keeps it running (listeners, bindings that read state, windows, panels, chains, streams…) and what does not (network requests, timers and promises the player does not see: as in Chrome Manifest V3, keep what they need in session storage).
21
+
3
22
  ## 1.5.37
4
23
 
5
24
  **Who must change: nobody.** Additions only, `apiVersion` stays `vestra/1`. The new API needs player 0.5.16 (`"minPlayerVersion": "0.5.16"`): older players do not load a plugin that declares the new capability `video.upscaling` (an unknown capability). A plugin that does not use it sees no difference.
package/README.md CHANGED
@@ -15,6 +15,7 @@
15
15
  <a href="https://github.com/vestrasys/vestra-player-sdk/blob/main/CHANGELOG.md">Changelog</a> ·
16
16
  <a href="https://vestrasys.github.io/vestra-player-sdk/llms.txt">llms.txt</a>
17
17
  </p>
18
+
18
19
  ## Features
19
20
 
20
21
  - **One typed API** (`vestra/1`) for commands and menus, windows, panels, settings pages, on-screen messages, playback state and control, video and audio filters, and generated streams.
@@ -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";
@@ -29,6 +29,7 @@ class AppearanceService {
29
29
  if (this.#started)
30
30
  return;
31
31
  this.#started = true;
32
+ // A mirror: a plugin listens through `watch` and bindings.
32
33
  this.#runtime.on("appearance.changed", (data) => {
33
34
  const event = data;
34
35
  const language = preference(event?.language);
@@ -39,7 +40,7 @@ class AppearanceService {
39
40
  const theme = event?.theme;
40
41
  if (theme && THEMES.includes(theme))
41
42
  this.accept(theme);
42
- });
43
+ }, "events", true);
43
44
  }
44
45
  accept(theme) {
45
46
  if (theme === this.theme)
@@ -51,7 +52,13 @@ class AppearanceService {
51
52
  }
52
53
  watch(listener) {
53
54
  this.#listeners.add(listener);
54
- return { dispose: async () => void this.#listeners.delete(listener) };
55
+ const held = this.#runtime.hold("appearance.changed");
56
+ return {
57
+ dispose: async () => {
58
+ this.#listeners.delete(listener);
59
+ await held.dispose();
60
+ },
61
+ };
55
62
  }
56
63
  }
57
64
  const appearanceOf = (runtime) => runtime.service("appearance", () => new AppearanceService(runtime));
@@ -60,6 +67,7 @@ export function appearance(scope) {
60
67
  return {
61
68
  get theme() {
62
69
  service().start();
70
+ scope.runtime.reading("appearance.changed");
63
71
  return service().theme;
64
72
  },
65
73
  onChange(listener) {
@@ -80,6 +88,7 @@ export function appearance(scope) {
80
88
  },
81
89
  get language() {
82
90
  service().start();
91
+ scope.runtime.reading("appearance.changed");
83
92
  return service().language;
84
93
  },
85
94
  async setLanguage(language) {
@@ -164,6 +164,9 @@ const info = (plugin) => Object.freeze({
164
164
  ...(warning.slot === undefined ? {} : { slot: warning.slot }),
165
165
  ...(warning.owner === undefined ? {} : { owner: warning.owner }),
166
166
  }))),
167
+ ...(plugin.process === "running" || plugin.process === "idle"
168
+ ? { process: plugin.process }
169
+ : {}),
167
170
  ...(plugin.display
168
171
  ? {
169
172
  display: Object.freeze({
@@ -116,7 +116,9 @@ export class PlayerService {
116
116
  output;
117
117
  constructor(runtime) {
118
118
  this.runtime = runtime;
119
- runtime.on("source.state", (data) => this.accept(data));
119
+ // The SDK's copy of the state (a mirror): a plugin listens through
120
+ // `watch` and the bindings that read `state`.
121
+ runtime.on("source.state", (data) => this.accept(data), "events", true);
120
122
  this.output = new Latest(async ({ volume, muted }) => {
121
123
  if (volume !== undefined)
122
124
  await runtime.host.playback.set("volume", volume);
@@ -144,9 +146,11 @@ export class PlayerService {
144
146
  }
145
147
  watch(watcher) {
146
148
  this.#watchers.add(watcher);
149
+ const held = this.runtime.hold("source.state");
147
150
  return {
148
151
  dispose: async () => {
149
152
  this.#watchers.delete(watcher);
153
+ await held.dispose();
150
154
  },
151
155
  };
152
156
  }
@@ -429,6 +433,7 @@ export function player(scope) {
429
433
  const service = () => playerOf(scope.runtime);
430
434
  return {
431
435
  get state() {
436
+ scope.runtime.reading("source.state");
432
437
  return service().state;
433
438
  },
434
439
  onChange(listener) {
@@ -26,11 +26,12 @@ class FileTypesService {
26
26
  if (this.#started)
27
27
  return;
28
28
  this.#started = true;
29
+ // A mirror: a plugin listens through `watch` and bindings.
29
30
  this.#runtime.on("resolvers.fileTypes", (data) => {
30
31
  const types = data?.fileTypes;
31
32
  if (Array.isArray(types) && types.every((t) => typeof t === "string"))
32
33
  this.accept(types);
33
- });
34
+ }, "events", true);
34
35
  // What changed between the start-up read and the subscription.
35
36
  const window = windowOf(this.#runtime);
36
37
  void window.read().then(() => {
@@ -50,7 +51,13 @@ class FileTypesService {
50
51
  }
51
52
  watch(listener) {
52
53
  this.#listeners.add(listener);
53
- return { dispose: async () => void this.#listeners.delete(listener) };
54
+ const held = this.#runtime.hold("resolvers.fileTypes");
55
+ return {
56
+ dispose: async () => {
57
+ this.#listeners.delete(listener);
58
+ await held.dispose();
59
+ },
60
+ };
54
61
  }
55
62
  }
56
63
  const fileTypesOf = (runtime) => runtime.service("fileTypes", () => new FileTypesService(runtime));
@@ -58,6 +65,7 @@ export const resolvers = (scope) => ({
58
65
  get fileTypes() {
59
66
  const service = fileTypesOf(scope.runtime);
60
67
  service.start();
68
+ scope.runtime.reading("resolvers.fileTypes");
61
69
  return service.types;
62
70
  },
63
71
  onFileTypesChange(listener) {
@@ -53,15 +53,28 @@ export declare class Runtime {
53
53
  starting<T>(work: Promise<T>, report: (error: unknown) => void): Promise<T>;
54
54
  activated(): Promise<void>;
55
55
  bind(evaluate: () => void): Disposable;
56
+ /**
57
+ * A service's state for `event` is read: a binding evaluated now depends
58
+ * on it, so the event counts as listened to while the binding reads it.
59
+ */
60
+ reading(event: string): void;
56
61
  /** Coalesce: one evaluation pass per microtask turn, however many changes. */
57
62
  invalidate(): void;
58
63
  /**
59
64
  * Host events with one `events.subscribe` per name, however many listeners.
60
65
  * Without the `events` capability the listener simply never fires.
66
+ * `mirror`: the SDK's own copy of the state (`plugin.player.state` and
67
+ * the like), not a listener of the plugin's (see {@link EventGroup}).
61
68
  */
62
69
  on(event: string, listener: Listener,
63
70
  /** The capability the event needs; `null`: none. */
64
- needs?: string | null): Disposable;
71
+ needs?: string | null, mirror?: boolean): Disposable;
72
+ /**
73
+ * Count `event` as listened to while held: a watcher of a service's
74
+ * state, a binding that reads it. Nothing when nothing subscribed to it
75
+ * (no capability, or the service did not start).
76
+ */
77
+ hold(event: string): Disposable;
65
78
  /** Raw transport events the host pushes without a subscription. */
66
79
  raw(event: string, listener: Listener): Disposable;
67
80
  }
@@ -87,7 +87,10 @@ export class Runtime {
87
87
  startup = new Set();
88
88
  #activating = true;
89
89
  #transport;
90
- #bindings = new Set();
90
+ /** Each binding with the events whose state it read last time (held). */
91
+ #bindings = new Map();
92
+ /** The events read by the binding being evaluated. */
93
+ #reads;
91
94
  #scheduled = false;
92
95
  #events = new Map();
93
96
  views = new Map();
@@ -149,14 +152,47 @@ export class Runtime {
149
152
  this.#activating = false;
150
153
  }
151
154
  bind(evaluate) {
152
- this.#bindings.add(evaluate);
155
+ const held = new Map();
156
+ this.#bindings.set(evaluate, held);
153
157
  this.invalidate();
154
158
  return {
155
159
  dispose: async () => {
160
+ if (this.#bindings.get(evaluate) !== held)
161
+ return;
156
162
  this.#bindings.delete(evaluate);
163
+ for (const hold of held.values())
164
+ await hold.dispose();
165
+ held.clear();
157
166
  },
158
167
  };
159
168
  }
169
+ /**
170
+ * A service's state for `event` is read: a binding evaluated now depends
171
+ * on it, so the event counts as listened to while the binding reads it.
172
+ */
173
+ reading(event) {
174
+ this.#reads?.add(event);
175
+ }
176
+ /** Evaluate one binding, holding the events whose state it read. */
177
+ #evaluate(evaluate, held) {
178
+ const reads = new Set();
179
+ const outer = this.#reads;
180
+ this.#reads = reads;
181
+ try {
182
+ evaluate();
183
+ }
184
+ finally {
185
+ this.#reads = outer;
186
+ for (const [event, hold] of held)
187
+ if (!reads.has(event)) {
188
+ held.delete(event);
189
+ void hold.dispose();
190
+ }
191
+ for (const event of reads)
192
+ if (!held.has(event))
193
+ held.set(event, this.hold(event));
194
+ }
195
+ }
160
196
  /** Coalesce: one evaluation pass per microtask turn, however many changes. */
161
197
  invalidate() {
162
198
  if (this.#scheduled)
@@ -164,9 +200,11 @@ export class Runtime {
164
200
  this.#scheduled = true;
165
201
  queueMicrotask(() => {
166
202
  this.#scheduled = false;
167
- for (const evaluate of this.#bindings) {
203
+ for (const [evaluate, held] of [...this.#bindings]) {
204
+ if (this.#bindings.get(evaluate) !== held)
205
+ continue;
168
206
  try {
169
- evaluate();
207
+ this.#evaluate(evaluate, held);
170
208
  }
171
209
  catch (error) {
172
210
  console.error(`[${this.manifest.id}] binding failed:`, error);
@@ -177,36 +215,89 @@ export class Runtime {
177
215
  /**
178
216
  * Host events with one `events.subscribe` per name, however many listeners.
179
217
  * Without the `events` capability the listener simply never fires.
218
+ * `mirror`: the SDK's own copy of the state (`plugin.player.state` and
219
+ * the like), not a listener of the plugin's (see {@link EventGroup}).
180
220
  */
181
221
  on(event, listener,
182
222
  /** The capability the event needs; `null`: none. */
183
- needs = "events") {
223
+ needs = "events", mirror = false) {
184
224
  let entry = this.#events.get(event);
225
+ const created = !entry;
185
226
  if (!entry) {
186
- entry = { listeners: new Set() };
227
+ entry = { listeners: new Set(), active: 0 };
187
228
  this.#events.set(event, entry);
188
- if (needs === null || this.can(needs)) {
189
- const group = entry;
190
- entry.off = this.host.events.subscribe(event, (data) => {
191
- for (const each of [...group.listeners])
192
- each(data);
193
- });
194
- this.starting(entry.off, (error) => console.error(`[${this.manifest.id}] subscribe ${event}:`, error));
195
- }
196
229
  }
197
- entry.listeners.add(listener);
198
230
  const group = entry;
231
+ group.listeners.add(listener);
232
+ if (!mirror)
233
+ group.active++;
234
+ if (created && (needs === null || this.can(needs))) {
235
+ group.told = group.active > 0;
236
+ group.off = this.host.events.subscribe(event, (data) => {
237
+ for (const each of [...group.listeners])
238
+ each(data);
239
+ }, !group.told);
240
+ this.starting(group.off, (error) => console.error(`[${this.manifest.id}] subscribe ${event}:`, error));
241
+ }
242
+ else
243
+ this.#tell(event, group);
244
+ let listening = true;
199
245
  return {
200
246
  dispose: async () => {
247
+ if (!listening)
248
+ return;
249
+ listening = false;
201
250
  group.listeners.delete(listener);
202
- if (group.listeners.size || this.#events.get(event) !== group)
251
+ if (!mirror)
252
+ group.active--;
253
+ if (this.#events.get(event) !== group)
203
254
  return;
255
+ if (group.listeners.size)
256
+ return this.#tell(event, group);
204
257
  this.#events.delete(event);
205
258
  const off = await group.off?.catch(() => undefined);
206
259
  await off?.().catch(() => { });
207
260
  },
208
261
  };
209
262
  }
263
+ /**
264
+ * Count `event` as listened to while held: a watcher of a service's
265
+ * state, a binding that reads it. Nothing when nothing subscribed to it
266
+ * (no capability, or the service did not start).
267
+ */
268
+ hold(event) {
269
+ const group = this.#events.get(event);
270
+ if (!group)
271
+ return { dispose: async () => { } };
272
+ group.active++;
273
+ this.#tell(event, group);
274
+ let held = true;
275
+ return {
276
+ dispose: async () => {
277
+ if (!held)
278
+ return;
279
+ held = false;
280
+ group.active--;
281
+ if (this.#events.get(event) === group)
282
+ this.#tell(event, group);
283
+ },
284
+ };
285
+ }
286
+ /** Tell the player when `group` changes between mirror and listener. */
287
+ #tell(event, group) {
288
+ const off = group.off;
289
+ const active = group.active > 0;
290
+ if (!off || group.told === active)
291
+ return;
292
+ group.told = active;
293
+ // Part of activation while it runs: the player classifies the plugin
294
+ // at its ready, which must see this mark. A failure is not one of
295
+ // activation (an older player never refuses it).
296
+ const told = off
297
+ .then(() => this.host.events.listen(event, active))
298
+ .catch(() => { });
299
+ this.starting(told, () => { });
300
+ }
210
301
  /** Raw transport events the host pushes without a subscription. */
211
302
  raw(event, listener) {
212
303
  const off = this.#transport.on(event, listener);
@@ -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
+ }
@@ -62,6 +62,8 @@ export const SUBTITLE_LIMITS = Object.freeze({
62
62
  /** Loads of every plugin in progress at the same time (8). */
63
63
  uploads: 8,
64
64
  });
65
+ /** The events the subtitle state follows (it has none of its own). */
66
+ const EVENTS = ["source.state", "playback.backend"];
65
67
  const raws = new WeakMap();
66
68
  const targets = new WeakMap();
67
69
  const CHUNK = 32 * 1024;
@@ -85,6 +87,7 @@ class SubtitleService {
85
87
  constructor(runtime) {
86
88
  this.#runtime = runtime;
87
89
  // No subtitle event exists: re-read when the source or backend changes.
90
+ // Mirrors: a plugin listens through `watch` and bindings.
88
91
  runtime.on("source.state", (data) => {
89
92
  const raw = data;
90
93
  const source = `${raw.stamp.generation}:${raw.phase}`;
@@ -92,8 +95,8 @@ class SubtitleService {
92
95
  return;
93
96
  this.#source = source;
94
97
  void this.refresh().catch(() => { });
95
- });
96
- runtime.on("playback.backend", () => void this.refresh().catch(() => { }));
98
+ }, "events", true);
99
+ runtime.on("playback.backend", () => void this.refresh().catch(() => { }), "events", true);
97
100
  this.style = new Latest(async ({ style, options }) => {
98
101
  const expected = await this.expected(options);
99
102
  await this.#mutate(++this.#operation, (operationId) => runtime.host.subtitles.style({
@@ -106,12 +109,20 @@ class SubtitleService {
106
109
  }
107
110
  watch(watcher) {
108
111
  this.#watchers.add(watcher);
112
+ const held = EVENTS.map((event) => this.#runtime.hold(event));
109
113
  return {
110
114
  dispose: async () => {
111
115
  this.#watchers.delete(watcher);
116
+ for (const hold of held.splice(0))
117
+ await hold.dispose();
112
118
  },
113
119
  };
114
120
  }
121
+ /** A binding that reads the state follows both events. */
122
+ reading() {
123
+ for (const event of EVENTS)
124
+ this.#runtime.reading(event);
125
+ }
115
126
  /** Keep the newest view: older generations and older revisions are dropped. */
116
127
  accept(raw) {
117
128
  const old = this.raw;
@@ -258,6 +269,7 @@ export function subtitles(scope) {
258
269
  const host = () => scope.runtime.host.subtitles;
259
270
  return {
260
271
  get state() {
272
+ service().reading();
261
273
  return service().state;
262
274
  },
263
275
  onChange(listener) {