@vestra-player/sdk 1.6.8 → 1.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.10
4
+
5
+ **Who must change: nobody.** Additions only: a clip format, optional export fields, two optional `JobState` fields, an error code and a `TestHost` field. `apiVersion` stays `vestra/1`, no new capability (`media.control` covers them). A plugin that does not use them sends exactly what it sent before; one that uses them needs player 0.7.6: declare `"minPlayerVersion": "0.7.6"`, or compare `plugin.app.version` with 0.7.6 to offer them only where they exist (do not probe by trying: on an older player `plugin` comes from that player's own, older SDK, which rejects `format: "copy"` with `invalid` but builds a re-encode request without the `encoding` it does not know, so the clip is encoded with the fixed settings and no error), and leave `progress`, `start` and `end` out.
6
+
7
+ - **Stream-copied clips** (`Snapshot.export({ format: "copy", destination, end, container? })`, player 0.7.6): the video and the snapshot's audio track are copied as they are in the file, without re-encoding: lossless and about as fast as the disk. A copy can only begin on a keyframe: it starts at the video's last keyframe at or before the snapshot time (the first keyframe when the video itself starts later; keyframes an MP4 edit list discards do not count) and is cut at `end`, a few frames past it at most. `container` is `auto` (default: MP4 for H.264, HEVC, AV1 or MPEG-4 video with AAC, MP3, AC-3, E-AC-3 or ALAC audio, MKV for anything else), `mp4` or `mkv`; the player gives `destination` the container's extension and never overwrites a file. A copy needs a raw snapshot (`invalid` from a processed one), leaves subtitle tracks and chapters out, and keeps the snapshot lease, the job limits and cancellation of every export. One the player cannot make (no video stream, a cover image, an external audio track, codecs `mp4` cannot hold) ends `failed` with the new code **`unsupported`**. See `Snapshot.export`.
8
+ - **The range written** (player 0.7.6): a finished clip's `outcome` has `start` and `end`, the source times of its first frame and of the end of its last frame: a copy's earliest written frame (its keyframe, or an open GOP's leading frames) and its actual end, a re-encode's start and start plus the encoded length.
9
+ - **`JobState.progress`** (player 0.7.6): `{ done, total }`, seconds of the clip written and to write, from FFmpeg's progress output, at most 4 updates a second; `null` while unknown (before writing starts, and for frames). The player still checks the file after `done` reaches `total`.
10
+ - **`ExportEncoding`** (`encoding` of `format: "mp4"`, player 0.7.6): `crf` (14–30), `speed` (`faster`, `balanced`, `smaller`), `maxHeight` (1080, 720, 480; a ceiling), `fps` (60, 30; a ceiling), `audioBitrate` (128, 192, 256 kb/s) `threads` (`single`, or `auto`: half the logical processors, at most 8) and `videoBitrate: "source"` (aim at the source video's average bitrate instead of a CRF, peaks up to 1.5 times it: a "Same as source" preset; not with `crf`; `unsupported` when the player cannot tell the source's bitrate). A closed set, never encoder arguments; the SDK and the player refuse other values with `invalid`. Without it, clips are encoded as before.
11
+ - **Error codes**: `export-failed` (an export job's failure) and `unsupported` (a copy the player cannot make, a source bitrate it cannot tell) are listed in [Reference: errors](docs/reference/errors.md).
12
+ - **`TestHost`**: `jobs.start` takes `rawCopy`, `encoding` and `container` and refuses what the player refuses, now also a job kind that does not match the snapshot (`rawPng` from a processed snapshot, say: `invalid`, as the player answers; plugins that go through `Snapshot.export` never send one); a running clip reports half its `progress`, and a finished one `start` and `end` (a copy 1 s before the snapshot time, as if snapped to a keyframe, its destination named for the container). **`TestHost.exportFailure`** makes running exports fail with a given code, for testing a plugin's `unsupported` path.
13
+ - **Exports run at below-normal priority** (player 0.7.6): the player starts the FFmpeg that writes a frame or a clip below normal priority (Windows `BELOW_NORMAL_PRIORITY_CLASS`, `nice 10` elsewhere), so playback keeps its rate while an export uses the cores; probes stay at normal priority.
14
+
15
+ ## 1.6.9
16
+
17
+ **Who must change: nobody.** Additions only: timeline ranges, OSD messages with action buttons, a limits constant, a wire method and `TestHost` support, and documentation of a bottom bar's `content-full-width`. `apiVersion` stays `vestra/1`, no new capability (`timeline.marks`, `playback.read` and `ui.osd` cover them). A plugin that does not use them sends exactly what it sent before; `onChange` of `plugin.timeline` still runs only when the marks change. One that uses them needs player 0.7.6 (`"minPlayerVersion": "0.7.6"`): the player builds `plugin` from its own SDK, so on older players `setRanges` and `ranges` do not exist (a `TypeError`), and `osd.show` there takes no options.
18
+
19
+ - **Timeline ranges** (player 0.7.6): `TimelineLayer.setRanges(ranges, { ifCurrent? })` puts spans of the media on the seek bar, each `{ start, end, style, title? }` with `style` `"selection"` (a span the user picked to act on, such as a clip to export; drawn in the accent colour) or `"region"` (a part of the media the plugin describes; drawn dimmer). A layer's marks and ranges are replaced on their own (`set` keeps the ranges, `setRanges` keeps the marks); disposing the layer, its scope or stopping the plugin removes both, and a new source clears every layer. At most 100 ranges per layer, `0 ≤ start < end`, `end` within the media, titles ≤ 100 characters (`TIMELINE_LIMITS`, new, with the marks' 1000); failures go to `onError` as for marks. `plugin.timeline.ranges` (new, `playback.read`) is every layer's ranges merged by start, with `TimelineRange` (`start`, `end`, `style`, `title`, `plugin`); read it in a binding to follow it. Wire: `timeline.setRanges` (needs `timeline.marks`), and `ranges` beside `marks` in `timeline.marks` and `timeline.changed`.
20
+ - **OSD messages with actions** (player 0.7.6): `plugin.osd.show(text, args?, { actions })` ends the message in up to two buttons (`OsdAction`: `title`, translated like the text, 1–40 characters; `command`, the local id of one of the plugin's own commands). The message stays about 4 seconds instead of 2 and does not fade while the pointer is on it and moving (it goes 2 seconds after the pointer leaves or rests, and at the latest 30 seconds after it showed); a click runs the command as if picked from a menu and removes the message. It stays one line, and nothing in it is created or removed while the pointer moves. A newer message replaces it with its buttons; the buttons go when the plugin stops, and while they show the plugin is not stopped for being idle. `osd.show` rejects with `invalid` for more than two actions, a bad title or a command the plugin has not declared. `OSD_LIMITS` gains `actions` (2) and `actionChars` (40). Older players ignore `actions`.
21
+ - **`TestHost`**: `timeline.rangeLayers`, `timeline.ranges` and `setTimelineRanges(pluginId, ranges)` (another plugin's ranges); `timeline.setRanges` checked as the player does; `osdActions` (each message's buttons, in step with `osd`) and `pressOsdAction(index)`, which runs the command like a click.
22
+ - **A bottom bar's `content-full-width`** (player 0.7.6, docs only): a bar that drops controls of its own also reports its row with every control shown in `content-full-width`, a public `length` property declared like `content-min-width`. The player then gives plugin buttons only the width beyond it, so every plugin button moves to » before the bar drops a control, and narrowing the window never brings a button back. A bar with only `content-min-width` keeps the earlier rule. [Writing a bottom bar](docs/guidelines/interface.md#writing-a-bottom-bar) and [Reference: theme](docs/reference/theme.md#bar-buttons) say so.
23
+
3
24
  ## 1.6.8
4
25
 
5
26
  **Who must change: nobody; authors of a bottom bar should move to `content-min-width`.** Documentation, a read-only Slint property and a removed CLI warning: no change to the TypeScript API, the wire or `TestHost`, `apiVersion` stays `vestra/1`, no new capability. A plugin's `button` declarations stay as they are. A bottom bar that still places `BarButtons` keeps working on player 0.7.6 (it shows that strip, and the player draws no second one); it is deprecated and goes in a later patch.
@@ -20,7 +41,7 @@
20
41
  - **`plugin.commands.keys(id)`** (player 0.7.6): the keys of one of the plugin's **own** commands, as the player's menus and command palette write them (`["W"]`, `["Ctrl+B"]`, `["⌘B"]` on macOS, arrows as `←`), always the current binding: the user's keys after a change, `[]` when the user cleared them or turned on Ignore Default Shortcuts, `[]` for a key another command holds; mouse bindings and (outside macOS) `Cmd` bindings left out. Read-only and synchronous; read in a `bind` function, the view follows every change. Another plugin's commands, or an unknown id, read `[]`; it needs `commands` only, not `bindings.manage`. The first read asks the player (`commands.keys`) and subscribes to the `commands.keys` event, which the player sends only to the plugin whose own labels changed, at most every 200 ms; until the first answer every command reads `[]`. See [Key hints beside controls](docs/explanation/commands.md#key-hints-beside-controls).
21
42
  - **`plugin.commands.onKeysChange(listener)`** (player 0.7.6): runs after any of the plugin's keys changed. `commands.keys` is a wake event, like `bindings.changed`: a listener (or a binding that reads `keys`) does not keep a plugin running while idle, and a plugin in standby is started for a change of its keys.
22
43
  - **`KeyHint`** in `@vestra/widgets.slint`: a key cap (monospace `font-xs`, `text-dim`, a 1px border, both palettes); an empty `text` draws nothing and takes no space.
23
- - **`ToolTipArea.keys`** (a list of the new struct `ToolTipKey`, `{ key, text }`) and **`ToolTipArea.detail`**: the tip reads "Brightness [W] − [E] + · double-click: Default" with key caps. Without them the tip is the plain text tip as before.
44
+ - **`ToolTipArea.keys`** (a list of the new struct `ToolTipKey`, `{ key, text }`) and **`ToolTipArea.detail`**: the tip reads "Brightness [W] − [E] + · double-click: Default" with key caps. Without them the tip is the plain text tip as before.
24
45
  - **`TestHost.rebind(command, inputs | null)`** changes or clears a key of the plugin's command as the user does; `TestHost` answers `commands.keys` and pushes the event like the player, writing labels for `host.systemInfo.os` (macOS by default: `⇧W`, `⌘B`). `bindings.set` and `bindings.reset` push it too.
25
46
  - The [theme-widgets example](examples/theme-widgets) shows a slider's two commands (F7, F8) as key caps and in its tooltip.
26
47
 
@@ -71,8 +71,12 @@ export const API_REQUESTS = Object.freeze({
71
71
  "Subtitles.remove": [{ method: "subtitles.remove" }, readsSubtitles],
72
72
  "Subtitles.setStyle": [{ method: "subtitles.style" }, readsSubtitles],
73
73
  "Timeline.marks": [{ method: "timeline.marks" }],
74
+ "Timeline.ranges": [{ method: "timeline.marks" }],
74
75
  "Timeline.onChange": [listen("timeline.changed")],
75
- "Timeline.layer": [{ method: "timeline.set" }],
76
+ "Timeline.layer": [
77
+ { method: "timeline.set" },
78
+ { method: "timeline.setRanges" },
79
+ ],
76
80
  "MainWindow.onChange": [listen("window.state")],
77
81
  "MainWindow.setVideoArea": [{ method: "window.setVideoArea" }],
78
82
  "MainWindow.setAlwaysOnTop": [{ method: "window.setAlwaysOnTop" }],
Binary file
@@ -300,7 +300,10 @@ export const HOST_CAPABILITIES = {
300
300
  },
301
301
  {
302
302
  label: "Show marks on the progress bar",
303
- methods: [{ name: "timeline.set", needs: "timeline.marks" }],
303
+ methods: [
304
+ { name: "timeline.set", needs: "timeline.marks" },
305
+ { name: "timeline.setRanges", needs: "timeline.marks" },
306
+ ],
304
307
  name: "timeline.marks",
305
308
  requires: [],
306
309
  residency: "live",
package/dist/media.d.ts CHANGED
@@ -160,9 +160,27 @@ export type MediaJob = {
160
160
  duration: string;
161
161
  };
162
162
  };
163
+ /** A stream copy (player 0.7.6): what was copied and from where. */
164
+ copy?: {
165
+ container: "mp4" | "mkv";
166
+ videoCodec: string;
167
+ audioCodec: string | null;
168
+ /** The keyframe the copy starts on, source seconds. */
169
+ keyframe: number;
170
+ /** FFmpeg's input seek, the keyframe rounded down to a microsecond. */
171
+ seek: number;
172
+ written: {
173
+ start: number;
174
+ end: number;
175
+ };
176
+ };
163
177
  };
164
178
  outcome: null | {
165
179
  destination: string;
180
+ /** Clips (player 0.7.6): first frame, source seconds. */
181
+ start?: number;
182
+ /** Clips (player 0.7.6): end of the last frame, source seconds. */
183
+ end?: number;
166
184
  } | {
167
185
  error: {
168
186
  code: string;
@@ -171,6 +189,24 @@ export type MediaJob = {
171
189
  } | {
172
190
  cancelled: true;
173
191
  };
192
+ /**
193
+ * Player 0.7.6: seconds of a clip written and to write while FFmpeg runs;
194
+ * `null` (or absent from older players) while unknown.
195
+ */
196
+ progress?: {
197
+ done: number;
198
+ total: number;
199
+ } | null;
200
+ };
201
+ /** The wire form of `ExportEncoding` (`jobs.start`). */
202
+ export type WireEncoding = {
203
+ crf?: number;
204
+ speed?: "faster" | "balanced" | "smaller";
205
+ maxHeight?: 1080 | 720 | 480;
206
+ fps?: 60 | 30;
207
+ audioBitrate?: 128 | 192 | 256;
208
+ threads?: "single" | "auto";
209
+ videoBitrate?: "source";
174
210
  };
175
211
  export type MediaOperation = {
176
212
  status: "pending" | "unknown" | "unknownExpired";
@@ -210,6 +246,13 @@ export declare function mediaClients(transport: Transport): {
210
246
  } | {
211
247
  kind: "rawMp4" | "processedMp4";
212
248
  end: number;
249
+ /** Player 0.7.6. */
250
+ encoding?: WireEncoding;
251
+ } | {
252
+ /** Stream copy, player 0.7.6. */
253
+ kind: "rawCopy";
254
+ end: number;
255
+ container?: "auto" | "mp4" | "mkv";
213
256
  })) => Promise<{
214
257
  jobId: number;
215
258
  status: "queued";
@@ -16,6 +16,7 @@ export { HOST_CAPABILITIES } from "./host-capabilities.ts";
16
16
  export { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "./commands.ts";
17
17
  export { BINDING_INPUTS } from "./plugin/bindings.ts";
18
18
  export { OSD_LIMITS } from "./plugin/osd.ts";
19
+ export { TIMELINE_LIMITS } from "./plugin/timeline.ts";
19
20
  export { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./plugin/services.ts";
20
21
  export { SUBTITLE_LIMITS } from "./plugin/subtitles.ts";
21
22
  export { VIDEO_PROCESSING_LIMITS } from "./processing.ts";
@@ -14,6 +14,7 @@ export { HOST_CAPABILITIES } from "./host-capabilities.js";
14
14
  export { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "./commands.js";
15
15
  export { BINDING_INPUTS } from "./plugin/bindings.js";
16
16
  export { OSD_LIMITS } from "./plugin/osd.js";
17
+ export { TIMELINE_LIMITS } from "./plugin/timeline.js";
17
18
  export { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./plugin/services.js";
18
19
  export { SUBTITLE_LIMITS } from "./plugin/subtitles.js";
19
20
  export { VIDEO_PROCESSING_LIMITS } from "./processing.js";
@@ -1,6 +1,31 @@
1
1
  import { failure, sleep, uncertain } from "./runtime.js";
2
2
  import { playerOf } from "./player.js";
3
3
  const TERMINAL = ["completed", "failed", "cancelled"];
4
+ /** The player's sets for each {@link ExportEncoding} field. */
5
+ const ENCODING = {
6
+ crf: Array.from({ length: 17 }, (_, i) => i + 14),
7
+ speed: ["faster", "balanced", "smaller"],
8
+ maxHeight: [1080, 720, 480],
9
+ fps: [60, 30],
10
+ audioBitrate: [128, 192, 256],
11
+ threads: ["single", "auto"],
12
+ videoBitrate: ["source"],
13
+ };
14
+ /** Refuse what the player would refuse, with the field named. */
15
+ function checkEncoding(encoding) {
16
+ if (typeof encoding !== "object" || encoding === null)
17
+ throw failure("invalid", "encoding must be an object");
18
+ for (const [key, value] of Object.entries(encoding)) {
19
+ const allowed = ENCODING[key];
20
+ if (!allowed)
21
+ throw failure("invalid", `unknown encoding option ${key}`);
22
+ if (value !== undefined && !allowed.includes(value))
23
+ throw failure("invalid", `encoding.${key} must be one of ${allowed.join(", ")}`);
24
+ }
25
+ if (encoding.crf !== undefined && encoding.videoBitrate !== undefined)
26
+ throw failure("invalid", "encoding takes crf or videoBitrate, not both");
27
+ return Object.fromEntries(Object.entries(encoding).filter(([, value]) => value !== undefined));
28
+ }
4
29
  /** One counter for snapshot and job operations, as the host's book expects. */
5
30
  class MediaService {
6
31
  runtime;
@@ -159,8 +184,28 @@ class SnapshotHandle {
159
184
  let job;
160
185
  switch (options.format) {
161
186
  case "mp4":
162
- job = { kind: `${mode}Mp4`, end: options.end };
187
+ job = {
188
+ kind: `${mode}Mp4`,
189
+ end: options.end,
190
+ ...(options.encoding === undefined
191
+ ? {}
192
+ : { encoding: checkEncoding(options.encoding) }),
193
+ };
163
194
  break;
195
+ case "copy": {
196
+ if (processed)
197
+ throw failure("invalid", "a stream copy needs a raw snapshot: effects need a re-encode");
198
+ const container = options.container;
199
+ if (container !== undefined &&
200
+ !["auto", "mp4", "mkv"].includes(container))
201
+ throw failure("invalid", "container must be auto, mp4 or mkv");
202
+ job = {
203
+ kind: "rawCopy",
204
+ end: options.end,
205
+ ...(container === undefined ? {} : { container }),
206
+ };
207
+ break;
208
+ }
164
209
  case "jpeg": {
165
210
  const quality = options.quality;
166
211
  if (quality !== undefined &&
@@ -46,6 +46,10 @@ export declare const OSD_LIMITS: Readonly<{
46
46
  readonly minColumns: 8;
47
47
  /** The largest `OsdPanelOptions.maxColumns` (1000). */
48
48
  readonly maxColumns: 1000;
49
+ /** Action buttons of one `osd.show` message (`OsdShowOptions.actions`; player 0.7.6) (2). */
50
+ readonly actions: 2;
51
+ /** Characters of an action's title, as shown (40; at least 1 that is not white space). */
52
+ readonly actionChars: 40;
49
53
  }>;
50
54
  /**
51
55
  * Copy and check template arguments: an array or object of at most 16
@@ -47,7 +47,32 @@ export const OSD_LIMITS = Object.freeze({
47
47
  minColumns: 8,
48
48
  /** The largest `OsdPanelOptions.maxColumns` (1000). */
49
49
  maxColumns: 1000,
50
+ /** Action buttons of one `osd.show` message (`OsdShowOptions.actions`; player 0.7.6) (2). */
51
+ actions: 2,
52
+ /** Characters of an action's title, as shown (40; at least 1 that is not white space). */
53
+ actionChars: 40,
50
54
  });
55
+ /** Copy and check `OsdShowOptions.actions`: what the SDK can check before the player does. */
56
+ function osdActions(options) {
57
+ if (options === undefined || options === null)
58
+ return undefined;
59
+ if (typeof options !== "object")
60
+ throw failure("invalid", "options is an object");
61
+ const actions = options.actions;
62
+ if (actions === undefined)
63
+ return undefined;
64
+ if (!Array.isArray(actions) || actions.length > OSD_LIMITS.actions)
65
+ throw failure("invalid", `actions is a list of at most ${OSD_LIMITS.actions}`);
66
+ return actions.map((action, i) => {
67
+ const { title, command } = (action ?? {});
68
+ if (typeof command !== "string" || command === "")
69
+ throw failure("invalid", `actions[${i}].command is a command id`);
70
+ return {
71
+ title: shownText(title, undefined, `actions[${i}].title`),
72
+ command,
73
+ };
74
+ });
75
+ }
51
76
  /**
52
77
  * Copy and check template arguments: an array or object of at most 16
53
78
  * strings, finite numbers, texts and lists (`TEXT_LIMITS`).
@@ -134,6 +159,6 @@ class PanelHandle {
134
159
  }
135
160
  }
136
161
  export const osd = (scope) => ({
137
- show: async (text, args) => scope.runtime.host.osd.show(shownText(text, args), templateArgs(args)),
162
+ show: async (text, args, options) => scope.runtime.host.osd.show(shownText(text, args), templateArgs(args), osdActions(options)),
138
163
  panel: () => new PanelHandle(scope),
139
164
  });
@@ -7,6 +7,7 @@ import type { BuiltinCommand, BuiltinSubmenu } from "../commands.ts";
7
7
  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
+ import { TIMELINE_LIMITS } from "./timeline.ts";
10
11
  import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./services.ts";
11
12
  import { SUBTITLE_LIMITS } from "./subtitles.ts";
12
13
  import { BINDING_INPUTS } from "./bindings.ts";
@@ -36,6 +37,8 @@ export type { CapabilityInfo };
36
37
  export { MESSAGE_LIMIT };
37
38
  /** The player's limits on the on-screen display. */
38
39
  export { OSD_LIMITS };
40
+ /** The player's limits on a timeline layer. */
41
+ export { TIMELINE_LIMITS };
39
42
  /** The player's limits on stored secrets. */
40
43
  export { SECRET_LIMITS };
41
44
  /** The player's limits on `plugin.storage.session`. */
@@ -647,6 +650,43 @@ export declare class TestHost implements Transport {
647
650
  * template and args the plugin sent are in `host.sent("osd.show")`.
648
651
  */
649
652
  readonly osd: string[];
653
+ /**
654
+ * The action buttons of each `osd.show` message, in step with `osd`
655
+ * (index for index): each button's title as shown and its command; `[]`
656
+ * for a message without actions (SDK 1.6.9).
657
+ */
658
+ readonly osdActions: {
659
+ /** The button title as shown, in the plugin's locale. */
660
+ title: string;
661
+ /** The local id of the plugin's command the button runs. */
662
+ command: string;
663
+ }[][];
664
+ /**
665
+ * Press button `index` of the newest OSD message, as the user does
666
+ * (player 0.7.6): its command runs as if picked from a menu, and settles.
667
+ * A disabled or busy command does nothing, as in the player.
668
+ *
669
+ * @param index - 0 or 1.
670
+ *
671
+ * @throws `Error` (rejects) The newest message has no button `index`.
672
+ *
673
+ * @example Press "Show in Folder" on the saved message
674
+ * The test checks the button the plugin put on its newest message, then
675
+ * presses it as the user would: the plugin's `reveal` command runs.
676
+ * ```ts
677
+ * import assert from "node:assert/strict";
678
+ * declare const host: TestHost;
679
+ * // ---cut---
680
+ * assert.deepEqual(host.osdActions.at(-1), [
681
+ * { title: "Show in Folder", command: "reveal" },
682
+ * ]);
683
+ * await host.pressOsdAction(0); // runs the plugin's `reveal` command
684
+ * ```
685
+ *
686
+ * @capability None.
687
+ * @since 1.6.9
688
+ */
689
+ pressOsdAction(index: number): Promise<void>;
650
690
  /** The text of this plugin's osd panel as shown, `null` when hidden. */
651
691
  osdPanel: string | null;
652
692
  /**
@@ -948,7 +988,65 @@ export declare class TestHost implements Transport {
948
988
  /** The mark's title, `null` without one. */
949
989
  title: string | null;
950
990
  }[];
991
+ /**
992
+ * Each plugin's ranges by plugin id; layers without ranges are absent
993
+ * (SDK 1.6.9).
994
+ */
995
+ rangeLayers: Map<string, {
996
+ /** Seconds from the start. */
997
+ start: number;
998
+ /** Seconds from the start, after `start`. */
999
+ end: number;
1000
+ /** `"selection"` or `"region"`. */
1001
+ style: string;
1002
+ /** The range's title, `null` without one. */
1003
+ title: string | null;
1004
+ }[]>;
1005
+ /** Every layer's ranges merged, sorted by start, with the plugin's id. */
1006
+ readonly ranges: {
1007
+ /** The manifest id of the plugin that published the range. */
1008
+ plugin: string;
1009
+ /** Seconds from the start. */
1010
+ start: number;
1011
+ /** Seconds from the start, after `start`. */
1012
+ end: number;
1013
+ /** `"selection"` or `"region"`. */
1014
+ style: string;
1015
+ /** The range's title, `null` without one. */
1016
+ title: string | null;
1017
+ }[];
951
1018
  };
1019
+ /**
1020
+ * Simulate another plugin publishing its timeline ranges (or clearing
1021
+ * them with `[]`); pushes `timeline.changed` when the plugin subscribed.
1022
+ *
1023
+ * @param pluginId - The other plugin's manifest id.
1024
+ * @param ranges - All its ranges.
1025
+ *
1026
+ * @example Let another plugin publish a clip range
1027
+ * The plugin under test sees a range another plugin put on the seek bar:
1028
+ * `setTimelineRanges` stores that plugin's ranges and pushes
1029
+ * `timeline.changed`, and `settle()` lets it arrive.
1030
+ * ```ts
1031
+ * import assert from "node:assert/strict";
1032
+ * declare const host: TestHost;
1033
+ * // ---cut---
1034
+ * host.setTimelineRanges("community.clips", [
1035
+ * { start: 40, end: 52.5, style: "selection", title: "Clip" },
1036
+ * ]);
1037
+ * await host.settle();
1038
+ * assert.equal(plugin.timeline.ranges[0]?.plugin, "community.clips");
1039
+ * ```
1040
+ *
1041
+ * @capability None.
1042
+ * @since 1.6.9
1043
+ */
1044
+ setTimelineRanges(pluginId: string, ranges: {
1045
+ start: number;
1046
+ end: number;
1047
+ style: string;
1048
+ title?: string | null;
1049
+ }[]): void;
952
1050
  /**
953
1051
  * Simulate another plugin publishing its timeline layer (or clearing it
954
1052
  * with `[]`); pushes `timeline.changed` when the plugin subscribed.
@@ -1556,9 +1654,27 @@ export declare class TestHost implements Transport {
1556
1654
  /**
1557
1655
  * Export jobs by job id. Each state query advances a job one step
1558
1656
  * (`queued` → `running` → `completed`; `cancelling` → `cancelled`).
1559
- * Wire-level.
1657
+ * A running clip reports half its `progress`; a finished clip's
1658
+ * `outcome` has `start` and `end` (a stream copy starts 1 s before the
1659
+ * snapshot time, as if snapped to a keyframe, and its destination gets
1660
+ * the extension of its container: `.mkv` for `container: "mkv"`, else
1661
+ * `.mp4`). Wire-level.
1560
1662
  */
1561
1663
  readonly jobs: Map<number, MediaJob>;
1664
+ /**
1665
+ * Set it to make every running export end `failed` with this error on
1666
+ * its next state query, as the player reports one (`unsupported` for a
1667
+ * stream copy it cannot make, `export-failed` otherwise); `null` (the
1668
+ * default) completes them.
1669
+ *
1670
+ * @since 1.6.10
1671
+ */
1672
+ exportFailure: {
1673
+ /** The `outcome.error.code`. */
1674
+ code: string;
1675
+ /** The `outcome.error.message`. */
1676
+ message: string;
1677
+ } | null;
1562
1678
  /**
1563
1679
  * `openStream`'s transport: a local stream (a Unix socket on macOS and
1564
1680
  * Linux, a named pipe on Windows) or, with `"tcp"`, loopback TCP where the