@vestra-player/sdk 1.5.42 → 1.5.44

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.
@@ -86,6 +86,7 @@ import { nodeProblem, VIDEO_PROCESSING_LIMITS } from "../processing.js";
86
86
  import { shaderIdProblem, shaderListProblem } from "./shaders.js";
87
87
  import { RENDER_DEFAULTS, renderProblem, VIEW_DEFAULTS, viewProblem, } from "./view.js";
88
88
  import { createPlugin } from "./index.js";
89
+ import { applyOps, PATCH_FEATURE, PATCH_OPS } from "./patch.js";
89
90
  import { evaluate } from "./when.js";
90
91
  import { WAKE_EVENTS } from "./runtime.js";
91
92
  import { THEMES } from "./appearance.js";
@@ -929,6 +930,54 @@ export class TestHost {
929
930
  if (reason !== null && reason !== undefined)
930
931
  this.#declared(reason, "reason");
931
932
  }
933
+ /**
934
+ * Row patches (player 0.6.0): the `seq` of each property's last write, by
935
+ * view and property (none: 0, the initial properties).
936
+ */
937
+ #writes = new Map();
938
+ #resetWrites(view) {
939
+ for (const slot of [...this.#writes.keys()])
940
+ if (slot.startsWith(`${view}\u0000`))
941
+ this.#writes.delete(slot);
942
+ }
943
+ /**
944
+ * A property write as the player takes it, returning the value the
945
+ * property shows after it: a whole `value`, or a `patch` of the array
946
+ * `current` applying on the write numbered `base` (code `stale`
947
+ * otherwise). The result is checked against the value limits whole: the
948
+ * player counts the rows a patch keeps too.
949
+ */
950
+ #write(view, property, p, current) {
951
+ const slot = `${view}\u0000${property}`;
952
+ const patch = p.patch;
953
+ if (patch === undefined) {
954
+ const value = this.#shownValues(p.value);
955
+ this.#checkValues({ [property]: value });
956
+ this.#seeImages([value]);
957
+ this.#writes.set(slot, typeof p.seq === "number" ? p.seq : 0);
958
+ return value;
959
+ }
960
+ const { base, seq } = patch;
961
+ if (!Number.isInteger(base) ||
962
+ !Number.isInteger(seq) ||
963
+ base < 0 ||
964
+ seq < 1)
965
+ throw new HostError("invalid", "patch.base and patch.seq require non-negative integers (seq ≥ 1)");
966
+ const stale = () => new HostError("stale", "the property changed since the write this patch applies on; send the whole value");
967
+ if ((this.#writes.get(slot) ?? 0) !== base || !Array.isArray(current))
968
+ throw stale();
969
+ const ops = patchOps(patch.ops, current.length);
970
+ const resolved = ops.map((op) => op.op === "set"
971
+ ? { ...op, row: this.#shownValues(op.row) }
972
+ : op.op === "insert"
973
+ ? { ...op, rows: op.rows.map((row) => this.#shownValues(row)) }
974
+ : op);
975
+ const value = applyOps(current, resolved);
976
+ this.#checkValues({ [property]: value });
977
+ this.#seeImages(resolved.flatMap((op) => op.op === "set" ? [op.row] : op.op === "insert" ? op.rows : []));
978
+ this.#writes.set(slot, seq);
979
+ return value;
980
+ }
932
981
  /**
933
982
  * Refuse property values over the player's limits ({@link UI_VALUE_LIMITS})
934
983
  * with `limit`, naming the property, as the player does.
@@ -1837,7 +1886,104 @@ export class TestHost {
1837
1886
  factor: 2,
1838
1887
  checked: false,
1839
1888
  renderingStarting: false,
1889
+ video: {
1890
+ width: 1920,
1891
+ height: 1080,
1892
+ displayWidth: 1920,
1893
+ displayHeight: 1080,
1894
+ },
1895
+ area: { width: 3840, height: 2160 },
1896
+ gpu: "NVIDIA GeForce RTX 4070 Laptop GPU",
1897
+ hwdecCurrent: "d3d11va",
1840
1898
  };
1899
+ /**
1900
+ * Make the upscaler's reason `decoding` with `cause`, with the player's
1901
+ * message (player 0.5.25), and set `upscaling.hwdecCurrent` to match
1902
+ * (`d3d11va-copy`, `no`, `nvdec`). `plugins` are named for `copy-back`
1903
+ * only, as the player does. Returns the reason; call
1904
+ * {@link TestHost.upscalingChanged} to tell a listening plugin.
1905
+ *
1906
+ * @example Test the window's advice for each cause
1907
+ * The plugin's window explains why upscaling waits. The test fakes the
1908
+ * copy-back cause that Picture Adjustments' filters cause and checks
1909
+ * that the window names the plugin.
1910
+ * ```ts
1911
+ * import assert from "node:assert/strict";
1912
+ * declare const host: TestHost;
1913
+ * // ---cut---
1914
+ * host.upscalingDecoding("copy-back", [{ id: "vestra.video", name: "Picture Adjustments" }]);
1915
+ * host.upscalingChanged();
1916
+ * await host.settle();
1917
+ * assert.match(String(host.window("upscale").props.blocker), /Picture Adjustments/);
1918
+ * ```
1919
+ *
1920
+ * @capability None.
1921
+ * @since 1.5.43
1922
+ */
1923
+ upscalingDecoding(cause, plugins = []) {
1924
+ const needs = "upscaling needs it decoded on the GPU that renders it (d3d11va)";
1925
+ const names = plugins.map((p) => p.name);
1926
+ const because = names.length === 0
1927
+ ? "for example because a video filter needs the frames in memory"
1928
+ : names.length === 1
1929
+ ? `because the video filter of ${names[0]} needs the frames in memory`
1930
+ : `because the video filters of ${names.join(", ")} need the frames in memory`;
1931
+ const [message, current] = cause === "copy-back"
1932
+ ? [
1933
+ `the video is decoded with copy-back (auto-copy-safe), ${because}; ${needs}`,
1934
+ "d3d11va-copy",
1935
+ ]
1936
+ : cause === "hwdec-off"
1937
+ ? [
1938
+ "hardware decoding is off; upscaling needs the video decoded on the GPU that renders it (d3d11va)",
1939
+ "no",
1940
+ ]
1941
+ : cause === "software"
1942
+ ? [`this video is decoded in software; ${needs}`, "no"]
1943
+ : [`this video is decoded with nvdec; ${needs}`, "nvdec"];
1944
+ const reason = cause === "copy-back"
1945
+ ? {
1946
+ code: "decoding",
1947
+ message,
1948
+ cause,
1949
+ plugins: plugins.map(({ id, name }) => ({ id, name })),
1950
+ }
1951
+ : { code: "decoding", message, cause };
1952
+ this.upscaling.reason = reason;
1953
+ this.upscaling.hwdecCurrent = current;
1954
+ return reason;
1955
+ }
1956
+ /**
1957
+ * Send `video.upscaling.changed` with the state {@link TestHost.upscaling}
1958
+ * describes now (without `enabled`), as the player does when it changes
1959
+ * (player 0.5.25); a plugin listening with
1960
+ * `plugin.video.upscaling.onChange` receives it. Nothing reaches a plugin
1961
+ * that does not listen.
1962
+ *
1963
+ * @capability None.
1964
+ *
1965
+ * @example Show the new factor in the plugin's window
1966
+ * The window resized: the test changes the area and the factor, sends
1967
+ * the change, and checks that the plugin's window shows the new factor.
1968
+ * ```ts
1969
+ * import assert from "node:assert/strict";
1970
+ * declare const host: TestHost;
1971
+ * // ---cut---
1972
+ * host.upscaling.area = { width: 2560, height: 1440 };
1973
+ * host.upscaling.factor = 1.33; // 1080p in a 1440p area
1974
+ * host.upscalingChanged(); // as the player after the resize settled
1975
+ * await host.settle();
1976
+ * assert.equal(host.window("upscale").props.factor, "×1.33");
1977
+ * ```
1978
+ *
1979
+ * @since 1.5.43
1980
+ */
1981
+ upscalingChanged() {
1982
+ if (!this.subscriptions.has("video.upscaling.changed"))
1983
+ return;
1984
+ const { enabled: _, ...state } = this.#upscalingState(false);
1985
+ this.emit("video.upscaling.changed", state);
1986
+ }
1841
1987
  /** What `player.chapters()` lists while media is ready. */
1842
1988
  chapters = [];
1843
1989
  #overrides = new Map();
@@ -3481,12 +3627,20 @@ export class TestHost {
3481
3627
  message: "the player has not checked the GPU yet; state() or set(true) checks it",
3482
3628
  };
3483
3629
  const requested = enabled && reason === null && factor !== null && factor > 1;
3630
+ const { video, area, gpu, hwdecCurrent } = this.upscaling;
3484
3631
  return {
3485
3632
  available: reason === null,
3486
- reason: reason ? { ...reason } : null,
3633
+ reason: reason
3634
+ ? structuredClone(reason)
3635
+ : null,
3487
3636
  enabled,
3488
3637
  requested,
3489
3638
  factor: requested ? factor : null,
3639
+ video: video ? { ...video } : null,
3640
+ area: area ? { ...area } : null,
3641
+ gpu,
3642
+ hwdecCurrent,
3643
+ inChain: requested,
3490
3644
  };
3491
3645
  }
3492
3646
  /** `rendering.state` from {@link TestHost.rendering} and `videoRender.gpu`. */
@@ -3711,6 +3865,7 @@ export class TestHost {
3711
3865
  const id = String(p.id);
3712
3866
  if (this.windows.has(id))
3713
3867
  throw new HostError("conflict", "view already exists");
3868
+ this.#resetWrites(`w:${id}`);
3714
3869
  const properties = this.#shownValues(p.properties);
3715
3870
  this.#checkValues(properties);
3716
3871
  this.#seeImages(properties);
@@ -3736,10 +3891,8 @@ export class TestHost {
3736
3891
  const window = this.windows.get(String(p.id));
3737
3892
  if (!window)
3738
3893
  throw new HostError("missing", "view not found");
3739
- const value = this.#shownValues(p.value);
3740
- this.#checkValues({ [String(p.property)]: value });
3741
- this.#seeImages([value]);
3742
- window.props[String(p.property)] = value;
3894
+ const property = String(p.property);
3895
+ window.props[property] = this.#write(`w:${String(p.id)}`, property, p, window.props[property]);
3743
3896
  },
3744
3897
  "ui.show": (p) => {
3745
3898
  const window = this.windows.get(String(p.id));
@@ -3773,6 +3926,7 @@ export class TestHost {
3773
3926
  ]));
3774
3927
  const properties = this.#initialValues(this.#shownValues(p.properties));
3775
3928
  this.#seeImages(properties);
3929
+ this.#resetWrites("$mount");
3776
3930
  const host = this;
3777
3931
  this.panel = {
3778
3932
  slot: String(p.slot),
@@ -4097,11 +4251,9 @@ export class TestHost {
4097
4251
  this.emit("sequence.step", { direction });
4098
4252
  },
4099
4253
  "ui.setMount": (p) => {
4100
- this.mounted();
4101
- const value = this.#shownValues(p.value);
4102
- this.#checkValues({ [String(p.property)]: value });
4103
- this.#seeImages([value]);
4104
- this.mounted().props[String(p.property)] = value;
4254
+ const panel = this.mounted();
4255
+ const property = String(p.property);
4256
+ panel.props[property] = this.#write("$mount", property, p, panel.props[property]);
4105
4257
  },
4106
4258
  "timeline.marks": () => ({ marks: this.timeline.marks }),
4107
4259
  "drops.provide": (p) => {
@@ -4140,6 +4292,7 @@ export class TestHost {
4140
4292
  throw new HostError("invalid", "a plugin has at most 8 settings pages");
4141
4293
  const properties = this.#initialValues(this.#shownValues(p.properties));
4142
4294
  this.#seeImages(properties);
4295
+ this.#resetWrites(`$settings:${id}`);
4143
4296
  const host = this;
4144
4297
  this.settings.pages.set(id, {
4145
4298
  category: String(p.category),
@@ -4162,10 +4315,8 @@ export class TestHost {
4162
4315
  const page = this.settings.pages.get(String(p.id));
4163
4316
  if (!page)
4164
4317
  throw new HostError("missing", "page not registered");
4165
- const value = this.#shownValues(p.value);
4166
- this.#checkValues({ [String(p.property)]: value });
4167
- this.#seeImages([value]);
4168
- page.props[String(p.property)] = value;
4318
+ const property = String(p.property);
4319
+ page.props[property] = this.#write(`$settings:${String(p.id)}`, property, p, page.props[property]);
4169
4320
  },
4170
4321
  "settings.unregister": (p) => {
4171
4322
  this.settings.pages.delete(String(p.id));
@@ -5156,7 +5307,10 @@ export async function startPlugin(activate, options = {}) {
5156
5307
  mkdirSync(data, { recursive: true });
5157
5308
  if (host.fileRoots.length === 0)
5158
5309
  host.fileRoots = [directory, data];
5159
- const instance = await createPlugin(host, manifest, directory, data, host.app);
5310
+ const instance = await createPlugin(host, manifest, directory, data,
5311
+ // Like the player: protocol additions it takes (`app.features` set on
5312
+ // the host replaces them, e.g. `[]` for an older player's behaviour).
5313
+ { features: [PATCH_FEATURE], ...host.app });
5160
5314
  await activate(instance.plugin);
5161
5315
  // Like the player: a position or resolver another plugin holds is not an
5162
5316
  // activation failure.
@@ -5185,3 +5339,72 @@ export async function startPlugin(activate, options = {}) {
5185
5339
  },
5186
5340
  };
5187
5341
  }
5342
+ /**
5343
+ * A patch's ops checked against an array of `length` rows, as the player
5344
+ * checks them (`ui_patch.rs`): in order, each on the result of the ones
5345
+ * before; at most {@link PATCH_OPS}; the result at most
5346
+ * `UI_VALUE_LIMITS.arrayRows` rows.
5347
+ */
5348
+ function patchOps(ops, length) {
5349
+ if (!Array.isArray(ops))
5350
+ throw new HostError("invalid", "patch.ops requires an array");
5351
+ if (ops.length > PATCH_OPS)
5352
+ throw new HostError("limit", `a patch carries at most ${PATCH_OPS} operations`);
5353
+ const index = (op, key) => {
5354
+ const value = op[key];
5355
+ if (!Number.isInteger(value) || value < 0)
5356
+ throw new HostError("invalid", `patch op ${key} requires a non-negative integer`);
5357
+ return value;
5358
+ };
5359
+ const count = (op) => {
5360
+ const value = index(op, "count");
5361
+ if (value === 0)
5362
+ throw new HostError("invalid", "patch op count must be at least 1");
5363
+ return value;
5364
+ };
5365
+ const past = (what) => new HostError("invalid", `${what} is past the end of the list`);
5366
+ let len = length;
5367
+ return ops.map((raw) => {
5368
+ const op = (raw ?? {});
5369
+ switch (op.op) {
5370
+ case "set": {
5371
+ const at = index(op, "at");
5372
+ if (at >= len)
5373
+ throw past(`set at ${at}`);
5374
+ if (!("row" in op))
5375
+ throw new HostError("invalid", "patch op set requires row");
5376
+ return { op: "set", at, row: op.row };
5377
+ }
5378
+ case "insert": {
5379
+ const at = index(op, "at");
5380
+ const rows = op.rows;
5381
+ if (!Array.isArray(rows) || rows.length === 0)
5382
+ throw new HostError("invalid", "patch op insert requires rows");
5383
+ if (at > len)
5384
+ throw past(`insert at ${at}`);
5385
+ len += rows.length;
5386
+ if (len > UI_VALUE_LIMITS.arrayRows)
5387
+ throw new HostError("limit", `UI array exceeds ${UI_VALUE_LIMITS.arrayRows} rows`);
5388
+ return { op: "insert", at, rows: rows };
5389
+ }
5390
+ case "remove": {
5391
+ const at = index(op, "at");
5392
+ const n = count(op);
5393
+ if (at + n > len)
5394
+ throw past(`remove ${n} at ${at}`);
5395
+ len -= n;
5396
+ return { op: "remove", at, count: n };
5397
+ }
5398
+ case "move": {
5399
+ const from = index(op, "from");
5400
+ const n = count(op);
5401
+ const to = index(op, "to");
5402
+ if (from + n > len || to + n > len)
5403
+ throw past(`move ${n} from ${from} to ${to}`);
5404
+ return { op: "move", from, count: n, to };
5405
+ }
5406
+ default:
5407
+ throw new HostError("invalid", "patch op must be set, insert, remove or move");
5408
+ }
5409
+ });
5410
+ }
@@ -6870,7 +6870,7 @@ export interface VideoShaders extends Disposable {
6870
6870
  * | `gpu-unsupported` | The driver does not offer upscaling on this GPU (Windows: not an RTX GPU), or the check failed. |
6871
6871
  * | `rendering` | The player renders in software, or GPU rendering is still starting; upscaling needs GPU rendering ({@link Rendering}). |
6872
6872
  * | `adapter` | GPU rendering runs on another GPU than the one that can upscale (a laptop with two GPUs; Windows: choose the high-performance GPU for Vestra in Settings ▸ System ▸ Display ▸ Graphics). |
6873
- * | `decoding` | The video is not decoded on the GPU that renders it: copy-back or software decoding, for example while a {@link VideoFilter} needs the frames in memory. |
6873
+ * | `decoding` | The video is not decoded on the GPU that renders it: copy-back or software decoding, for example while a {@link VideoFilter} needs the frames in memory. {@link UpscalingReason.cause} says which, and {@link UpscalingReason.plugins} whose filters (newer players; see there). |
6874
6874
  * | `unchecked` | The player has not checked the GPU yet. Only in the answer to `set(false)`, which never starts the check; `state()` and `set(true)` always check first. |
6875
6875
  *
6876
6876
  * `decoding` and `rendering` while GPU rendering is still starting can pass
@@ -6925,6 +6925,93 @@ export interface UpscalingReason {
6925
6925
  * and what to change; never branch on it.
6926
6926
  */
6927
6927
  readonly message: string;
6928
+ /**
6929
+ * With code `decoding` only: why the video is not decoded on the GPU
6930
+ * that renders it ({@link UpscalingDecodingCause}). Absent with every
6931
+ * other code, and from players before 0.5.25.
6932
+ *
6933
+ * @player 0.5.25; older players send `decoding` without a cause.
6934
+ * @since 1.5.43
6935
+ */
6936
+ readonly cause?: UpscalingDecodingCause;
6937
+ /**
6938
+ * With cause `copy-back` only: the plugins whose video filters need the
6939
+ * frames in memory, which is why the player switched to copy-back
6940
+ * decoding, in the order the plugins started (each plugin once). Name
6941
+ * them to the user, who can turn the filters off there. Empty when
6942
+ * the copy-back decoding is not one the player chose for a filter (the
6943
+ * user's own `hwdec` setting). Absent with any other cause, and from
6944
+ * players before 0.5.25.
6945
+ *
6946
+ * @player 0.5.25
6947
+ * @since 1.5.43
6948
+ */
6949
+ readonly plugins?: readonly UpscalingPlugin[];
6950
+ }
6951
+ /**
6952
+ * Why the video is not decoded on the GPU that renders it: the
6953
+ * {@link UpscalingReason.cause} of a `decoding` reason (player 0.5.25). A
6954
+ * newer player may add causes: show {@link UpscalingReason.message} for the
6955
+ * ones you do not handle.
6956
+ *
6957
+ * | Cause | Meaning |
6958
+ * | --- | --- |
6959
+ * | `copy-back` | Decoded on the GPU and copied back to memory, because a plugin's video filter needs the frames there ({@link UpscalingReason.plugins} names it), or because of the user's `hwdec` setting. |
6960
+ * | `hwdec-off` | Hardware decoding is turned off (mpv's `hwdec` is `no`). |
6961
+ * | `software` | This video is decoded in software: a cover picture, a format the GPU does not decode, or a hardware decoder that failed. It passes with the next video. |
6962
+ * | `other-hwdec` | This video is decoded with another hardware decoder than the one the upscaler needs. |
6963
+ *
6964
+ * @example Name the plugins whose filters block upscaling
6965
+ * A plugin's upscaling window explains a `decoding` reason by its cause,
6966
+ * and for `copy-back` names the plugins whose filters stand in the way, so
6967
+ * the user can turn them off there (a plugin cannot run another plugin's
6968
+ * commands).
6969
+ * ```ts
6970
+ * const { reason } = await plugin.video.upscaling.state();
6971
+ * if (reason?.code === "decoding" && reason.cause === "copy-back") {
6972
+ * const names = (reason.plugins ?? []).map((p) => p.name).join(", ");
6973
+ * // Ask the user to reset those filters; the manifest names are not translated.
6974
+ * console.log(names ? `Turn off the video filters of ${names}` : reason.message);
6975
+ * } else if (reason) console.log(reason.message);
6976
+ * ```
6977
+ *
6978
+ * @player 0.5.25
6979
+ * @since 1.5.43
6980
+ * @category Processing
6981
+ */
6982
+ export type UpscalingDecodingCause = "copy-back" | "hwdec-off" | "software" | "other-hwdec" | (string & {});
6983
+ /**
6984
+ * A plugin named in an {@link UpscalingReason}: its manifest id and its
6985
+ * name for people, as its manifest gives it (not translated).
6986
+ *
6987
+ * @player 0.5.25
6988
+ * @since 1.5.43
6989
+ * @category Processing
6990
+ */
6991
+ export interface UpscalingPlugin {
6992
+ /** The plugin's manifest id, for example `vestra.video`. */
6993
+ readonly id: string;
6994
+ /** The manifest's `name` as is (its id without one), for people. */
6995
+ readonly name: string;
6996
+ }
6997
+ /**
6998
+ * The video's size as the upscaler gets it ({@link UpscalingState.video}):
6999
+ * the decoder's frame size, width and height swapped for the file's
7000
+ * quarter-turn rotation metadata.
7001
+ *
7002
+ * @player 0.5.25
7003
+ * @since 1.5.43
7004
+ * @category Processing
7005
+ */
7006
+ export interface UpscalingVideoSize {
7007
+ /** The frames' stored width in pixels. */
7008
+ readonly width: number;
7009
+ /** The frames' stored height in pixels. */
7010
+ readonly height: number;
7011
+ /** The width the video is meant to be shown at (non-square pixels). */
7012
+ readonly displayWidth: number;
7013
+ /** The height the video is meant to be shown at. */
7014
+ readonly displayHeight: number;
6928
7015
  }
6929
7016
  /**
6930
7017
  * What {@link VideoUpscaling.state} and {@link VideoUpscaling.set} report:
@@ -6980,6 +7067,59 @@ export interface UpscalingState {
6980
7067
  * nothing is requested.
6981
7068
  */
6982
7069
  readonly factor: number | null;
7070
+ /**
7071
+ * The video's size as the upscaler gets it; `null` while no video
7072
+ * decodes. With {@link UpscalingState.area} it explains the factor.
7073
+ * Absent from players before 0.5.25.
7074
+ *
7075
+ * @player 0.5.25
7076
+ * @since 1.5.43
7077
+ */
7078
+ readonly video?: UpscalingVideoSize | null;
7079
+ /**
7080
+ * The area the video is shown in, in pixels (the upscaler scales the
7081
+ * video to fit it); `null` while the player shows no video area. Absent
7082
+ * from players before 0.5.25.
7083
+ *
7084
+ * @player 0.5.25
7085
+ * @since 1.5.43
7086
+ */
7087
+ readonly area?: {
7088
+ /** Width in physical pixels. */
7089
+ readonly width: number;
7090
+ /** Height in physical pixels. */
7091
+ readonly height: number;
7092
+ } | null;
7093
+ /**
7094
+ * The name of the GPU that renders, for example `NVIDIA GeForce RTX 4070
7095
+ * Laptop GPU`; `null` in software rendering and where the player does not
7096
+ * know it (macOS, Linux). Absent from players before 0.5.25.
7097
+ *
7098
+ * @player 0.5.25
7099
+ * @since 1.5.43
7100
+ */
7101
+ readonly gpu?: string | null;
7102
+ /**
7103
+ * The hardware decoder in use for this video, as mpv names it (`d3d11va`,
7104
+ * `d3d11va-copy`; `no` for software decoding); `null` while no video
7105
+ * decodes. For people and logs: branch on {@link UpscalingReason.cause}.
7106
+ * Absent from players before 0.5.25.
7107
+ *
7108
+ * @player 0.5.25
7109
+ * @since 1.5.43
7110
+ */
7111
+ readonly hwdecCurrent?: string | null;
7112
+ /**
7113
+ * The upscaler is in the player's video filter chain now, read from the
7114
+ * chain itself (`requested` is what the player asked for; they differ
7115
+ * only for a moment, while the chain changes). Still not a sign that the
7116
+ * GPU upscales: the driver gives none (see the remarks). Absent from
7117
+ * players before 0.5.25.
7118
+ *
7119
+ * @player 0.5.25
7120
+ * @since 1.5.43
7121
+ */
7122
+ readonly inChain?: boolean;
6983
7123
  }
6984
7124
  /**
6985
7125
  * `plugin.video.upscaling`: the GPU's video upscaler, NVIDIA RTX Video
@@ -7119,6 +7259,57 @@ export interface VideoUpscaling {
7119
7259
  * @since 1.5.37
7120
7260
  */
7121
7261
  set(enabled: boolean): Promise<UpscalingState>;
7262
+ /**
7263
+ * Call `listener` with the new state whenever it changes while some
7264
+ * plugin has upscaling on: the factor, whether the upscaler is in the
7265
+ * chain, the reason, the video's and the area's size, the decoder. A
7266
+ * window that shows how upscaling runs listens instead of polling
7267
+ * {@link VideoUpscaling.state}.
7268
+ *
7269
+ * @remarks
7270
+ * The player looks about five times a second while upscaling is on for
7271
+ * any plugin (a window resize sends at most that many), and sends a
7272
+ * change only when something differs; after the last plugin turns it
7273
+ * off one more change says so, then nothing until it is on again: read
7274
+ * {@link VideoUpscaling.state} when you need the state then. Nothing is
7275
+ * sent before the player's GPU check ran (the first `state()` or
7276
+ * `set(true)` of any plugin). `enabled` is this plugin's own request, as
7277
+ * in `state()`. The message of a `rendering` reason can be shorter than
7278
+ * the one `state()` gives (it may leave out what to do).
7279
+ *
7280
+ * **A listener keeps the plugin running.** `video.upscaling.changed` is
7281
+ * not an event the player wakes a stopped plugin for: while the plugin
7282
+ * listens, the player does not stop it while idle (lazy activation), even
7283
+ * with upscaling off. Listen only while a window that shows the state is
7284
+ * open, and dispose of the listener when it closes. Players before 0.5.25 do not know the event: the listener
7285
+ * never fires and the SDK logs that the player refused the subscription.
7286
+ *
7287
+ * @capability video.upscaling
7288
+ * @param listener - Receives the new state.
7289
+ * @returns Stops listening; also when the scope that added it is
7290
+ * disposed.
7291
+ * @throws `capability` (synchronously) The manifest does not declare
7292
+ * `video.upscaling`.
7293
+ *
7294
+ * @example Show how upscaling runs while a window is open
7295
+ * The plugin's window shows the factor, the sizes and the GPU while it
7296
+ * is open; the listener goes when the window closes, so the plugin can
7297
+ * be stopped while idle again.
7298
+ * ```ts
7299
+ * // When the window opens:
7300
+ * const watch = plugin.video.upscaling.onChange((state) => {
7301
+ * const video = state.video ? `${state.video.width}×${state.video.height}` : "no video";
7302
+ * const area = state.area ? `${state.area.width}×${state.area.height}` : "—";
7303
+ * console.log(`${video} → ${area}, ×${state.factor ?? 1}, ${state.gpu ?? "no GPU"}`);
7304
+ * });
7305
+ * // When it closes: stop listening (it keeps the plugin running).
7306
+ * await watch.dispose();
7307
+ * ```
7308
+ *
7309
+ * @player 0.5.25
7310
+ * @since 1.5.43
7311
+ */
7312
+ onChange(listener: (state: UpscalingState) => void): Disposable;
7122
7313
  }
7123
7314
  /**
7124
7315
  * A subtitle track of the current source.
@@ -1,4 +1,4 @@
1
- import type { Json } from "../wire.ts";
1
+ import type { Json, PropertyWrite } from "../wire.ts";
2
2
  import type { ScopeImpl } from "./scope.ts";
3
3
  import type { Dialogs, Disposable, ImageOptions, ImageRef, Panels, PropValue, Props, Ui, View, Windows } from "./types.ts";
4
4
  /** Slint names are kebab-case; plugin code may use camelCase. */
@@ -29,7 +29,7 @@ export declare abstract class ViewBase implements View {
29
29
  protected closed: boolean;
30
30
  constructor(scope: ScopeImpl);
31
31
  protected abstract live(): boolean;
32
- protected abstract write(property: string, value: Json): Promise<void>;
32
+ protected abstract write(property: string, write: PropertyWrite): Promise<void>;
33
33
  /**
34
34
  * Initial properties in wire form. A released image is left out (the
35
35
  * property keeps its default) and reported, like a failed write.