@vestra-player/sdk 1.5.33 → 1.5.35

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.
@@ -8,13 +8,14 @@ import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../capabilities.ts";
8
8
  import type { CapabilityInfo } from "../capabilities.ts";
9
9
  import { OSD_LIMITS } from "./osd.ts";
10
10
  import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./services.ts";
11
+ import { SUBTITLE_LIMITS } from "./subtitles.ts";
11
12
  import { BINDING_INPUTS } from "./bindings.ts";
12
13
  import { THEME_PALETTES } from "../palettes.ts";
13
14
  import { IMAGE_LIMITS, UI_VALUE_LIMITS } from "./values.ts";
14
15
  import type { CommandDeclaration } from "../commands.ts";
15
16
  import type { SourceState } from "../source.ts";
16
17
  import type { SubtitleState } from "../subtitles.ts";
17
- import type { RenderSettings, ViewSettings } from "./types.ts";
18
+ import type { RenderingMode, RenderingPreference, RenderingReason, RenderSettings, ViewSettings } from "./types.ts";
18
19
  import type { MediaJob } from "../media.ts";
19
20
  import type { LanguagePreference, Locale, Plugin, TemplateArgs, TextValue, ThemeId } from "./types.ts";
20
21
  import { LOCALES, LIST_SEPARATORS, TEXT_LIMITS } from "../i18n.ts";
@@ -38,6 +39,8 @@ export { OSD_LIMITS };
38
39
  export { SECRET_LIMITS };
39
40
  /** The player's limits on `plugin.storage.session`. */
40
41
  export { SESSION_STORAGE_LIMITS };
42
+ /** The player's limits on loaded subtitle files, which the test host enforces as well. */
43
+ export { SUBTITLE_LIMITS };
41
44
  /** The names bindings are made of. */
42
45
  export { BINDING_INPUTS };
43
46
  /** The colours of the player's palettes, by `Theme` token. */
@@ -466,7 +469,7 @@ export interface MarkdownWire {
466
469
  * {@link TestHost.app}, {@link TestHost.systemInfo},
467
470
  * {@link TestHost.chapters}, {@link TestHost.audioDevices},
468
471
  * {@link TestHost.secrets}, {@link TestHost.clipboard},
469
- * {@link TestHost.tools}.
472
+ * {@link TestHost.tools}, {@link TestHost.rendering}.
470
473
  * - **Assert** on records: {@link TestHost.osd}, {@link TestHost.calls},
471
474
  * {@link TestHost.sent}, {@link TestHost.conflicts},
472
475
  * {@link TestHost.layout}, {@link TestHost.menus}, and many more.
@@ -1270,7 +1273,10 @@ export declare class TestHost implements Transport {
1270
1273
  owner: string | null;
1271
1274
  /** The values the player holds. */
1272
1275
  values: RenderSettings;
1273
- /** `true`: GPU rendering; `false`: software rendering (`unsupported`). */
1276
+ /**
1277
+ * `true`: GPU rendering; `false`: software rendering (`unsupported`).
1278
+ * `plugin.app.system()` reports it as `rendering`.
1279
+ */
1274
1280
  gpu: boolean;
1275
1281
  };
1276
1282
  /**
@@ -1300,7 +1306,8 @@ export declare class TestHost implements Transport {
1300
1306
  /**
1301
1307
  * What the player pushes when mpv cannot compile a shader (code `failed`)
1302
1308
  * or switches to software rendering (`unsupported`, which also sets
1303
- * `videoRender.gpu` to false). Pushed only when this plugin is the owner.
1309
+ * `videoRender.gpu` to false and `rendering.reason` to `window-fallback`
1310
+ * with `message`). Pushed only when this plugin is the owner.
1304
1311
  *
1305
1312
  * @example Make a shader fail to compile
1306
1313
  * The plugin's render options report `failed`, as when mpv rejects a
@@ -1385,6 +1392,13 @@ export declare class TestHost implements Transport {
1385
1392
  bytes: Buffer[];
1386
1393
  /** The announced total size in bytes. */
1387
1394
  expected: number;
1395
+ /**
1396
+ * The source generation it was begun for; like the player, a new
1397
+ * source drops the uploads of the old one.
1398
+ *
1399
+ * @since 1.5.34
1400
+ */
1401
+ generation?: number;
1388
1402
  }>;
1389
1403
  /** Snapshots taken with `media.capture`, by snapshot id. Wire-level. */
1390
1404
  readonly snapshots: Map<number, Json>;
@@ -1595,8 +1609,13 @@ export declare class TestHost implements Transport {
1595
1609
  * @since 1.5.25
1596
1610
  */
1597
1611
  readonly sessionStorage: Map<string, Json>;
1598
- /** What `plugin.app.system()` reports. */
1599
- systemInfo: WireSystemInfo;
1612
+ /**
1613
+ * What `plugin.app.system()` reports. Its `rendering` follows
1614
+ * {@link TestHost.videoRender | videoRender.gpu} (`"gpu"` or
1615
+ * `"software"`), so one switch covers the render options and the fact;
1616
+ * its `renderingReason` is {@link TestHost.rendering | rendering.reason}.
1617
+ */
1618
+ systemInfo: Omit<WireSystemInfo, "rendering" | "renderingReason">;
1600
1619
  /**
1601
1620
  * What `plugin.app` reports. Set it before {@link startPlugin}: the plugin
1602
1621
  * receives it once at start-up.
@@ -1616,6 +1635,74 @@ export declare class TestHost implements Transport {
1616
1635
  slint: string;
1617
1636
  };
1618
1637
  };
1638
+ /**
1639
+ * The player's rendering choice behind `plugin.rendering` (player
1640
+ * 0.5.13). The mode drawing now is {@link TestHost.videoRender |
1641
+ * videoRender.gpu}, as for `plugin.app.system()`; the rest is preset here
1642
+ * and changed by the plugin's calls, as the player would:
1643
+ *
1644
+ * - `setPreference` saves `preference` and never changes the mode. It
1645
+ * refuses `"gpu"` while `gpu.available` is false: with `unsupported`
1646
+ * when `gpu.reason.code` is `no-gpu-backend` or `headless`, otherwise
1647
+ * with `unavailable`, the reason's message in both cases.
1648
+ * - The next start uses `"software"` for the `"software"` choice, else
1649
+ * software with `gpu.reason` while the GPU is not available, else the
1650
+ * GPU (code `preference` for a `"gpu"` choice, `default` for
1651
+ * `"auto"`). `restartPending` compares that with `startedWith`.
1652
+ * - `restartPlayer` counts in `restarts` and leaves the plugin running
1653
+ * (call `stop()` yourself); a second one rejects with `busy`, as a
1654
+ * restart is then under way.
1655
+ *
1656
+ * Inject other refusals (`failed` for an unwritable settings file or a
1657
+ * new process that could not start) with `host.handle("rendering.setPreference", …)`
1658
+ * or `host.handle("rendering.restart", …)`. The defaults are a macOS
1659
+ * player on the GPU: `"auto"`, available, started on the GPU.
1660
+ *
1661
+ * @example Test a GPU switch on a system without a GPU renderer
1662
+ * The plugin's GPU menu item is greyed out with the player's reason when
1663
+ * the GPU cannot be chosen. The host is preset before the plugin starts,
1664
+ * because the plugin reads the state while activating; software
1665
+ * rendering is `videoRender.gpu = false`, and `gpu` says why the GPU
1666
+ * cannot be chosen.
1667
+ * ```ts
1668
+ * import assert from "node:assert/strict";
1669
+ * import { startPlugin, TestHost } from "@vestra-player/sdk/testing";
1670
+ * import activate from "../main.ts";
1671
+ *
1672
+ * const host = new TestHost();
1673
+ * host.videoRender.gpu = false; // software draws now (Windows)
1674
+ * host.rendering.startedWith = "software";
1675
+ * host.rendering.reason = { code: "no-gpu-backend", message: "No GPU renderer on Windows" };
1676
+ * host.rendering.gpu = { available: false, reason: host.rendering.reason };
1677
+ * const { stop } = await startPlugin(activate, {
1678
+ * directory: new URL("..", import.meta.url),
1679
+ * host,
1680
+ * });
1681
+ * const item = host.command("gpu-rendering");
1682
+ * assert.equal(item.state.enabled, false);
1683
+ * assert.equal(item.state.reason, "No GPU renderer on Windows");
1684
+ * await stop();
1685
+ * ```
1686
+ *
1687
+ * @since 1.5.35
1688
+ */
1689
+ readonly rendering: {
1690
+ /** Why the mode drawing now is what it is (`RenderingState.reason`, `SystemInfo.renderingReason`). */
1691
+ reason: RenderingReason;
1692
+ /** The saved choice: `"auto"`, `"gpu"` or `"software"`. */
1693
+ preference: RenderingPreference;
1694
+ /** The mode this run started with; `restartPending` compares the next start's mode with it. */
1695
+ startedWith: RenderingMode;
1696
+ /** Whether the plugin may choose `"gpu"`, and why not (`null` while available). */
1697
+ gpu: {
1698
+ /** `false`: `setPreference("gpu")` is refused. */
1699
+ available: boolean;
1700
+ /** Why the GPU cannot be chosen; `null` while it can. */
1701
+ reason: RenderingReason | null;
1702
+ };
1703
+ /** Accepted `restartPlayer` calls. */
1704
+ restarts: number;
1705
+ };
1619
1706
  /** What `player.chapters()` lists while media is ready. */
1620
1707
  chapters: {
1621
1708
  /** The chapter title, `null` without one. */
@@ -77,6 +77,7 @@ import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS
77
77
  import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../capabilities.js";
78
78
  import { OSD_LIMITS } from "./osd.js";
79
79
  import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./services.js";
80
+ import { SUBTITLE_LIMITS } from "./subtitles.js";
80
81
  import { BINDING_INPUTS } from "./bindings.js";
81
82
  import { THEME_PALETTES } from "../palettes.js";
82
83
  import { IMAGE_LIMITS, UI_VALUE_LIMITS, valueLimitProblem } from "./values.js";
@@ -104,6 +105,8 @@ export { OSD_LIMITS };
104
105
  export { SECRET_LIMITS };
105
106
  /** The player's limits on `plugin.storage.session`. */
106
107
  export { SESSION_STORAGE_LIMITS };
108
+ /** The player's limits on loaded subtitle files, which the test host enforces as well. */
109
+ export { SUBTITLE_LIMITS };
107
110
  /** The names bindings are made of. */
108
111
  export { BINDING_INPUTS };
109
112
  /** The colours of the player's palettes, by `Theme` token. */
@@ -265,7 +268,7 @@ const MAX_SIDEBAR_TABS = 8;
265
268
  * {@link TestHost.app}, {@link TestHost.systemInfo},
266
269
  * {@link TestHost.chapters}, {@link TestHost.audioDevices},
267
270
  * {@link TestHost.secrets}, {@link TestHost.clipboard},
268
- * {@link TestHost.tools}.
271
+ * {@link TestHost.tools}, {@link TestHost.rendering}.
269
272
  * - **Assert** on records: {@link TestHost.osd}, {@link TestHost.calls},
270
273
  * {@link TestHost.sent}, {@link TestHost.conflicts},
271
274
  * {@link TestHost.layout}, {@link TestHost.menus}, and many more.
@@ -1264,7 +1267,8 @@ export class TestHost {
1264
1267
  /**
1265
1268
  * What the player pushes when mpv cannot compile a shader (code `failed`)
1266
1269
  * or switches to software rendering (`unsupported`, which also sets
1267
- * `videoRender.gpu` to false). Pushed only when this plugin is the owner.
1270
+ * `videoRender.gpu` to false and `rendering.reason` to `window-fallback`
1271
+ * with `message`). Pushed only when this plugin is the owner.
1268
1272
  *
1269
1273
  * @example Make a shader fail to compile
1270
1274
  * The plugin's render options report `failed`, as when mpv rejects a
@@ -1287,8 +1291,10 @@ export class TestHost {
1287
1291
  * @since 1.0.0
1288
1292
  */
1289
1293
  failVideoRender(code, message) {
1290
- if (code === "unsupported")
1294
+ if (code === "unsupported") {
1291
1295
  this.videoRender.gpu = false;
1296
+ this.rendering.reason = { code: "window-fallback", message };
1297
+ }
1292
1298
  if (this.videoRender.owner === this.manifestId)
1293
1299
  this.emit("video.render.status", {
1294
1300
  state: "failed",
@@ -1521,7 +1527,12 @@ export class TestHost {
1521
1527
  * @since 1.5.25
1522
1528
  */
1523
1529
  sessionStorage = new Map();
1524
- /** What `plugin.app.system()` reports. */
1530
+ /**
1531
+ * What `plugin.app.system()` reports. Its `rendering` follows
1532
+ * {@link TestHost.videoRender | videoRender.gpu} (`"gpu"` or
1533
+ * `"software"`), so one switch covers the render options and the fact;
1534
+ * its `renderingReason` is {@link TestHost.rendering | rendering.reason}.
1535
+ */
1525
1536
  systemInfo = {
1526
1537
  os: "macos",
1527
1538
  name: "macOS",
@@ -1553,6 +1564,64 @@ export class TestHost {
1553
1564
  slint: "1.18.1",
1554
1565
  },
1555
1566
  };
1567
+ /**
1568
+ * The player's rendering choice behind `plugin.rendering` (player
1569
+ * 0.5.13). The mode drawing now is {@link TestHost.videoRender |
1570
+ * videoRender.gpu}, as for `plugin.app.system()`; the rest is preset here
1571
+ * and changed by the plugin's calls, as the player would:
1572
+ *
1573
+ * - `setPreference` saves `preference` and never changes the mode. It
1574
+ * refuses `"gpu"` while `gpu.available` is false: with `unsupported`
1575
+ * when `gpu.reason.code` is `no-gpu-backend` or `headless`, otherwise
1576
+ * with `unavailable`, the reason's message in both cases.
1577
+ * - The next start uses `"software"` for the `"software"` choice, else
1578
+ * software with `gpu.reason` while the GPU is not available, else the
1579
+ * GPU (code `preference` for a `"gpu"` choice, `default` for
1580
+ * `"auto"`). `restartPending` compares that with `startedWith`.
1581
+ * - `restartPlayer` counts in `restarts` and leaves the plugin running
1582
+ * (call `stop()` yourself); a second one rejects with `busy`, as a
1583
+ * restart is then under way.
1584
+ *
1585
+ * Inject other refusals (`failed` for an unwritable settings file or a
1586
+ * new process that could not start) with `host.handle("rendering.setPreference", …)`
1587
+ * or `host.handle("rendering.restart", …)`. The defaults are a macOS
1588
+ * player on the GPU: `"auto"`, available, started on the GPU.
1589
+ *
1590
+ * @example Test a GPU switch on a system without a GPU renderer
1591
+ * The plugin's GPU menu item is greyed out with the player's reason when
1592
+ * the GPU cannot be chosen. The host is preset before the plugin starts,
1593
+ * because the plugin reads the state while activating; software
1594
+ * rendering is `videoRender.gpu = false`, and `gpu` says why the GPU
1595
+ * cannot be chosen.
1596
+ * ```ts
1597
+ * import assert from "node:assert/strict";
1598
+ * import { startPlugin, TestHost } from "@vestra-player/sdk/testing";
1599
+ * import activate from "../main.ts";
1600
+ *
1601
+ * const host = new TestHost();
1602
+ * host.videoRender.gpu = false; // software draws now (Windows)
1603
+ * host.rendering.startedWith = "software";
1604
+ * host.rendering.reason = { code: "no-gpu-backend", message: "No GPU renderer on Windows" };
1605
+ * host.rendering.gpu = { available: false, reason: host.rendering.reason };
1606
+ * const { stop } = await startPlugin(activate, {
1607
+ * directory: new URL("..", import.meta.url),
1608
+ * host,
1609
+ * });
1610
+ * const item = host.command("gpu-rendering");
1611
+ * assert.equal(item.state.enabled, false);
1612
+ * assert.equal(item.state.reason, "No GPU renderer on Windows");
1613
+ * await stop();
1614
+ * ```
1615
+ *
1616
+ * @since 1.5.35
1617
+ */
1618
+ rendering = {
1619
+ reason: { code: "default", message: "nothing rules GPU rendering out" },
1620
+ preference: "auto",
1621
+ startedWith: "gpu",
1622
+ gpu: { available: true, reason: null },
1623
+ restarts: 0,
1624
+ };
1556
1625
  /** What `player.chapters()` lists while media is ready. */
1557
1626
  chapters = [];
1558
1627
  #overrides = new Map();
@@ -3135,17 +3204,102 @@ export class TestHost {
3135
3204
  !["ready", "ended"].includes(this.source.phase))
3136
3205
  throw new HostError("unavailable", "audio tracks need ready media");
3137
3206
  }
3138
- #subtitleChange(expected) {
3207
+ /** `stale` unless `expected` is the current subtitle stamp. */
3208
+ #subtitleExpected(expected) {
3139
3209
  const stamp = this.subtitles.selection.stamp;
3140
3210
  const want = expected;
3141
3211
  if (want.generation !== stamp.generation ||
3142
3212
  want.revision !== stamp.revision)
3143
3213
  throw new HostError("stale", "subtitle selection changed");
3214
+ }
3215
+ /**
3216
+ * The resources this plugin loaded into the current source (resource id →
3217
+ * bytes), removed ones included: like the player, a removed track frees
3218
+ * its place among the live tracks but not among the loads.
3219
+ */
3220
+ #subtitleResources = { generation: -1, bytes: new Map() };
3221
+ #loadedSubtitles() {
3222
+ const generation = this.source.stamp.generation;
3223
+ if (this.#subtitleResources.generation !== generation)
3224
+ this.#subtitleResources = { generation, bytes: new Map() };
3225
+ return this.#subtitleResources.bytes;
3226
+ }
3227
+ /**
3228
+ * The player's `SUBTITLE_LIMITS` for a new resource (`busy`, as the
3229
+ * player reports them). External tracks in `subtitles.tracks` that this
3230
+ * plugin did not load count as other plugins'. `totalBytes` is not
3231
+ * modelled: one plugin's 4 files of at most 2 MiB stay below it, and the
3232
+ * bytes of other plugins' tracks are unknown.
3233
+ */
3234
+ #subtitleRoom(resourceId) {
3235
+ const loaded = this.#loadedSubtitles();
3236
+ if (loaded.has(resourceId))
3237
+ return;
3238
+ const external = this.subtitles.tracks.filter((t) => t.target.kind === "external");
3239
+ const own = external.filter((t) => t.target.kind === "external" && loaded.has(t.target.resourceId));
3240
+ const others = external.length - own.length;
3241
+ if (loaded.size >= SUBTITLE_LIMITS.loadsPerPlugin ||
3242
+ loaded.size + others >= SUBTITLE_LIMITS.loads ||
3243
+ own.length >= SUBTITLE_LIMITS.tracksPerPlugin ||
3244
+ external.length >= SUBTITLE_LIMITS.tracks)
3245
+ throw new HostError("busy", "subtitle resource or counter limit exceeded");
3246
+ }
3247
+ #subtitleChange(expected) {
3248
+ this.#subtitleExpected(expected);
3249
+ const stamp = this.subtitles.selection.stamp;
3144
3250
  this.subtitles.selection.stamp = {
3145
3251
  generation: this.source.stamp.generation,
3146
3252
  revision: stamp.revision + 1,
3147
3253
  };
3148
3254
  }
3255
+ /** `rendering.state` from {@link TestHost.rendering} and `videoRender.gpu`. */
3256
+ #renderingState() {
3257
+ const { reason, preference, startedWith, gpu } = this.rendering;
3258
+ // The player's order (render_mode.rs decide): no windows or no GPU
3259
+ // renderer first, then the preference `software`, then what keeps GPU
3260
+ // rendering off (denylist, crash), else GPU with the reason `default`
3261
+ // (also when the preference is `gpu`).
3262
+ const system = !gpu.available &&
3263
+ (gpu.reason?.code === "headless" ||
3264
+ gpu.reason?.code === "no-gpu-backend");
3265
+ const next = system
3266
+ ? { mode: "software", reason: gpu.reason }
3267
+ : preference === "software"
3268
+ ? {
3269
+ mode: "software",
3270
+ reason: {
3271
+ code: "preference",
3272
+ message: "chosen in the player's settings",
3273
+ },
3274
+ }
3275
+ : !gpu.available
3276
+ ? {
3277
+ mode: "software",
3278
+ reason: gpu.reason ?? {
3279
+ code: "default",
3280
+ message: "nothing rules GPU rendering out",
3281
+ },
3282
+ }
3283
+ : {
3284
+ mode: "gpu",
3285
+ reason: {
3286
+ code: "default",
3287
+ message: "nothing rules GPU rendering out",
3288
+ },
3289
+ };
3290
+ return {
3291
+ mode: this.videoRender.gpu ? "gpu" : "software",
3292
+ reason: { ...reason },
3293
+ preference,
3294
+ nextMode: next.mode,
3295
+ nextReason: { ...next.reason },
3296
+ restartPending: next.mode !== startedWith,
3297
+ gpu: {
3298
+ available: gpu.available,
3299
+ reason: gpu.available || !gpu.reason ? null : { ...gpu.reason },
3300
+ },
3301
+ };
3302
+ }
3149
3303
  #defaults = {
3150
3304
  "events.subscribe": (p) => void this.subscriptions.add(String(p.event)),
3151
3305
  "events.unsubscribe": (p) => void this.subscriptions.delete(String(p.event)),
@@ -3459,7 +3613,33 @@ export class TestHost {
3459
3613
  },
3460
3614
  "storage.session.delete": (p) => void this.sessionStorage.delete(sessionKey(p.key)),
3461
3615
  "storage.session.keys": () => [...this.sessionStorage.keys()].sort(byCodePoint),
3462
- "app.system": () => structuredClone(this.systemInfo),
3616
+ "app.system": () => ({
3617
+ ...structuredClone(this.systemInfo),
3618
+ rendering: this.videoRender.gpu ? "gpu" : "software",
3619
+ renderingReason: { ...this.rendering.reason },
3620
+ }),
3621
+ // As the player 0.5.13 (see TestHost.rendering for the model).
3622
+ "rendering.state": () => this.#renderingState(),
3623
+ "rendering.setPreference": (p) => {
3624
+ const preference = p.preference;
3625
+ if (preference !== "auto" &&
3626
+ preference !== "gpu" &&
3627
+ preference !== "software")
3628
+ throw new HostError("invalid", 'preference must be "auto", "gpu" or "software"');
3629
+ const { available, reason } = this.rendering.gpu;
3630
+ if (preference === "gpu" && !available)
3631
+ throw new HostError(reason?.code === "no-gpu-backend" || reason?.code === "headless"
3632
+ ? "unsupported"
3633
+ : "unavailable", reason?.message ?? "GPU rendering cannot be chosen");
3634
+ this.rendering.preference = preference;
3635
+ return this.#renderingState();
3636
+ },
3637
+ "rendering.restart": () => {
3638
+ if (this.rendering.restarts > 0)
3639
+ throw new HostError("busy", "a restart is already under way");
3640
+ this.rendering.restarts++;
3641
+ return null;
3642
+ },
3463
3643
  "clipboard.readText": () => this.clipboard.text,
3464
3644
  "clipboard.writeText": (p) => {
3465
3645
  if (typeof p.text !== "string")
@@ -4143,9 +4323,24 @@ export class TestHost {
4143
4323
  "subtitles.upload.begin": (p) => {
4144
4324
  if (p.generation !== this.source.stamp.generation)
4145
4325
  throw new HostError("stale", "source generation changed");
4326
+ const expectedBytes = Number(p.expectedBytes);
4327
+ if (!Number.isSafeInteger(expectedBytes) ||
4328
+ expectedBytes < 1 ||
4329
+ expectedBytes > SUBTITLE_LIMITS.fileBytes)
4330
+ throw new HostError("invalid", `a subtitle file has 1..${SUBTITLE_LIMITS.fileBytes} bytes`);
4331
+ // Like the player: a new source drops the old one's uploads. A commit
4332
+ // the player refused leaves its upload for `subtitles.upload.discard`,
4333
+ // which the SDK sends after any failed load.
4334
+ for (const [id, upload] of this.uploads)
4335
+ if (upload.generation !== undefined &&
4336
+ upload.generation !== this.source.stamp.generation)
4337
+ this.uploads.delete(id);
4338
+ if (this.uploads.size >= SUBTITLE_LIMITS.uploadsPerPlugin)
4339
+ throw new HostError("busy", "upload memory, owner or active-token limit reached");
4146
4340
  this.uploads.set(Number(p.uploadId), {
4147
4341
  bytes: [],
4148
- expected: Number(p.expectedBytes),
4342
+ expected: expectedBytes,
4343
+ generation: this.source.stamp.generation,
4149
4344
  });
4150
4345
  return {
4151
4346
  generation: p.generation,
@@ -4163,9 +4358,8 @@ export class TestHost {
4163
4358
  },
4164
4359
  "subtitles.upload.discard": (p) => void this.uploads.delete(Number(p.uploadId)),
4165
4360
  "subtitles.commit": (p) => {
4166
- const upload = this.uploads.get(Number(p.uploadId));
4167
- if (!upload)
4168
- throw new HostError("missing", "upload");
4361
+ // The player's order: the request's own fields, then `stale`, then
4362
+ // the upload and its content, then the limits.
4169
4363
  const format = p.format === "ass" ? "ass" : "srt";
4170
4364
  const select = p.select === "none"
4171
4365
  ? "none"
@@ -4179,12 +4373,18 @@ export class TestHost {
4179
4373
  : null;
4180
4374
  if (title !== null && ([...title].length < 1 || [...title].length > 200))
4181
4375
  throw new HostError("invalid", "subtitle title needs 1..200 characters");
4376
+ this.#subtitleExpected(p.expected);
4377
+ const upload = this.uploads.get(Number(p.uploadId));
4378
+ if (!upload)
4379
+ throw new HostError("missing", "upload");
4182
4380
  const bytes = Buffer.concat(upload.bytes);
4183
4381
  if (format === "ass" &&
4184
4382
  !(/\[Script Info\]/i.test(bytes.toString()) &&
4185
4383
  /\[Events\]/i.test(bytes.toString())))
4186
4384
  throw new HostError("invalid", "ASS requires [Script Info] and [Events] sections");
4385
+ this.#subtitleRoom(String(p.resourceId));
4187
4386
  this.#subtitleChange(p.expected);
4387
+ this.#loadedSubtitles().set(String(p.resourceId), bytes.length);
4188
4388
  const target = {
4189
4389
  kind: "external",
4190
4390
  owner: 1,