@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 +19 -0
- package/README.md +1 -0
- package/dist/api-requests.js +1 -0
- package/dist/host-capabilities.js +1 -0
- package/dist/player-runtime.d.ts +1 -0
- package/dist/player-runtime.js +1 -0
- package/dist/plugin/appearance.js +11 -2
- package/dist/plugin/packages.js +3 -0
- package/dist/plugin/player.js +6 -1
- package/dist/plugin/resolvers.js +10 -2
- package/dist/plugin/runtime.d.ts +14 -1
- package/dist/plugin/runtime.js +107 -16
- package/dist/plugin/scope.d.ts +1 -0
- package/dist/plugin/scope.js +2 -0
- package/dist/plugin/shaders.d.ts +9 -0
- package/dist/plugin/shaders.js +160 -0
- package/dist/plugin/subtitles.js +14 -2
- package/dist/plugin/testing.d.ts +74 -3
- package/dist/plugin/testing.js +221 -8
- package/dist/plugin/timeline.d.ts +1 -1
- package/dist/plugin/timeline.js +10 -2
- package/dist/plugin/types.d.ts +213 -22
- package/dist/plugin/ui.js +9 -2
- package/dist/plugin/window.d.ts +1 -1
- package/dist/plugin/window.js +10 -2
- package/dist/processing.d.ts +118 -4
- package/dist/processing.js +139 -0
- package/dist/wire.d.ts +24 -2
- package/dist/wire.js +19 -3
- package/llms-full.txt +318 -25
- package/llms.txt +23 -23
- package/package.json +1 -1
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.
|
package/dist/api-requests.js
CHANGED
|
@@ -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"],
|
package/dist/player-runtime.d.ts
CHANGED
|
@@ -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";
|
package/dist/player-runtime.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/plugin/packages.js
CHANGED
|
@@ -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({
|
package/dist/plugin/player.js
CHANGED
|
@@ -116,7 +116,9 @@ export class PlayerService {
|
|
|
116
116
|
output;
|
|
117
117
|
constructor(runtime) {
|
|
118
118
|
this.runtime = runtime;
|
|
119
|
-
|
|
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) {
|
package/dist/plugin/resolvers.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/plugin/runtime.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/plugin/runtime.js
CHANGED
|
@@ -87,7 +87,10 @@ export class Runtime {
|
|
|
87
87
|
startup = new Set();
|
|
88
88
|
#activating = true;
|
|
89
89
|
#transport;
|
|
90
|
-
|
|
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
|
-
|
|
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 (
|
|
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);
|
package/dist/plugin/scope.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/plugin/scope.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/plugin/subtitles.js
CHANGED
|
@@ -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) {
|