@vestra-player/sdk 1.5.38 → 1.5.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/bin/vestra-plugin.mjs +5 -1
- package/dist/api-requests.js +1 -0
- package/dist/commands.d.ts +12 -2
- package/dist/commands.js +4 -2
- 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/commands.js +41 -8
- package/dist/plugin/runtime.d.ts +31 -1
- package/dist/plugin/runtime.js +154 -12
- package/dist/plugin/scope.d.ts +2 -0
- package/dist/plugin/scope.js +4 -0
- package/dist/plugin/shaders.d.ts +9 -0
- package/dist/plugin/shaders.js +160 -0
- package/dist/plugin/testing.d.ts +120 -4
- package/dist/plugin/testing.js +320 -7
- package/dist/plugin/types.d.ts +443 -30
- package/dist/plugin/ui.js +6 -4
- package/dist/plugin/when.d.ts +46 -0
- package/dist/plugin/when.js +106 -0
- package/dist/processing.d.ts +118 -4
- package/dist/processing.js +139 -0
- package/dist/wire.d.ts +11 -4
- package/dist/wire.js +18 -3
- package/llms-full.txt +532 -32
- package/llms.txt +20 -20
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.40
|
|
4
|
+
|
|
5
|
+
**Who must change: nobody.** Additions only, `apiVersion` stays `vestra/1`. `plugin.when` needs player 0.5.21 (`"minPlayerVersion": "0.5.21"`); older players have no `plugin.when`. A plugin that does not use it sees no difference. What changes with the player's lazy activation (off by default; `VESTRA_LAZY=1`): a listener of `packages.changed`, `bindings.changed`, `appearance.changed` or `resolvers.fileTypes` no longer keeps a plugin running; the player stops it while idle and starts it again to deliver the event. `bindings.changed` (`plugin.bindings.onChange`) now fires only when the binding list changes, no longer when a plugin starts again with the same commands; nobody must change (the event carries no data, and the list is what it stands for).
|
|
6
|
+
|
|
7
|
+
- **`plugin.when`** (player 0.5.21): conditions on the player's own state for a command's `enabled` and `checked`, evaluated by the player, so a stopped plugin's menu items still follow playback. The vocabulary: `open`, `ready`, `playing`, `paused`, `stream`, `fullscreen`, `phase(...phases)`, `can(control)` (the names of `PlayerState.can`), `theme(id)`, `language(preference)`; combine with `and`, `or`, `not()`. `enabled: when.playing`, `checked: when.theme("vestra-light")`. With an `enabled` condition the player shows the command's `reason` only while the condition is false (or the command is busy), so a fixed reason is enough. A condition reads nothing in the plugin and keeps no listener; a function (`enabled: () => plugin.player.state.phase === "ready"`) keeps working and keeps the plugin running, as before. Wire form: `commands.register` and `commands.update` carry `when: { enabled?, checked? }` (one-key condition objects: `phase`, `paused`, `kind`, `can`, `fullscreen`, `theme`, `language`, `all`, `any`, `not`); the state fields they cover carry a placeholder. New types `When`, `Condition`; `Command.set` and `CommandOptions.enabled`/`checked` accept a condition.
|
|
8
|
+
- **Why a plugin keeps running.** The SDK tells the player everything that listens to an event and where the plugin added it (`events.subscribe { event, why: [...] }`, player 0.5.21 logs it; older players ignore it): `a listener added at main.ts:570`, `command "Snapshot" enabled is a function that reads plugin.player.state (main.ts:353) — use plugin.when`. The player's log names it when it keeps a plugin running, and for a development load (`--plugin`) at its start.
|
|
9
|
+
- **`TestHost`**: evaluates conditions as the player does (`FakeCommand.state` against `source`, `mainWindow.fullscreen`, `appearance` and `language`; `FakeCommand.when` is what was sent); `listeningWhy` (every reason each event of `listening` is listened to); `plugin.when.theme` and `language` refuse ids the player does not have (`TypeError`); `keepsRunning` (why the player would keep the plugin running: listeners of events other than the four above, a mounted panel, open windows). `vestra-plugin test` prints `keepsRunning` once per test file when it is not empty.
|
|
10
|
+
|
|
11
|
+
## 1.5.39
|
|
12
|
+
|
|
13
|
+
**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.
|
|
14
|
+
|
|
15
|
+
- **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`.
|
|
16
|
+
- **`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`.
|
|
17
|
+
- **`VIDEO_PROCESSING_LIMITS`** (`@vestra-player/sdk/testing`): the bounds above (including `lutPathBytes`, 4096), in the generated table of _Limits_.
|
|
18
|
+
- **`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.
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
3
21
|
## 1.5.38
|
|
4
22
|
|
|
5
23
|
**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`).
|
package/bin/vestra-plugin.mjs
CHANGED
|
@@ -787,7 +787,11 @@ async function test(target) {
|
|
|
787
787
|
run(
|
|
788
788
|
process.execPath,
|
|
789
789
|
["--test", ...tests.map((file) => join(directory, file))],
|
|
790
|
-
|
|
790
|
+
// startPlugin says why a plugin would keep running while idle.
|
|
791
|
+
{
|
|
792
|
+
cwd: target.directory,
|
|
793
|
+
env: { ...process.env, VESTRA_PLUGIN_RESIDENCY: "1" },
|
|
794
|
+
},
|
|
791
795
|
) !== 0
|
|
792
796
|
)
|
|
793
797
|
fail(`${target.name}: tests failed`);
|
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
|
],
|
package/dist/commands.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Finite candidate command registry. Host authenticates owner; no plugin supplies an owner id. */
|
|
2
2
|
import type { Json, Transport } from "./wire.ts";
|
|
3
3
|
import type { ListJson, TextJson } from "./i18n.ts";
|
|
4
|
+
import type { ConditionJson } from "./plugin/when.ts";
|
|
4
5
|
/**
|
|
5
6
|
* The player's command registry caps, the same values as the player's own
|
|
6
7
|
* table (the player's tests check that they agree). They are runaway guards,
|
|
@@ -238,6 +239,15 @@ export type CommandState = {
|
|
|
238
239
|
/** Source text, or a text value (player 0.4.3). */
|
|
239
240
|
reason: string | TextJson | ListJson | null;
|
|
240
241
|
};
|
|
242
|
+
/**
|
|
243
|
+
* A command's conditions (SDK 1.5.40, player 0.5.21): `enabled` and
|
|
244
|
+
* `checked` evaluated by the player (`plugin.when`). Older players ignore
|
|
245
|
+
* the field and show the state as sent.
|
|
246
|
+
*/
|
|
247
|
+
export type CommandWhen = {
|
|
248
|
+
enabled?: ConditionJson;
|
|
249
|
+
checked?: ConditionJson;
|
|
250
|
+
};
|
|
241
251
|
export type CommandDeclaration = {
|
|
242
252
|
/** Source text, or a text value (player 0.4.3). */
|
|
243
253
|
title: string | TextJson;
|
|
@@ -266,12 +276,12 @@ export type CommandDeclaration = {
|
|
|
266
276
|
};
|
|
267
277
|
};
|
|
268
278
|
export declare function commandClient(transport: Transport): {
|
|
269
|
-
register: (id: string, revision: number, declaration: CommandDeclaration, state: CommandState) => Promise<{
|
|
279
|
+
register: (id: string, revision: number, declaration: CommandDeclaration, state: CommandState, when?: CommandWhen) => Promise<{
|
|
270
280
|
revision: number;
|
|
271
281
|
}>;
|
|
272
282
|
/** Remove the command from the menu and the binding table; the id is free again. */
|
|
273
283
|
unregister: (id: string) => Promise<void>;
|
|
274
|
-
update: (id: string, revision: number, state: CommandState) => Promise<{
|
|
284
|
+
update: (id: string, revision: number, state: CommandState, when?: CommandWhen) => Promise<{
|
|
275
285
|
revision: number;
|
|
276
286
|
}>;
|
|
277
287
|
};
|
package/dist/commands.js
CHANGED
|
@@ -619,18 +619,20 @@ export const BUILTIN_COMMANDS = Object.freeze(BUILTIN_COMMAND_TABLE.trim()
|
|
|
619
619
|
}));
|
|
620
620
|
export function commandClient(transport) {
|
|
621
621
|
return {
|
|
622
|
-
register: (id, revision, declaration, state) => transport.request("commands.register", {
|
|
622
|
+
register: (id, revision, declaration, state, when) => transport.request("commands.register", {
|
|
623
623
|
id,
|
|
624
624
|
revision,
|
|
625
625
|
declaration,
|
|
626
626
|
state,
|
|
627
|
+
...(when ? { when: when } : {}),
|
|
627
628
|
}),
|
|
628
629
|
/** Remove the command from the menu and the binding table; the id is free again. */
|
|
629
630
|
unregister: (id) => transport.request("commands.unregister", { id }),
|
|
630
|
-
update: (id, revision, state) => transport.request("commands.update", {
|
|
631
|
+
update: (id, revision, state, when) => transport.request("commands.update", {
|
|
631
632
|
id,
|
|
632
633
|
revision,
|
|
633
634
|
state,
|
|
635
|
+
...(when ? { when: when } : {}),
|
|
634
636
|
}),
|
|
635
637
|
};
|
|
636
638
|
}
|
|
@@ -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";
|
package/dist/plugin/commands.js
CHANGED
|
@@ -4,7 +4,20 @@ import { checkBindings } from "./bindings.js";
|
|
|
4
4
|
import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, MENU_IDS } from "../commands.js";
|
|
5
5
|
import { ImageHandle } from "./ui.js";
|
|
6
6
|
import { isTextValue, textProblem } from "../i18n.js";
|
|
7
|
+
import { conditionJson, isCondition } from "./when.js";
|
|
7
8
|
const value = (item) => typeof item === "function" ? item() : item;
|
|
9
|
+
/** A field's value, or `fallback` (and its condition noted) for a condition. */
|
|
10
|
+
function field(item, fallback, name, when) {
|
|
11
|
+
if (!isCondition(item))
|
|
12
|
+
return value(item);
|
|
13
|
+
when[name] = conditionJson(item);
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
/** How the player's log names a command: its title, else its id. */
|
|
17
|
+
const label = (options) => {
|
|
18
|
+
const title = options.title ?? options.path?.at(-1);
|
|
19
|
+
return `command "${typeof title === "string" ? title : options.id}"`;
|
|
20
|
+
};
|
|
8
21
|
/** Validate options and build the wire declaration. */
|
|
9
22
|
function declare(options) {
|
|
10
23
|
const path = options.path ?? [];
|
|
@@ -88,13 +101,18 @@ class CommandHandle {
|
|
|
88
101
|
#disposed = false;
|
|
89
102
|
#registered = false;
|
|
90
103
|
#pump;
|
|
104
|
+
#label;
|
|
105
|
+
/** Where the plugin added it, for the player's log. */
|
|
106
|
+
#where;
|
|
91
107
|
#binding;
|
|
92
108
|
constructor(scope, options) {
|
|
93
109
|
const runtime = scope.runtime;
|
|
110
|
+
this.#where = runtime.callSite();
|
|
94
111
|
if (runtime.commands.has(options.id))
|
|
95
112
|
throw failure("conflict", `command ${options.id} is already declared`);
|
|
96
113
|
this.id = options.id;
|
|
97
114
|
this.#scope = scope;
|
|
115
|
+
this.#label = label(options);
|
|
98
116
|
this.#spec = {
|
|
99
117
|
enabled: options.enabled ?? true,
|
|
100
118
|
checked: options.checked ?? null,
|
|
@@ -105,15 +123,15 @@ class CommandHandle {
|
|
|
105
123
|
// The host requires a strictly increasing revision per update; the
|
|
106
124
|
// registration itself is revision 1, so every later state follows it.
|
|
107
125
|
const previous = removing(runtime).get(this.id);
|
|
108
|
-
this.#pump = new Latest(async (state) => {
|
|
126
|
+
this.#pump = new Latest(async ({ state, when }) => {
|
|
109
127
|
const revision = ++this.#revision;
|
|
110
128
|
if (revision === 1) {
|
|
111
129
|
await previous;
|
|
112
|
-
await runtime.host.commands.register(this.id, 1, declaration, state);
|
|
130
|
+
await runtime.host.commands.register(this.id, 1, declaration, state, when);
|
|
113
131
|
this.#registered = true;
|
|
114
132
|
}
|
|
115
133
|
else
|
|
116
|
-
await runtime.host.commands.update(this.id, revision, state);
|
|
134
|
+
await runtime.host.commands.update(this.id, revision, state, when);
|
|
117
135
|
});
|
|
118
136
|
const initial = this.#state();
|
|
119
137
|
this.#last = JSON.stringify(initial);
|
|
@@ -127,8 +145,22 @@ class CommandHandle {
|
|
|
127
145
|
this.#binding = runtime.bind(() => this.#sync());
|
|
128
146
|
scope.add(this);
|
|
129
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* The state to send. A condition field is sent as a placeholder with the
|
|
150
|
+
* condition in `when`: the player evaluates it. Each function field is
|
|
151
|
+
* evaluated under its own name, so what it reads is reported as this
|
|
152
|
+
* command's (`Runtime.reader`).
|
|
153
|
+
*/
|
|
130
154
|
#state() {
|
|
131
|
-
const
|
|
155
|
+
const runtime = this.#scope.runtime;
|
|
156
|
+
const spec = this.#spec;
|
|
157
|
+
const as = (name, read) => runtime.reader(`${this.#label} ${name}`, this.#where, read, name === "enabled" || name === "checked");
|
|
158
|
+
// enabled and checked first: what they read is reported as theirs
|
|
159
|
+
// (a condition can replace them), not as the reason's.
|
|
160
|
+
const when = {};
|
|
161
|
+
const enabled = as("enabled", () => field(spec.enabled, true, "enabled", when));
|
|
162
|
+
const checked = as("checked", () => field(spec.checked, false, "checked", when));
|
|
163
|
+
const reason = as("reason", () => value(spec.reason));
|
|
132
164
|
if (reason !== null && typeof reason !== "string") {
|
|
133
165
|
const problem = isTextValue(reason)
|
|
134
166
|
? textProblem(reason)
|
|
@@ -136,12 +168,13 @@ class CommandHandle {
|
|
|
136
168
|
if (problem)
|
|
137
169
|
throw failure("invalid", `command ${this.id}: ${problem}`);
|
|
138
170
|
}
|
|
139
|
-
|
|
140
|
-
enabled
|
|
141
|
-
checked
|
|
142
|
-
busy: value(
|
|
171
|
+
const state = {
|
|
172
|
+
enabled,
|
|
173
|
+
checked,
|
|
174
|
+
busy: as("busy", () => value(spec.busy)),
|
|
143
175
|
reason,
|
|
144
176
|
};
|
|
177
|
+
return Object.keys(when).length ? { state, when } : { state };
|
|
145
178
|
}
|
|
146
179
|
#sync() {
|
|
147
180
|
if (this.#disposed)
|
package/dist/plugin/runtime.d.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import type { Json, LocalServices, Transport, Wire } from "../wire.ts";
|
|
2
2
|
import type { App, Disposable, Locale, PluginError, SystemInfo } from "./types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Why something listens, for the player's log line that says why a plugin
|
|
5
|
+
* keeps running (`events.subscribe { why }`, player 0.5.21): computed
|
|
6
|
+
* only when the player is told.
|
|
7
|
+
*/
|
|
8
|
+
export type Why = () => string;
|
|
9
|
+
/**
|
|
10
|
+
* Events the player wakes a stopped plugin for (player 0.5.21): a listener
|
|
11
|
+
* of one does not keep the plugin running. They follow deliberate changes
|
|
12
|
+
* (an install, a key edit, the palette, a resolver), not playback.
|
|
13
|
+
*/
|
|
14
|
+
export declare const WAKE_EVENTS: readonly string[];
|
|
3
15
|
export interface PluginManifest {
|
|
4
16
|
id: string;
|
|
5
17
|
capabilities: string[];
|
|
@@ -58,6 +70,18 @@ export declare class Runtime {
|
|
|
58
70
|
* on it, so the event counts as listened to while the binding reads it.
|
|
59
71
|
*/
|
|
60
72
|
reading(event: string): void;
|
|
73
|
+
/**
|
|
74
|
+
* Evaluate `read` as `label`, added at `where`: the state it reads is
|
|
75
|
+
* reported as read by it. `field`: a command's `enabled` or `checked`,
|
|
76
|
+
* which a `plugin.when` condition can replace.
|
|
77
|
+
*/
|
|
78
|
+
reader<T>(label: string, where: (() => string | undefined) | undefined, read: () => T, field?: boolean): T;
|
|
79
|
+
/**
|
|
80
|
+
* Where the plugin's code called into the SDK: the first stack frame
|
|
81
|
+
* outside the SDK, as a path relative to the plugin's directory and a
|
|
82
|
+
* line (`main.ts:353`). Captured now, formatted when asked.
|
|
83
|
+
*/
|
|
84
|
+
callSite(): () => string | undefined;
|
|
61
85
|
/** Coalesce: one evaluation pass per microtask turn, however many changes. */
|
|
62
86
|
invalidate(): void;
|
|
63
87
|
/**
|
|
@@ -74,7 +98,13 @@ export declare class Runtime {
|
|
|
74
98
|
* state, a binding that reads it. Nothing when nothing subscribed to it
|
|
75
99
|
* (no capability, or the service did not start).
|
|
76
100
|
*/
|
|
77
|
-
hold(event: string): Disposable;
|
|
101
|
+
hold(event: string, why?: Why): Disposable;
|
|
102
|
+
/**
|
|
103
|
+
* Why the plugin listens to each event it really listens to (mirrors
|
|
104
|
+
* left out), as the player's log says it: for {@link TestHost} and
|
|
105
|
+
* `vestra-plugin test`.
|
|
106
|
+
*/
|
|
107
|
+
listening(): Map<string, string[]>;
|
|
78
108
|
/** Raw transport events the host pushes without a subscription. */
|
|
79
109
|
raw(event: string, listener: Listener): Disposable;
|
|
80
110
|
}
|
package/dist/plugin/runtime.js
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
/** Shared machinery behind every scope: invocation, update passes, events. */
|
|
2
2
|
import { createWire, HostError } from "../wire.js";
|
|
3
3
|
import { LOCALES } from "../i18n.js";
|
|
4
|
+
import { basename, isAbsolute, relative } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
/** The SDK's own files (`src/` or `dist/`): left out of a call site. */
|
|
7
|
+
const SDK_DIRECTORY = fileURLToPath(new URL("..", import.meta.url));
|
|
8
|
+
/**
|
|
9
|
+
* Events the player wakes a stopped plugin for (player 0.5.21): a listener
|
|
10
|
+
* of one does not keep the plugin running. They follow deliberate changes
|
|
11
|
+
* (an install, a key edit, the palette, a resolver), not playback.
|
|
12
|
+
*/
|
|
13
|
+
export const WAKE_EVENTS = [
|
|
14
|
+
"packages.changed",
|
|
15
|
+
"bindings.changed",
|
|
16
|
+
"appearance.changed",
|
|
17
|
+
"resolvers.fileTypes",
|
|
18
|
+
];
|
|
19
|
+
/**
|
|
20
|
+
* The state each event keeps current, and whether `plugin.when` can say
|
|
21
|
+
* the same without the plugin running.
|
|
22
|
+
*/
|
|
23
|
+
const STATE = {
|
|
24
|
+
"source.state": { name: "plugin.player.state", when: true },
|
|
25
|
+
"window.state": { name: "plugin.window.state", when: true },
|
|
26
|
+
"appearance.changed": { name: "plugin.appearance", when: true },
|
|
27
|
+
"timeline.changed": { name: "plugin.timeline.marks", when: false },
|
|
28
|
+
"playback.backend": { name: "plugin.subtitles.state", when: false },
|
|
29
|
+
"resolvers.fileTypes": {
|
|
30
|
+
name: "plugin.resolvers.fileTypes",
|
|
31
|
+
when: false,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
4
34
|
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
35
|
export function toError(error) {
|
|
6
36
|
if (error instanceof Error) {
|
|
@@ -89,8 +119,10 @@ export class Runtime {
|
|
|
89
119
|
#transport;
|
|
90
120
|
/** Each binding with the events whose state it read last time (held). */
|
|
91
121
|
#bindings = new Map();
|
|
92
|
-
/** The events read by the binding being evaluated. */
|
|
122
|
+
/** The events read by the binding being evaluated, with who read them. */
|
|
93
123
|
#reads;
|
|
124
|
+
/** Who reads now (a command's field and where it was added). */
|
|
125
|
+
#reader;
|
|
94
126
|
#scheduled = false;
|
|
95
127
|
#events = new Map();
|
|
96
128
|
views = new Map();
|
|
@@ -171,26 +203,98 @@ export class Runtime {
|
|
|
171
203
|
* on it, so the event counts as listened to while the binding reads it.
|
|
172
204
|
*/
|
|
173
205
|
reading(event) {
|
|
174
|
-
this.#reads
|
|
206
|
+
const reads = this.#reads;
|
|
207
|
+
if (!reads || reads.has(event))
|
|
208
|
+
return;
|
|
209
|
+
const reader = this.#reader;
|
|
210
|
+
const state = STATE[event];
|
|
211
|
+
reads.set(event, () => {
|
|
212
|
+
const read = state?.name ?? event;
|
|
213
|
+
const site = reader?.where?.();
|
|
214
|
+
const at = site ? ` (${site})` : "";
|
|
215
|
+
if (!reader)
|
|
216
|
+
return `a binding reads ${read}`;
|
|
217
|
+
if (!reader.field)
|
|
218
|
+
return `${reader.label} reads ${read}${at}`;
|
|
219
|
+
const hint = state?.when ? " — use plugin.when" : "";
|
|
220
|
+
return `${reader.label} is a function that reads ${read}${at}${hint}`;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Evaluate `read` as `label`, added at `where`: the state it reads is
|
|
225
|
+
* reported as read by it. `field`: a command's `enabled` or `checked`,
|
|
226
|
+
* which a `plugin.when` condition can replace.
|
|
227
|
+
*/
|
|
228
|
+
reader(label, where, read, field = false) {
|
|
229
|
+
const outer = this.#reader;
|
|
230
|
+
this.#reader = { label, where, field };
|
|
231
|
+
try {
|
|
232
|
+
return read();
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
this.#reader = outer;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Where the plugin's code called into the SDK: the first stack frame
|
|
240
|
+
* outside the SDK, as a path relative to the plugin's directory and a
|
|
241
|
+
* line (`main.ts:353`). Captured now, formatted when asked.
|
|
242
|
+
*/
|
|
243
|
+
callSite() {
|
|
244
|
+
const limit = Error.stackTraceLimit;
|
|
245
|
+
Error.stackTraceLimit = 40;
|
|
246
|
+
const error = new Error();
|
|
247
|
+
Error.stackTraceLimit = limit;
|
|
248
|
+
let site = null;
|
|
249
|
+
return () => {
|
|
250
|
+
if (site === null)
|
|
251
|
+
site = this.#site(error.stack ?? "");
|
|
252
|
+
return site;
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
#site(stack) {
|
|
256
|
+
for (const line of stack.split("\n").slice(1)) {
|
|
257
|
+
const match = /\(?((?:file:\/\/)?[^()\s]+?):(\d+):\d+\)?\s*$/.exec(line);
|
|
258
|
+
if (!match)
|
|
259
|
+
continue;
|
|
260
|
+
let file = match[1];
|
|
261
|
+
if (file.startsWith("file:"))
|
|
262
|
+
try {
|
|
263
|
+
file = fileURLToPath(file);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const sdk = relative(SDK_DIRECTORY, file);
|
|
269
|
+
if (!isAbsolute(file) || !(sdk.startsWith("..") || isAbsolute(sdk)))
|
|
270
|
+
continue;
|
|
271
|
+
const inside = relative(this.directory, file);
|
|
272
|
+
const shown = inside.startsWith("..") || isAbsolute(inside) ? basename(file) : inside;
|
|
273
|
+
return `${shown.replaceAll("\\", "/")}:${match[2]}`;
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
175
276
|
}
|
|
176
277
|
/** Evaluate one binding, holding the events whose state it read. */
|
|
177
278
|
#evaluate(evaluate, held) {
|
|
178
|
-
const reads = new
|
|
279
|
+
const reads = new Map();
|
|
179
280
|
const outer = this.#reads;
|
|
281
|
+
const reader = this.#reader;
|
|
180
282
|
this.#reads = reads;
|
|
283
|
+
this.#reader = undefined;
|
|
181
284
|
try {
|
|
182
285
|
evaluate();
|
|
183
286
|
}
|
|
184
287
|
finally {
|
|
185
288
|
this.#reads = outer;
|
|
289
|
+
this.#reader = reader;
|
|
186
290
|
for (const [event, hold] of held)
|
|
187
291
|
if (!reads.has(event)) {
|
|
188
292
|
held.delete(event);
|
|
189
293
|
void hold.dispose();
|
|
190
294
|
}
|
|
191
|
-
for (const event of reads)
|
|
295
|
+
for (const [event, why] of reads)
|
|
192
296
|
if (!held.has(event))
|
|
193
|
-
held.set(event, this.hold(event));
|
|
297
|
+
held.set(event, this.hold(event, why));
|
|
194
298
|
}
|
|
195
299
|
}
|
|
196
300
|
/** Coalesce: one evaluation pass per microtask turn, however many changes. */
|
|
@@ -224,19 +328,24 @@ export class Runtime {
|
|
|
224
328
|
let entry = this.#events.get(event);
|
|
225
329
|
const created = !entry;
|
|
226
330
|
if (!entry) {
|
|
227
|
-
entry = { listeners: new Set(), active: 0 };
|
|
331
|
+
entry = { listeners: new Set(), active: 0, whys: new Map() };
|
|
228
332
|
this.#events.set(event, entry);
|
|
229
333
|
}
|
|
230
334
|
const group = entry;
|
|
231
335
|
group.listeners.add(listener);
|
|
232
|
-
|
|
336
|
+
const token = {};
|
|
337
|
+
if (!mirror) {
|
|
233
338
|
group.active++;
|
|
339
|
+
group.whys.set(token, listenerWhy(this.callSite()));
|
|
340
|
+
}
|
|
234
341
|
if (created && (needs === null || this.can(needs))) {
|
|
235
342
|
group.told = group.active > 0;
|
|
343
|
+
const whys = group.told ? allWhys(group) : undefined;
|
|
344
|
+
group.toldWhy = whys?.join("\n");
|
|
236
345
|
group.off = this.host.events.subscribe(event, (data) => {
|
|
237
346
|
for (const each of [...group.listeners])
|
|
238
347
|
each(data);
|
|
239
|
-
}, !group.told);
|
|
348
|
+
}, !group.told, whys);
|
|
240
349
|
this.starting(group.off, (error) => console.error(`[${this.manifest.id}] subscribe ${event}:`, error));
|
|
241
350
|
}
|
|
242
351
|
else
|
|
@@ -250,6 +359,7 @@ export class Runtime {
|
|
|
250
359
|
group.listeners.delete(listener);
|
|
251
360
|
if (!mirror)
|
|
252
361
|
group.active--;
|
|
362
|
+
group.whys.delete(token);
|
|
253
363
|
if (this.#events.get(event) !== group)
|
|
254
364
|
return;
|
|
255
365
|
if (group.listeners.size)
|
|
@@ -265,11 +375,13 @@ export class Runtime {
|
|
|
265
375
|
* state, a binding that reads it. Nothing when nothing subscribed to it
|
|
266
376
|
* (no capability, or the service did not start).
|
|
267
377
|
*/
|
|
268
|
-
hold(event) {
|
|
378
|
+
hold(event, why) {
|
|
269
379
|
const group = this.#events.get(event);
|
|
270
380
|
if (!group)
|
|
271
381
|
return { dispose: async () => { } };
|
|
272
382
|
group.active++;
|
|
383
|
+
const token = {};
|
|
384
|
+
group.whys.set(token, why ?? listenerWhy(this.callSite()));
|
|
273
385
|
this.#tell(event, group);
|
|
274
386
|
let held = true;
|
|
275
387
|
return {
|
|
@@ -278,32 +390,62 @@ export class Runtime {
|
|
|
278
390
|
return;
|
|
279
391
|
held = false;
|
|
280
392
|
group.active--;
|
|
393
|
+
group.whys.delete(token);
|
|
281
394
|
if (this.#events.get(event) === group)
|
|
282
395
|
this.#tell(event, group);
|
|
283
396
|
},
|
|
284
397
|
};
|
|
285
398
|
}
|
|
286
|
-
/**
|
|
399
|
+
/**
|
|
400
|
+
* Tell the player when `group` changes between mirror and listener, and
|
|
401
|
+
* why it listens when that changed.
|
|
402
|
+
*/
|
|
287
403
|
#tell(event, group) {
|
|
288
404
|
const off = group.off;
|
|
405
|
+
if (!off)
|
|
406
|
+
return;
|
|
289
407
|
const active = group.active > 0;
|
|
290
|
-
|
|
408
|
+
const whys = active ? allWhys(group) : undefined;
|
|
409
|
+
const why = whys?.join("\n");
|
|
410
|
+
if (group.told === active && group.toldWhy === why)
|
|
291
411
|
return;
|
|
292
412
|
group.told = active;
|
|
413
|
+
group.toldWhy = why;
|
|
293
414
|
// Part of activation while it runs: the player classifies the plugin
|
|
294
415
|
// at its ready, which must see this mark. A failure is not one of
|
|
295
416
|
// activation (an older player never refuses it).
|
|
296
417
|
const told = off
|
|
297
|
-
.then(() => this.host.events.listen(event, active))
|
|
418
|
+
.then(() => this.host.events.listen(event, active, whys))
|
|
298
419
|
.catch(() => { });
|
|
299
420
|
this.starting(told, () => { });
|
|
300
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Why the plugin listens to each event it really listens to (mirrors
|
|
424
|
+
* left out), as the player's log says it: for {@link TestHost} and
|
|
425
|
+
* `vestra-plugin test`.
|
|
426
|
+
*/
|
|
427
|
+
listening() {
|
|
428
|
+
const result = new Map();
|
|
429
|
+
for (const [event, group] of this.#events)
|
|
430
|
+
if (group.active > 0)
|
|
431
|
+
result.set(event, allWhys(group));
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
301
434
|
/** Raw transport events the host pushes without a subscription. */
|
|
302
435
|
raw(event, listener) {
|
|
303
436
|
const off = this.#transport.on(event, listener);
|
|
304
437
|
return { dispose: async () => off() };
|
|
305
438
|
}
|
|
306
439
|
}
|
|
440
|
+
/** A plugin listener's reason: where it was added. */
|
|
441
|
+
const listenerWhy = (site) => () => {
|
|
442
|
+
const at = site();
|
|
443
|
+
return at ? `a listener added at ${at}` : "a listener";
|
|
444
|
+
};
|
|
445
|
+
/** Every active holder's reason in `group`, in the order they came, once each. */
|
|
446
|
+
function allWhys(group) {
|
|
447
|
+
return [...new Set([...group.whys.values()].map((why) => why()))];
|
|
448
|
+
}
|
|
307
449
|
/** `plugin.app` from the start-up handshake (the runner's environment). */
|
|
308
450
|
export function appInfo(raw, system = () => Promise.reject(failure("unavailable", "no player to ask"))) {
|
|
309
451
|
const info = (raw ?? {});
|
package/dist/plugin/scope.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export declare class ScopeImpl implements Plugin {
|
|
|
8
8
|
get dataDirectory(): string;
|
|
9
9
|
get disposed(): boolean;
|
|
10
10
|
readonly commands: import("./types.ts").Commands;
|
|
11
|
+
readonly when: import("./types.ts").When;
|
|
11
12
|
readonly windows: import("./types.ts").Windows;
|
|
12
13
|
readonly panels: import("./types.ts").Panels;
|
|
13
14
|
readonly dialogs: import("./types.ts").Dialogs;
|
|
@@ -15,6 +16,7 @@ export declare class ScopeImpl implements Plugin {
|
|
|
15
16
|
readonly video: {
|
|
16
17
|
view: () => import("./types.ts").VideoView;
|
|
17
18
|
render: () => import("./types.ts").RenderOptions;
|
|
19
|
+
shaders: (id?: string) => import("./types.ts").VideoShaders;
|
|
18
20
|
upscaling: import("./types.ts").VideoUpscaling;
|
|
19
21
|
filter(id: string): import("./types.ts").VideoFilter;
|
|
20
22
|
};
|
package/dist/plugin/scope.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Scopes own handles. Disposal runs newest first and never throws. */
|
|
2
2
|
import { failure, toError } from "./runtime.js";
|
|
3
3
|
import { commands } from "./commands.js";
|
|
4
|
+
import { WHEN } from "./when.js";
|
|
4
5
|
import { dialogs, panels, ui, windows } from "./ui.js";
|
|
5
6
|
import { timeline } from "./timeline.js";
|
|
6
7
|
import { drops } from "./drops.js";
|
|
@@ -10,6 +11,7 @@ import { i18n } from "./language.js";
|
|
|
10
11
|
import { player } from "./player.js";
|
|
11
12
|
import { audioFilters, videoFilters } from "./filters.js";
|
|
12
13
|
import { videoRender, videoView } from "./view.js";
|
|
14
|
+
import { videoShaders } from "./shaders.js";
|
|
13
15
|
import { subtitles } from "./subtitles.js";
|
|
14
16
|
import { mediaCapture } from "./media.js";
|
|
15
17
|
import { resolvers } from "./resolvers.js";
|
|
@@ -41,6 +43,7 @@ export class ScopeImpl {
|
|
|
41
43
|
return this.#disposed || (this.#parent?.disposed ?? false);
|
|
42
44
|
}
|
|
43
45
|
commands = commands(this);
|
|
46
|
+
when = WHEN;
|
|
44
47
|
windows = windows(this);
|
|
45
48
|
panels = panels(this);
|
|
46
49
|
dialogs = dialogs(this);
|
|
@@ -49,6 +52,7 @@ export class ScopeImpl {
|
|
|
49
52
|
...videoFilters(this),
|
|
50
53
|
view: () => videoView(this),
|
|
51
54
|
render: () => videoRender(this),
|
|
55
|
+
shaders: (id) => videoShaders(this, id),
|
|
52
56
|
upscaling: videoUpscaling(this),
|
|
53
57
|
};
|
|
54
58
|
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;
|