@vestra-player/sdk 1.6.9 → 1.6.11

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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.11
4
+
5
+ **Who must change: nobody.** Additions only: a mount slot, a panel position, a button icon and `TestHost` support; `apiVersion` stays `vestra/1`, no new capability. A plugin that mounts `"adjust"` sets `minPlayerVersion` to `0.7.6`: older players refuse the slot (`invalid`), which fails the activation like any refused mount.
6
+
7
+ - **The adjust slot** (player 0.7.6, menu redesign step 3b): `plugin.panels.mount("adjust", file, { title })` puts a panel in the adjust column, for controls that change what is playing (picture, sound, subtitles, speed). Any number of plugins mount there, one tab each. The slot is a contract about purpose; the player decides how it looks and may change that. The rule, in [Slots](docs/explanation/ui.md#slots-what-each-one-is-for) and on `Panels.mount`: if the user still looks at the panel after closing it, it is `"sidebar"`; otherwise it is `"adjust"`. Today the player draws a second column between the video and the sidebar, with text tabs and the opening key under each title (the key of the command the panel's commands name as their `home`), no close or detach button, floating over the video in a narrow window and sliding out from the right edge in fullscreen; it starts closed and is compiled the first time it opens.
8
+ - **`plugin.panels.adjust`**: the position, the same shape as `panels.sidebar` without `setDetached` (`PanelSlot`: `state`, `panels`, `onChange`, `setVisible`, `toggle`, `activate`). `Panel.show` and `Panel.toggle` work on an adjust panel as on a sidebar tab. Players before 0.7.6 build the `plugin` object with their own SDK, which has no `adjust`: there `plugin.panels.adjust` is `undefined` and reading its `state` throws a `TypeError` (as for `commands.keys`), so declare `minPlayerVersion` 0.7.6 or test it first.
9
+ - **Wire**: `ui.mount` and `layout.set` take `slot: "adjust"`; `layout.state` reports `adjust` (a `LayoutSlot`, never `detached`); `LayoutSlot` names the adjust column.
10
+ - **`BAR_BUTTON_ICONS`** gains `"sliders"` (the player's Adjust button), and `BUILTIN_COMMANDS` the player's `host/adjust`. `@vestra/bar-buttons.slint` (synced): `BarButtonsHost.adjust`, counted in `fixed`; `@vestra/icons.slint`: `sliders`.
11
+ - **`TestHost`**: `layout.adjust` (starts hidden), `mountOther("adjust", …)`, `selectTab` for adjust tabs, `layout.set` refuses detaching the adjust column, `keepsRunning` names an open adjust column's current tab; an unknown mount slot is refused with `invalid`, as by the player.
12
+ - **Example** [adjust-panel](docs/examples/adjust-panel.md): a speed slider in the adjust column, opened and closed by its own key.
13
+
14
+ ## 1.6.10
15
+
16
+ **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.
17
+
18
+ - **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`.
19
+ - **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.
20
+ - **`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`.
21
+ - **`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.
22
+ - **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).
23
+ - **`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.
24
+ - **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.
25
+
3
26
  ## 1.6.9
4
27
 
5
28
  **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.
@@ -29,7 +52,7 @@
29
52
  - **`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).
30
53
  - **`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.
31
54
  - **`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.
32
- - **`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.
55
+ - **`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.
33
56
  - **`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.
34
57
  - The [theme-widgets example](examples/theme-widgets) shows a slider's two commands (F7, F8) as key caps and in its tooltip.
35
58
 
@@ -136,7 +136,9 @@ export declare const MENU_IDS: readonly ["open", "playback", "subtitles", "video
136
136
  * The icons a bottom-bar button may name (`CommandOptions.button.icon`):
137
137
  * every icon of the player's V-Line set, `@vestra/icons.slint` (draw one
138
138
  * in your own Slint with `Icon { name: "store"; }`). The player's tests check
139
- * that this list is its icon set.
139
+ * that this list is its icon set. `"sliders"` (three sliders, the player's
140
+ * Adjust button) is new in player 0.7.6 (SDK 1.6.11); older players refuse
141
+ * it as a button icon with `invalid`.
140
142
  *
141
143
  * @example Check in a test that the button's icon exists
142
144
  * The test fails before release if the plugin's bar button names an icon
@@ -154,7 +156,7 @@ export declare const MENU_IDS: readonly ["open", "playback", "subtitles", "video
154
156
  *
155
157
  * @category Testing
156
158
  */
157
- export declare const BAR_BUTTON_ICONS: readonly ["play", "pause", "stop", "back", "forward", "open", "volume", "mute", "playlist", "detach", "attach", "close", "failed", "move-top", "move-up", "move-down", "move-bottom", "search", "chapter", "bookmark", "check-task", "check-box", "menu-check", "submenu", "scroll-up", "scroll-down", "caret-down", "sort-asc", "sort-desc", "tag-remove", "preview-play", "add", "remove", "sort", "fullscreen", "exit-fullscreen", "store", "ai"];
159
+ export declare const BAR_BUTTON_ICONS: readonly ["play", "pause", "stop", "back", "forward", "open", "volume", "mute", "playlist", "detach", "attach", "close", "failed", "move-top", "move-up", "move-down", "move-bottom", "search", "chapter", "bookmark", "check-task", "check-box", "menu-check", "submenu", "scroll-up", "scroll-down", "caret-down", "sort-asc", "sort-desc", "tag-remove", "preview-play", "add", "remove", "sort", "fullscreen", "exit-fullscreen", "store", "ai", "sliders"];
158
160
  /**
159
161
  * The player's limits on bottom-bar buttons (`CommandOptions.button`), the
160
162
  * same values as the player's (its tests check that they agree). A button
package/dist/commands.js CHANGED
@@ -141,7 +141,9 @@ export const MENU_IDS = [
141
141
  * The icons a bottom-bar button may name (`CommandOptions.button.icon`):
142
142
  * every icon of the player's V-Line set, `@vestra/icons.slint` (draw one
143
143
  * in your own Slint with `Icon { name: "store"; }`). The player's tests check
144
- * that this list is its icon set.
144
+ * that this list is its icon set. `"sliders"` (three sliders, the player's
145
+ * Adjust button) is new in player 0.7.6 (SDK 1.6.11); older players refuse
146
+ * it as a button icon with `invalid`.
145
147
  *
146
148
  * @example Check in a test that the button's icon exists
147
149
  * The test fails before release if the plugin's bar button names an icon
@@ -198,6 +200,7 @@ export const BAR_BUTTON_ICONS = [
198
200
  "exit-fullscreen",
199
201
  "store",
200
202
  "ai",
203
+ "sliders",
201
204
  ];
202
205
  /**
203
206
  * The player's limits on bottom-bar buttons (`CommandOptions.button`), the
@@ -601,6 +604,7 @@ host | enter-fullscreen | (Enter Fullscreen)
601
604
  host | exit-fullscreen | (Leave Fullscreen)
602
605
  host | sidebar | (Toggle Sidebar)
603
606
  host | palette | (Command Palette) | Ctrl+Shift+P
607
+ host | adjust | (Adjust)
604
608
  vestra.info | info.brief | (Brief Info) | Shift+Tab
605
609
  vestra.playlist | playlist.play | (Play) | Enter, P | panel
606
610
  vestra.playlist | playlist.list-add | (Add Files…) | Ctrl+I | panel
Binary file
package/dist/i18n.js CHANGED
@@ -942,6 +942,7 @@ export const HOST_ZH_HANS = {
942
942
  "bottom bar": "底栏",
943
943
  sidebar: "侧栏",
944
944
  "video backdrop": "视频背景",
945
+ "adjust column": "调节栏",
945
946
  "The {} is used by {}": "{}已被 {} 使用",
946
947
  "{} is resolved by {}": "{} 已由 {} 解析",
947
948
  "File type .{}": "文件类型 .{}",
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";
@@ -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 &&
@@ -601,7 +601,7 @@ export declare class TestHost implements Transport {
601
601
  * See {@link TestHost.mounted}.
602
602
  */
603
603
  panel: (Omit<FakeWindow, "callbacks" | "userClose"> & {
604
- /** `"bottom"`, `"sidebar"` or `"backdrop"`. */
604
+ /** `"bottom"`, `"sidebar"`, `"adjust"` or `"backdrop"`. */
605
605
  slot: string;
606
606
  /** The exported component the SDK found in the file. */
607
607
  component: string;
@@ -643,6 +643,12 @@ export declare class TestHost implements Transport {
643
643
  * the current tab's (the plugin under test is owner 1).
644
644
  */
645
645
  sidebar: LayoutSlot | null;
646
+ /**
647
+ * The adjust column (SDK 1.6.11, player 0.7.6), like the sidebar but
648
+ * never `detached`. It starts hidden (`preferred: false`, `reason:
649
+ * "userHidden"`), as the player's does until the user opens it.
650
+ */
651
+ adjust: LayoutSlot | null;
646
652
  };
647
653
  /**
648
654
  * Every `osd.show` text as the player shows it (translated with the
@@ -1331,7 +1337,7 @@ export declare class TestHost implements Transport {
1331
1337
  *
1332
1338
  * @capability None.
1333
1339
  */
1334
- mountOther(slot: "bottom" | "sidebar" | "backdrop", plugin: string, options?: {
1340
+ mountOther(slot: "bottom" | "sidebar" | "adjust" | "backdrop", plugin: string, options?: {
1335
1341
  title?: string;
1336
1342
  }): void;
1337
1343
  /**
@@ -1654,9 +1660,27 @@ export declare class TestHost implements Transport {
1654
1660
  /**
1655
1661
  * Export jobs by job id. Each state query advances a job one step
1656
1662
  * (`queued` → `running` → `completed`; `cancelling` → `cancelled`).
1657
- * Wire-level.
1663
+ * A running clip reports half its `progress`; a finished clip's
1664
+ * `outcome` has `start` and `end` (a stream copy starts 1 s before the
1665
+ * snapshot time, as if snapped to a keyframe, and its destination gets
1666
+ * the extension of its container: `.mkv` for `container: "mkv"`, else
1667
+ * `.mp4`). Wire-level.
1658
1668
  */
1659
1669
  readonly jobs: Map<number, MediaJob>;
1670
+ /**
1671
+ * Set it to make every running export end `failed` with this error on
1672
+ * its next state query, as the player reports one (`unsupported` for a
1673
+ * stream copy it cannot make, `export-failed` otherwise); `null` (the
1674
+ * default) completes them.
1675
+ *
1676
+ * @since 1.6.10
1677
+ */
1678
+ exportFailure: {
1679
+ /** The `outcome.error.code`. */
1680
+ code: string;
1681
+ /** The `outcome.error.message`. */
1682
+ message: string;
1683
+ } | null;
1660
1684
  /**
1661
1685
  * `openStream`'s transport: a local stream (a Unix socket on macOS and
1662
1686
  * Linux, a named pipe on Windows) or, with `"tcp"`, loopback TCP where the
@@ -2811,7 +2835,7 @@ export declare class TestHost implements Transport {
2811
2835
  * @since 1.0.0
2812
2836
  */
2813
2837
  mounted(): Omit<FakeWindow, "callbacks" | "userClose"> & {
2814
- /** `"bottom"`, `"sidebar"` or `"backdrop"`. */
2838
+ /** `"bottom"`, `"sidebar"`, `"adjust"` or `"backdrop"`. */
2815
2839
  slot: string;
2816
2840
  /** The exported component the SDK found in the file. */
2817
2841
  component: string;
@@ -167,6 +167,24 @@ export { LIST_SEPARATORS };
167
167
  * ```
168
168
  */
169
169
  export { LOCALES };
170
+ /** `jobs.start`'s `encoding`, checked against the player's sets. */
171
+ function validEncoding(value) {
172
+ if (typeof value !== "object" || value === null || Array.isArray(value))
173
+ return false;
174
+ const sets = {
175
+ crf: Array.from({ length: 17 }, (_, i) => i + 14),
176
+ speed: ["faster", "balanced", "smaller"],
177
+ maxHeight: [1080, 720, 480],
178
+ fps: [60, 30],
179
+ audioBitrate: [128, 192, 256],
180
+ threads: ["single", "auto"],
181
+ videoBitrate: ["source"],
182
+ };
183
+ const fields = value;
184
+ if (fields.crf !== undefined && fields.videoBitrate !== undefined)
185
+ return false;
186
+ return Object.entries(value).every(([key, v]) => sets[key]?.includes(v) ?? false);
187
+ }
170
188
  /** A `plugin.storage.session` key, checked as the player does. */
171
189
  function sessionKey(key) {
172
190
  if (typeof key !== "string" ||
@@ -310,6 +328,7 @@ const SLOT_LABELS = {
310
328
  bottom: "bottom bar",
311
329
  sidebar: "sidebar",
312
330
  backdrop: "video backdrop",
331
+ adjust: "adjust column",
313
332
  };
314
333
  /** Texts only the test host uses, with their Chinese of SDK 1.5.8. */
315
334
  const TEST_ZH_HANS = {
@@ -532,6 +551,7 @@ export class TestHost {
532
551
  layout = {
533
552
  bottom: null,
534
553
  sidebar: null,
554
+ adjust: null,
535
555
  };
536
556
  /**
537
557
  * Every `osd.show` text as the player shows it (translated with the
@@ -1510,10 +1530,10 @@ export class TestHost {
1510
1530
  * ```
1511
1531
  */
1512
1532
  selectTab(plugin) {
1513
- const slot = this.layout.sidebar;
1533
+ const slot = [this.layout.sidebar, this.layout.adjust].find((each) => each?.panels?.some((entry) => entry.plugin === plugin));
1514
1534
  const panel = slot?.panels?.find((entry) => entry.plugin === plugin);
1515
1535
  if (!slot || !panel)
1516
- throw new Error(`${plugin} has no sidebar tab`);
1536
+ throw new Error(`${plugin} has no tab`);
1517
1537
  slot.owner = panel.owner;
1518
1538
  this.emit("window.state", this.#windowFacts());
1519
1539
  }
@@ -1521,15 +1541,18 @@ export class TestHost {
1521
1541
  const state = this.layout[slot];
1522
1542
  if (state)
1523
1543
  state.panels = [...(state.panels ?? []), panel];
1524
- else
1544
+ else {
1545
+ // The adjust column starts hidden, as the player's.
1546
+ const shown = slot !== "adjust";
1525
1547
  this.layout[slot] = {
1526
1548
  owner: panel.owner,
1527
- preferred: true,
1528
- effective: true,
1529
- reason: null,
1549
+ preferred: shown,
1550
+ effective: shown,
1551
+ reason: shown ? null : "userHidden",
1530
1552
  detached: false,
1531
1553
  panels: [panel],
1532
1554
  };
1555
+ }
1533
1556
  }
1534
1557
  #removePanel(slot, owner) {
1535
1558
  const state = this.layout[slot];
@@ -1809,9 +1832,24 @@ export class TestHost {
1809
1832
  /**
1810
1833
  * Export jobs by job id. Each state query advances a job one step
1811
1834
  * (`queued` → `running` → `completed`; `cancelling` → `cancelled`).
1812
- * Wire-level.
1835
+ * A running clip reports half its `progress`; a finished clip's
1836
+ * `outcome` has `start` and `end` (a stream copy starts 1 s before the
1837
+ * snapshot time, as if snapped to a keyframe, and its destination gets
1838
+ * the extension of its container: `.mkv` for `container: "mkv"`, else
1839
+ * `.mp4`). Wire-level.
1813
1840
  */
1814
1841
  jobs = new Map();
1842
+ /**
1843
+ * Set it to make every running export end `failed` with this error on
1844
+ * its next state query, as the player reports one (`unsupported` for a
1845
+ * stream copy it cannot make, `export-failed` otherwise); `null` (the
1846
+ * default) completes them.
1847
+ *
1848
+ * @since 1.6.10
1849
+ */
1850
+ exportFailure = null;
1851
+ /** What each job was asked to write, for its outcome. */
1852
+ #exportJobs = new Map();
1815
1853
  /**
1816
1854
  * `openStream`'s transport: a local stream (a Unix socket on macOS and
1817
1855
  * Linux, a named pipe on Windows) or, with `"tcp"`, loopback TCP where the
@@ -4214,11 +4252,16 @@ export class TestHost {
4214
4252
  for (const why of this.listeningWhy.get(event) ?? ["a listener"])
4215
4253
  reasons.push(`listens to ${event}: ${why}`);
4216
4254
  // A sidebar panel is never live (player 0.7.3): the current tab keeps
4217
- // the plugin running as busy work, a background tab not at all.
4255
+ // the plugin running as busy work, a background tab not at all. The
4256
+ // adjust column's current tab only while the column is open (0.7.6).
4218
4257
  if (this.panel?.slot === "sidebar") {
4219
4258
  if (this.layout.sidebar?.owner === 1)
4220
4259
  reasons.push("its sidebar tab is the current tab");
4221
4260
  }
4261
+ else if (this.panel?.slot === "adjust") {
4262
+ if (this.layout.adjust?.owner === 1 && this.layout.adjust.preferred)
4263
+ reasons.push("its adjust tab is the current tab");
4264
+ }
4222
4265
  else if (this.panel)
4223
4266
  reasons.push("a mounted panel");
4224
4267
  for (const [id, window] of this.windows)
@@ -4454,8 +4497,11 @@ export class TestHost {
4454
4497
  "ui.mount": (p) => {
4455
4498
  if (this.panel)
4456
4499
  throw new HostError("conflict", "owner already mounted");
4500
+ if (!(String(p.slot) in SLOT_LABELS))
4501
+ throw new HostError("invalid", "unknown mount slot");
4457
4502
  // Like the player: one panel in the bottom bar and the backdrop, any
4458
- // number of tabs in the sidebar (player 0.7.3; 0.4.x to 0.7.2 took 8).
4503
+ // number of tabs in the sidebar (player 0.7.3; 0.4.x to 0.7.2 took 8)
4504
+ // and in the adjust column (player 0.7.6).
4459
4505
  const holder = p.slot === "backdrop"
4460
4506
  ? this.backdrop?.owner
4461
4507
  : p.slot === "bottom"
@@ -4509,7 +4555,10 @@ export class TestHost {
4509
4555
  "ui.menu": (p) => {
4510
4556
  if (!this.panel)
4511
4557
  throw new HostError("unavailable", "panel not shown");
4512
- if (this.panel.slot === "sidebar" && this.layout.sidebar?.owner !== 1)
4558
+ const tabs = this.panel.slot === "sidebar" || this.panel.slot === "adjust"
4559
+ ? this.layout[this.panel.slot]
4560
+ : null;
4561
+ if (tabs && tabs.owner !== 1)
4513
4562
  throw new HostError("unavailable", "the panel's tab is not the current one");
4514
4563
  const items = p.items;
4515
4564
  for (const id of items)
@@ -4526,11 +4575,15 @@ export class TestHost {
4526
4575
  }),
4527
4576
  "layout.set": (p) => {
4528
4577
  const slot = String(p.slot);
4578
+ if (!["bottom", "sidebar", "adjust"].includes(slot))
4579
+ throw new HostError("invalid", "unknown slot");
4529
4580
  const state = this.layout[slot];
4530
4581
  if (!state)
4531
4582
  throw new HostError("missing", "no panel at this position");
4532
4583
  if (p.detached !== undefined && slot === "bottom")
4533
4584
  throw new HostError("invalid", "the bottom bar cannot be detached");
4585
+ if (p.detached === true && slot === "adjust")
4586
+ throw new HostError("invalid", "the adjust column cannot be detached");
4534
4587
  if (p.active !== undefined && p.active !== null) {
4535
4588
  const panel = state.panels?.find((entry) => entry.plugin === p.active);
4536
4589
  if (!panel)
@@ -5637,8 +5690,18 @@ export class TestHost {
5637
5690
  "jobs.start": (p) => {
5638
5691
  if (!this.snapshots.has(Number(p.snapshotId)))
5639
5692
  throw new HostError("missing", "snapshot released");
5640
- if (!/^(raw|processed)(Png|Jpeg|Bmp|Mp4)$/.test(String(p.kind)) ||
5641
- (p.quality !== undefined && !String(p.kind).endsWith("Jpeg")))
5693
+ const kind = String(p.kind);
5694
+ const snapshot = this.snapshots.get(Number(p.snapshotId));
5695
+ const clip = /^(rawMp4|processedMp4|rawCopy)$/.test(kind);
5696
+ if (!(/^(raw|processed)(Png|Jpeg|Bmp)$/.test(kind) || clip) ||
5697
+ (p.quality !== undefined && !kind.endsWith("Jpeg")) ||
5698
+ (p.encoding !== undefined &&
5699
+ (!kind.endsWith("Mp4") || !validEncoding(p.encoding))) ||
5700
+ (p.container !== undefined &&
5701
+ (kind !== "rawCopy" ||
5702
+ !["auto", "mp4", "mkv"].includes(String(p.container)))) ||
5703
+ (clip && typeof p.end !== "number") ||
5704
+ !kind.startsWith(snapshot.kind))
5642
5705
  throw new HostError("invalid", `unknown job kind ${p.kind}`);
5643
5706
  this.exports.push(structuredClone(p));
5644
5707
  const jobId = ++this.#next.job;
@@ -5648,23 +5711,71 @@ export class TestHost {
5648
5711
  publicationStarted: false,
5649
5712
  facts: null,
5650
5713
  outcome: null,
5714
+ progress: null,
5715
+ });
5716
+ this.#exportJobs.set(jobId, {
5717
+ destination: String(p.destination),
5718
+ // A copy starts 1 s early (on a "keyframe"), the player's own way of
5719
+ // saying the start moved; a re-encode starts on the frame.
5720
+ clip: clip
5721
+ ? {
5722
+ start: kind === "rawCopy"
5723
+ ? Math.max(0, snapshot.time - 1)
5724
+ : snapshot.time,
5725
+ end: Number(p.end),
5726
+ container: kind !== "rawCopy"
5727
+ ? null
5728
+ : p.container === "mkv"
5729
+ ? "mkv"
5730
+ : "mp4",
5731
+ }
5732
+ : null,
5651
5733
  });
5652
5734
  return { jobId, status: "queued", snapshotId: p.snapshotId };
5653
5735
  },
5654
- // Each read advances a job one step: queued → running → completed.
5736
+ // Each read advances a job one step: queued → running → completed. A
5737
+ // clip reports half its progress while running.
5655
5738
  "jobs.state": (p) => {
5656
5739
  const job = this.jobs.get(Number(p.jobId));
5657
5740
  if (!job)
5658
5741
  throw new HostError("missing", "job");
5659
5742
  const current = structuredClone(job);
5660
- if (job.status === "queued")
5743
+ const made = this.#exportJobs.get(job.jobId);
5744
+ const clip = made?.clip;
5745
+ if (job.status === "queued") {
5661
5746
  job.status = "running";
5662
- else if (job.status === "running")
5747
+ if (clip) {
5748
+ const total = clip.end - clip.start;
5749
+ job.progress = { done: total / 2, total };
5750
+ }
5751
+ }
5752
+ else if (job.status === "running" && this.exportFailure)
5753
+ Object.assign(job, {
5754
+ status: "failed",
5755
+ outcome: { error: { ...this.exportFailure } },
5756
+ });
5757
+ else if (job.status === "running") {
5758
+ const destination = clip?.container === "mkv"
5759
+ ? made.destination.replace(/(\.(mp4|m4v|mov|mkv|webm))?$/i, ".mkv")
5760
+ : clip?.container === "mp4"
5761
+ ? made.destination.replace(/(\.(mp4|m4v|mov|mkv|webm))?$/i, ".mp4")
5762
+ : "/tmp/output";
5663
5763
  Object.assign(job, {
5664
5764
  status: "completed",
5665
5765
  publicationStarted: true,
5666
- outcome: { destination: "/tmp/output" },
5766
+ outcome: clip
5767
+ ? { destination, start: clip.start, end: clip.end }
5768
+ : { destination },
5769
+ ...(job.progress
5770
+ ? {
5771
+ progress: {
5772
+ done: job.progress.total,
5773
+ total: job.progress.total,
5774
+ },
5775
+ }
5776
+ : {}),
5667
5777
  });
5778
+ }
5668
5779
  else if (job.status === "cancelling")
5669
5780
  Object.assign(job, {
5670
5781
  status: "cancelled",
@@ -93,7 +93,7 @@ export interface Disposable {
93
93
  * | `busy` | The player is still doing a previous call of the same kind (a dialog is open, a step is in flight). |
94
94
  * | `conflict` | Another plugin owns the resource (the sequence provider, the display layer, the backdrop, a menu path), or this plugin already uses the id (a window or command id). |
95
95
  * | `unavailable` | Not possible right now (no ready media, no user interface, fullscreen, a disabled core action). |
96
- * | `unsupported` | Not possible on this system, build or source (a platform gap, software rendering, a source that cannot seek, a filter on a generated stream). |
96
+ * | `unsupported` | Not possible on this system, build or source (a platform gap, software rendering, a source that cannot seek, a filter on a generated stream, an export the player cannot make as asked: a stream copy, a source bitrate it cannot tell). |
97
97
  * | `backend` | Loading or resolving failed; `message` is the backend's reason. |
98
98
  * | `failed` | The operation ran and failed (`media.ready()` load error, a non-zero `tools.run` exit, the system refused). |
99
99
  * | `replaced` | Accepted, but another source replaced it before it finished. |
@@ -114,6 +114,7 @@ export interface Disposable {
114
114
  * | `unknown`, `rejected`, `expired` | After a transport problem the SDK could not confirm the outcome (does not happen in normal operation). |
115
115
  * | `activation` | The plugin opened a tool window while its activation was still pending and the player could not track it. |
116
116
  * | `command` | The player could not run or update a command it no longer knows for this plugin (removed meanwhile, or another plugin's). |
117
+ * | `export-failed` | An export job ({@link ExportJob}) failed: the code of its `outcome.error`, not a rejection; `message` has the worker's or FFmpeg's reason. |
117
118
  * | `input-overflow` | `openStream` input: the player's queue of physical key transitions was full; the input was revoked and its keys released. |
118
119
  * | `internal` | An internal step of the player failed (keeping an operation's result, a subtitle or media job); `message` has the reason. |
119
120
  * | `javascript`, `slint` | {@link Packages.validate}: the package's entry module does not pass `node --check` / one of its Slint files does not compile; `message` has the diagnostics. |
@@ -2577,12 +2578,13 @@ export interface PanelState {
2577
2578
  * else the position's reason ({@link SlotState.reason}).
2578
2579
  */
2579
2580
  reason: SlotState["reason"] | "otherTab";
2580
- /** The sidebar floats in its own window (with all its tabs). */
2581
+ /** The sidebar floats in its own window (with all its tabs); always `false` in the adjust column. */
2581
2582
  detached: boolean;
2582
2583
  }
2583
2584
  /**
2584
- * The bottom bar or the sidebar position (`plugin.panels.bottom`,
2585
- * `plugin.panels.sidebar`), independent of who mounted the panel there.
2585
+ * The bottom bar, the sidebar or the adjust column position
2586
+ * (`plugin.panels.bottom`, `plugin.panels.sidebar`, `plugin.panels.adjust`),
2587
+ * independent of who mounted the panel there.
2586
2588
  *
2587
2589
  * @remarks
2588
2590
  * The position does not belong to the plugin that mounted the panel: any
@@ -4139,6 +4141,38 @@ export interface Panels {
4139
4141
  * takes no pointer or keyboard input. While it is mounted, the player does
4140
4142
  * not show the cover art of audio files itself (from the next file
4141
4143
  * opened on), so the plugin can draw it.
4144
+ * - `"adjust"` (player 0.7.6, SDK 1.6.11): **controls that change what is
4145
+ * playing**: the picture, the sound, the subtitles, the speed (the
4146
+ * player's own Picture, Audio, Subtitles and Playback panels, a colour
4147
+ * grading tool). Several plugins may mount here, one tab each, labelled
4148
+ * `title` (else the plugin's name). Declare `minPlayerVersion: "0.7.6"`:
4149
+ * older players refuse the slot (`invalid`, "unknown mount slot"), and a
4150
+ * mount refused during `activate` fails the activation.
4151
+ *
4152
+ * **Choosing between `"sidebar"` and `"adjust"`.** The slot says what the
4153
+ * panel is for; the player decides how it looks and may change that at any
4154
+ * time. Ask: after the user closes the panel, do they still look at what it
4155
+ * showed? If yes (a list, chapters, search results, another video), it is
4156
+ * content: `"sidebar"`. If it only changes what is playing, it is
4157
+ * `"adjust"`.
4158
+ *
4159
+ * **What the player shows today (may change; do not depend on it).** The
4160
+ * adjust column is a second column between the video and the sidebar
4161
+ * (`[video][adjust][sidebar]`; both can be open, the sidebar always
4162
+ * rightmost), 317 wide by default, 280 to half the window, with text tabs
4163
+ * and the key that opens each tab under its title (the key of the command
4164
+ * your other commands name as their {@link CommandOptions.home | home}). It
4165
+ * has no close or detach button: the bottom bar's Adjust button, your
4166
+ * panel's own command ({@link Panel.toggle}) and the menus open and close
4167
+ * it. It starts closed. In a window too narrow to dock it beside a video at
4168
+ * least 480 wide, it floats over the right of the video, 300 wide; in
4169
+ * fullscreen it slides out from the right edge, left of the sidebar. Its
4170
+ * width, open state, current tab and tab order are remembered across
4171
+ * launches. Its strip is always drawn, so panel coordinates (drops) start
4172
+ * below it, and the bridge global's `tabbed` property, if declared, is
4173
+ * `true`. As in the sidebar, only the current tab's plugin is kept
4174
+ * running while the column is open, and the player compiles a panel the
4175
+ * first time it is shown, not at start-up.
4142
4176
  *
4143
4177
  * @capability ui.mount
4144
4178
  *
@@ -4175,12 +4209,12 @@ export interface Panels {
4175
4209
  *
4176
4210
  * @since 1.0.0
4177
4211
  */
4178
- mount(slot: "bottom" | "sidebar" | "backdrop", file: string, options?: {
4212
+ mount(slot: "bottom" | "sidebar" | "adjust" | "backdrop", file: string, options?: {
4179
4213
  /** The exported component to mount, if the file exports several. */
4180
4214
  component?: string;
4181
4215
  /** The exported global to use as the bridge, if the file exports several. */
4182
4216
  global?: string;
4183
- /** The floating sidebar window's title (at most 128 bytes). */
4217
+ /** The tab label and the floating sidebar window's title (at most 128 bytes). */
4184
4218
  title?: string;
4185
4219
  }): Panel;
4186
4220
  /**
@@ -4192,6 +4226,51 @@ export interface Panels {
4192
4226
  readonly bottom: PanelSlot;
4193
4227
  /** The sidebar position, including its floating state; any plugin may read and toggle it. */
4194
4228
  readonly sidebar: SidebarSlot;
4229
+ /**
4230
+ * The adjust column (player 0.7.6, SDK 1.6.11), whoever mounted there;
4231
+ * any plugin may read and toggle it. The same shape as
4232
+ * {@link Panels.sidebar} without floating: the adjust column never
4233
+ * detaches.
4234
+ *
4235
+ * @remarks
4236
+ * `state` is `null` while no plugin has a panel there. Players before
4237
+ * 0.7.6 build the `plugin` object with their own SDK, which has no
4238
+ * `adjust`: there `plugin.panels.adjust` is `undefined` and reading its
4239
+ * `state` throws a `TypeError`, so declare `"minPlayerVersion": "0.7.6"`
4240
+ * (or test `plugin.panels.adjust` first). The column starts hidden (`preferred: false`, `reason:
4241
+ * "userHidden"`) until the user opens it. To open the column at your own
4242
+ * panel, call {@link Panel.show} (or {@link Panel.toggle}, which closes it
4243
+ * again when your tab is already the current one, the behaviour of the
4244
+ * player's own `Alt+1…4`).
4245
+ *
4246
+ * @example Open the adjust column at this plugin's tab with a key
4247
+ * One command opens the column at the plugin's tab, and pressing it again
4248
+ * while that tab is shown closes the column. The panel's other commands
4249
+ * name it as their `home`, so the player shows its key under the tab and
4250
+ * the command palette knows where they live.
4251
+ * ```ts
4252
+ * const panel = plugin.panels.mount("adjust", "ui/wheels.slint", { title: "Wheels" });
4253
+ * plugin.commands.add({
4254
+ * id: "wheels",
4255
+ * title: "Colour Wheels",
4256
+ * bindings: ["Alt+5"],
4257
+ * // Opens the column at this tab; closes it when this tab is shown.
4258
+ * run: () => panel.toggle(),
4259
+ * });
4260
+ * plugin.commands.add({
4261
+ * id: "wheels-reset",
4262
+ * title: "Reset Colour Wheels",
4263
+ * // Lives in the panel `wheels` opens: its key shows under the tab.
4264
+ * home: "wheels",
4265
+ * run: () => panel.set({ lift: 0 }),
4266
+ * });
4267
+ * ```
4268
+ *
4269
+ * @player 0.7.6 (declare `"minPlayerVersion": "0.7.6"`; older players
4270
+ * have no `adjust`: reading its `state` throws a `TypeError`).
4271
+ * @since 1.6.11
4272
+ */
4273
+ readonly adjust: PanelSlot;
4195
4274
  }
4196
4275
  /**
4197
4276
  * What a timeline mark stands for; readers choose colours by it.
@@ -8294,11 +8373,30 @@ export interface Snapshot extends Disposable {
8294
8373
  * Start exporting; the job runs in the host's offline worker.
8295
8374
  * One snapshot can be exported several times.
8296
8375
  *
8376
+ * A **stream copy** (`format: "copy"`, player 0.7.6) writes the video
8377
+ * stream and the snapshot's audio track as they are in the file, without
8378
+ * decoding: lossless and about as fast as the disk, but a copy can only
8379
+ * begin on a keyframe. It starts at the video's last keyframe at or before
8380
+ * the snapshot time (its first keyframe when the video itself starts
8381
+ * later; keyframes an MP4 edit list discards do not count) and is cut at
8382
+ * `end`, where it may run a few frames past `end`. An open GOP can write
8383
+ * a frame or two that present before the keyframe; `start` includes them.
8384
+ * The finished job's `outcome` gives the `start` and `end` actually
8385
+ * written; a plugin that previews the cut (with `plugin.tools.run`
8386
+ * and ffprobe's packet flags, say) shows those once the job is done.
8387
+ * Copies need a raw snapshot (effects and burned-in subtitles need a
8388
+ * re-encode) and leave subtitle tracks and chapters out. `auto` writes
8389
+ * MP4 for H.264, HEVC, AV1 or MPEG-4 video with AAC, MP3, AC-3, E-AC-3 or
8390
+ * ALAC audio (or none), and MKV for anything else. A copy the player
8391
+ * cannot make (no video stream, a cover image, an external audio track,
8392
+ * codecs `mp4` cannot hold) ends `failed` with the code `unsupported`.
8393
+ *
8297
8394
  * @capability media.read and media.control
8298
8395
  * @throws `released` (rejects) The snapshot was already released.
8299
8396
  * @throws `invalid` (rejects) Bad options (`quality` outside 1–100, a
8300
8397
  * relative destination, a clip `end` not after the snapshot time or
8301
- * past the duration).
8398
+ * past the duration, an {@link ExportEncoding} value outside its set,
8399
+ * `copy` from a processed snapshot).
8302
8400
  * @throws `busy` (rejects) This plugin runs 2 exports (or the player 8).
8303
8401
  * @throws `unavailable` (rejects) No FFmpeg, or the export worker could not
8304
8402
  * start.
@@ -8318,18 +8416,48 @@ export interface Snapshot extends Disposable {
8318
8416
  * if (end.status !== "completed") await plugin.osd.show("Export {}", [end.status]);
8319
8417
  * ```
8320
8418
  *
8419
+ * @example Cut without re-encoding and report where it starts
8420
+ * The clip is copied as it is in the file. A copy starts on a keyframe,
8421
+ * so the OSD says where it actually starts; a file the player cannot
8422
+ * copy ends with `unsupported`, and the plugin offers a re-encode instead.
8423
+ * ```ts
8424
+ * declare const snapshot: Snapshot;
8425
+ * // ---cut---
8426
+ * const job = await snapshot.export({ format: "copy", destination: "/tmp/clip.mp4", end: snapshot.time + 20 });
8427
+ * const state = await job.done;
8428
+ * const outcome = state.outcome;
8429
+ * // The copy starts on a keyframe: outcome.start says where.
8430
+ * if (outcome && "destination" in outcome)
8431
+ * await plugin.osd.show("Saved {} from {} s", [outcome.destination, (outcome.start ?? snapshot.time).toFixed(1)]);
8432
+ * // Codecs the container cannot hold, a cover image, …
8433
+ * else if (outcome && "error" in outcome && outcome.error.code === "unsupported")
8434
+ * await plugin.osd.show("Fast cut is not possible for this file");
8435
+ * ```
8436
+ *
8321
8437
  * @since 1.0.0
8322
8438
  */
8323
8439
  export(options: ExportOptions): Promise<ExportJob>;
8324
8440
  }
8325
8441
  /**
8326
8442
  * One frame as PNG, JPEG (`quality` 1–100, default 90) or BMP (24-bit), or
8327
- * a clip as MP4.
8443
+ * a clip: re-encoded as MP4 (`mp4`, frame-accurate, optional
8444
+ * {@link ExportEncoding}) or copied without re-encoding (`copy`, player
8445
+ * 0.7.6, starting on a keyframe).
8328
8446
  *
8329
8447
  * `destination` is never overwritten if it exists. The extension does not
8330
- * choose the format. `sampleTime` picks the frame (default: the snapshot
8331
- * time); `end` is the clip's end in source seconds. On Windows the export
8332
- * worker needs FFmpeg 6.1 or newer.
8448
+ * choose the format (a copy's extension is set to its container).
8449
+ * `sampleTime` picks the frame (default: the snapshot time); `end` is the
8450
+ * clip's end in source seconds. On Windows the export worker needs FFmpeg
8451
+ * 6.1 or newer.
8452
+ *
8453
+ * **Which players have `copy`, `encoding` and the progress and range of
8454
+ * a job**: player 0.7.6 and later. Declare `"minPlayerVersion": "0.7.6"`,
8455
+ * or, to keep older players and offer these only where they exist, compare
8456
+ * {@link AppInfo.version | plugin.app.version} with `0.7.6`, as for any
8457
+ * newer API. Do not probe by trying: on an older player the `plugin`
8458
+ * object comes from that player's own, older SDK, which rejects `copy` with
8459
+ * `invalid` but builds a re-encode request without the `encoding` it does
8460
+ * not know, so the clip is encoded with the fixed settings and no error.
8333
8461
  *
8334
8462
  * @since 1.0.0
8335
8463
  * @category Media
@@ -8351,19 +8479,132 @@ export type ExportOptions = {
8351
8479
  /** Integer 1–100, default 90. */
8352
8480
  quality?: number;
8353
8481
  } | {
8354
- /** A clip from the snapshot time. */
8482
+ /** A clip from the snapshot time, re-encoded as H.264 + AAC. */
8355
8483
  format: "mp4";
8356
8484
  /** Absolute path of the new file. */
8357
8485
  destination: string;
8358
8486
  /** Clip end, source seconds. */
8359
8487
  end: number;
8488
+ /**
8489
+ * Encoder settings (player 0.7.6); without it, the settings before
8490
+ * they existed.
8491
+ *
8492
+ * @since 1.6.10
8493
+ */
8494
+ encoding?: ExportEncoding;
8495
+ } | {
8496
+ /**
8497
+ * A clip copied stream by stream, without re-encoding (player 0.7.6):
8498
+ * lossless and about as fast as the disk. It starts on the video
8499
+ * keyframe at or before the snapshot time; see {@link Snapshot.export}.
8500
+ *
8501
+ * @since 1.6.10
8502
+ */
8503
+ format: "copy";
8504
+ /**
8505
+ * Absolute path of the new file. The player gives it the extension of
8506
+ * the container it writes (`.mp4` or `.mkv`); the finished job's
8507
+ * `outcome.destination` is the actual path.
8508
+ */
8509
+ destination: string;
8510
+ /** Clip end, source seconds. */
8511
+ end: number;
8512
+ /**
8513
+ * `auto` (default): MP4 when the codecs fit it, otherwise MKV. `mp4`
8514
+ * fails with `unsupported` when they do not; `mkv` always writes MKV.
8515
+ */
8516
+ container?: "auto" | "mp4" | "mkv";
8360
8517
  };
8518
+ /**
8519
+ * The encoder settings of a re-encoded clip (`format: "mp4"`, player
8520
+ * 0.7.6): a closed set of values, never encoder arguments. Every field is
8521
+ * optional; the defaults are the settings clips had before: CRF 18, the
8522
+ * fastest speed, the source's size and frame rate, AAC 192 kb/s, one thread.
8523
+ * A value outside its set rejects the export with `invalid`. `videoBitrate:
8524
+ * "source"` replaces the CRF with the source's bitrate, for a "Same as
8525
+ * source" preset.
8526
+ *
8527
+ * @example A small file
8528
+ * A 720p, 30 fps clip at a lower quality, encoded with the cores the player
8529
+ * can spare.
8530
+ * ```ts
8531
+ * declare const snapshot: Snapshot;
8532
+ * // ---cut---
8533
+ * // Each field is a ceiling or a choice from a closed set.
8534
+ * const job = await snapshot.export({
8535
+ * format: "mp4",
8536
+ * destination: "/tmp/small.mp4",
8537
+ * end: snapshot.time + 30,
8538
+ * encoding: { crf: 26, maxHeight: 720, fps: 30, audioBitrate: 128, threads: "auto" },
8539
+ * });
8540
+ * ```
8541
+ *
8542
+ * @since 1.6.10
8543
+ * @category Media
8544
+ */
8545
+ export interface ExportEncoding {
8546
+ /**
8547
+ * H.264 quality, an integer 14–30 (x264's CRF): lower is a better picture
8548
+ * and a bigger file. Default 18.
8549
+ */
8550
+ crf?: number;
8551
+ /**
8552
+ * Encoding effort: `faster` (default), `balanced` or `smaller` (slower,
8553
+ * a smaller file at the same quality); x264's `veryfast`, `medium` and
8554
+ * `slow`.
8555
+ */
8556
+ speed?: "faster" | "balanced" | "smaller";
8557
+ /**
8558
+ * The tallest picture: a taller one is scaled down to this height,
8559
+ * keeping its shape; a shorter one is left as it is. Default: the
8560
+ * source's size.
8561
+ */
8562
+ maxHeight?: 1080 | 720 | 480;
8563
+ /**
8564
+ * The highest frame rate: a faster source drops frames to it; a slower
8565
+ * one is left as it is. Default: the source's rate.
8566
+ */
8567
+ fps?: 60 | 30;
8568
+ /** AAC audio bitrate in kb/s. Default 192. */
8569
+ audioBitrate?: 128 | 192 | 256;
8570
+ /**
8571
+ * `single` (default) encodes on one thread, leaving the other cores to
8572
+ * playback. `auto` lets the player use more: half the logical
8573
+ * processors, at most 8, so playback and the interface keep their rate.
8574
+ * From player 0.7.6 every export runs at below-normal priority (as
8575
+ * HandBrake and OBS run their encoders), with either setting.
8576
+ */
8577
+ threads?: "single" | "auto";
8578
+ /**
8579
+ * `source`: aim at the source video's average bitrate instead of a
8580
+ * quality level (peaks up to 1.5 times it), so the file comes out about
8581
+ * the size of that stretch of the source: a "Same as source" preset. Not
8582
+ * together with `crf` (`invalid`). A source whose bitrate the player
8583
+ * cannot tell ends the job `failed` with `unsupported`.
8584
+ */
8585
+ videoBitrate?: "source";
8586
+ }
8361
8587
  /**
8362
8588
  * An export job's state. `status` is `queued`, `running`, `cancelling`,
8363
8589
  * `completed`, `failed` or `cancelled`; once terminal, `outcome` is
8364
8590
  * `{ destination }`, `{ error: { code, message } }` or `{ cancelled: true }`.
8365
8591
  * `facts` describes what the worker produced (versions, range, streams).
8366
8592
  *
8593
+ * From player 0.7.6:
8594
+ * - a finished clip's `outcome` also has `start` and `end`, the source
8595
+ * times of its first frame and of the end of its last frame as written: a
8596
+ * copy's earliest written frame (its keyframe, or the leading frames of
8597
+ * an open GOP) and actual end; a re-encode's start and start
8598
+ * plus the encoded length.
8599
+ * - `progress` is `{ done, total }` in seconds of the clip while FFmpeg
8600
+ * writes it (at most 4 updates a second; the SDK reads it about every
8601
+ * 350 ms), `null` while unknown: before writing starts, and always for a
8602
+ * frame. Show an indeterminate bar then. After `done` reaches `total` the
8603
+ * player still checks the file before the job completes.
8604
+ * - `error.code` is `export-failed`, or `unsupported` for an export the
8605
+ * player cannot make as asked (a stream copy it cannot make, a
8606
+ * `videoBitrate: "source"` whose source bitrate it cannot tell).
8607
+ *
8367
8608
  * @since 1.0.0
8368
8609
  * @category Media
8369
8610
  */
@@ -9254,7 +9495,7 @@ export interface PluginWarning {
9254
9495
  * 0.4.1); a newer player may add codes.
9255
9496
  */
9256
9497
  code: string;
9257
- /** The panel position (`"bottom"`, `"sidebar"`, `"backdrop"`) for `slot-occupied`. */
9498
+ /** The panel position (`"bottom"`, `"backdrop"`; the sidebar and the adjust column hold any number of tabs) for `slot-occupied`. */
9258
9499
  slot?: string;
9259
9500
  /**
9260
9501
  * What was taken, for `resolver-taken`: 「文件类型 .nes」 or the
package/dist/plugin/ui.js CHANGED
@@ -991,7 +991,8 @@ class LayoutService {
991
991
  state = {
992
992
  bottom: null,
993
993
  sidebar: null,
994
- tabs: { bottom: null, sidebar: null },
994
+ adjust: null,
995
+ tabs: { bottom: null, sidebar: null, adjust: null },
995
996
  backdrop: null,
996
997
  };
997
998
  #listeners = new Set();
@@ -1051,10 +1052,16 @@ class LayoutService {
1051
1052
  active: panel.owner === slot.owner,
1052
1053
  })))
1053
1054
  : null;
1055
+ const adjust = raw.adjust ?? null;
1054
1056
  const next = {
1055
1057
  bottom: view(raw.bottom),
1056
1058
  sidebar: view(raw.sidebar),
1057
- tabs: { bottom: tabs(raw.bottom), sidebar: tabs(raw.sidebar) },
1059
+ adjust: view(adjust),
1060
+ tabs: {
1061
+ bottom: tabs(raw.bottom),
1062
+ sidebar: tabs(raw.sidebar),
1063
+ adjust: tabs(adjust),
1064
+ },
1058
1065
  backdrop: raw.backdrop
1059
1066
  ? Object.freeze({ effective: raw.backdrop.effective === true })
1060
1067
  : null,
@@ -1145,6 +1152,7 @@ export const panels = (scope) => ({
1145
1152
  mount: (position, file, options = {}) => new PanelHandle(scope, position, file, options),
1146
1153
  bottom: slot(scope, "bottom"),
1147
1154
  sidebar: slot(scope, "sidebar"),
1155
+ adjust: slot(scope, "adjust"),
1148
1156
  backdrop: backdrop(scope),
1149
1157
  });
1150
1158
  export const dialogs = (scope) => {
package/dist/wire.d.ts CHANGED
@@ -288,7 +288,10 @@ export interface LayoutPanel {
288
288
  /** The tab label: the mount's `title`, else the plugin's name. */
289
289
  title: string;
290
290
  }
291
- /** One position (bottom bar or sidebar) as `layout.state` reports it. */
291
+ /**
292
+ * One position (the bottom bar, the sidebar or, from player 0.7.6, the
293
+ * adjust column) as `layout.state` reports it.
294
+ */
292
295
  export interface LayoutSlot {
293
296
  /** The current tab's panel (the only panel of the bottom bar). */
294
297
  owner: number;
@@ -696,10 +699,13 @@ export declare function createWire(transport: Transport): {
696
699
  state: () => Promise<{
697
700
  bottom: LayoutSlot | null;
698
701
  sidebar: LayoutSlot | null;
702
+ /** Player 0.7.6 (absent from older players). */
703
+ adjust?: LayoutSlot | null;
699
704
  backdrop?: LayoutBackdrop | null;
700
705
  }>;
701
706
  set: (options: {
702
- slot: "bottom" | "sidebar";
707
+ /** `"adjust"`: player 0.7.6 (older players refuse it with `invalid`). */
708
+ slot: "bottom" | "sidebar" | "adjust";
703
709
  visible?: boolean;
704
710
  detached?: boolean;
705
711
  /** The plugin id of the panel whose tab becomes the current one. */
@@ -727,7 +733,8 @@ export declare function createWire(transport: Transport): {
727
733
  images: string[];
728
734
  }>;
729
735
  mount: (options: {
730
- slot: "bottom" | "sidebar" | "backdrop";
736
+ /** `"adjust"`: player 0.7.6 (older players refuse it with `invalid`). */
737
+ slot: "bottom" | "sidebar" | "backdrop" | "adjust";
731
738
  file: string;
732
739
  component: string;
733
740
  global: string;
@@ -798,6 +805,11 @@ export declare function createWire(transport: Transport): {
798
805
  } | {
799
806
  kind: "rawMp4" | "processedMp4";
800
807
  end: number;
808
+ encoding?: import("./media.ts").WireEncoding;
809
+ } | {
810
+ kind: "rawCopy";
811
+ end: number;
812
+ container?: "auto" | "mp4" | "mkv";
801
813
  })) => Promise<{
802
814
  jobId: number;
803
815
  status: "queued";
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vestra Player SDK
2
2
 
3
- > @vestra-player/sdk 1.6.9: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls. The documentation is a tree of short pages read by path, like `go doc`: the same pages and API as https://vestrasys.github.io/vestra-player-sdk/.
3
+ > @vestra-player/sdk 1.6.11: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls. The documentation is a tree of short pages read by path, like `go doc`: the same pages and API as https://vestrasys.github.io/vestra-player-sdk/.
4
4
 
5
5
  ## Read the documentation
6
6
 
@@ -29,7 +29,7 @@
29
29
  ## API
30
30
 
31
31
  - `/API/Commands`: 15 entries: CommandState, Bindable, Condition, When, MenuId, BarButtonIcon, CommandButton, CommandOptions, Command, Commands, HostAction, Host, BindingRef, BindingInfo, Bindings
32
- - `/API/Media`: 6 entries: CaptureOptions, Snapshot, ExportOptions, JobState, ExportJob, MediaCapture
32
+ - `/API/Media`: 7 entries: CaptureOptions, Snapshot, ExportOptions, ExportEncoding, JobState, ExportJob, MediaCapture
33
33
  - `/API/Packages`: 10 entries: PackageManifest, PackageSource, InstallOptions, PackageCandidate, PackageChangeStatus, PackageChange, PluginInfo, PluginDisplay, PluginWarning, Packages
34
34
  - `/API/Playback`: 20 entries: MarkKind, TimelineMark, RangeStyle, TimelineRange, TimelineLayer, Timeline, AudioTrack, PlayerState, AudioDevice, Chapter, Conditional, Media, Scrub, StreamFrame, StreamOptions,…
35
35
  - `/API/Plugin`: 13 entries: Disposable, PluginError, Scope, Plugin, StopReason, DisposeInfo, WindowState, MainWindow, AppInfo, App, SystemInfo, DisplayInfo, Json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vestra-player/sdk",
3
- "version": "1.6.9",
3
+ "version": "1.6.11",
4
4
  "description": "Plugin API types, test host and plugin CLI for Vestra Player (apiVersion vestra/1)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -3,8 +3,8 @@
3
3
  // player's own. Since SDK 1.6.8 (menu redesign step 3a) the player draws
4
4
  // the strip itself, at the right end of the bottom slot, its 40px cells on
5
5
  // the slot's bottom edge (a bar's 40px button row), and also when no plugin
6
- // mounts a bottom bar: `[plugin buttons…][»][Command Palette][Toggle
7
- // Sidebar]`. A bottom bar places nothing: it lays its controls out left of
6
+ // mounts a bottom bar: `[plugin buttons…][»][Command Palette][Adjust]
7
+ // [Toggle Sidebar]`. A bottom bar places nothing: it lays its controls out left of
8
8
  // the strip and says how wide they must stay:
9
9
  // import { BarButtonsHost } from "@vestra/bar-buttons.slint";
10
10
  // export component MyBar inherits Rectangle {
@@ -65,12 +65,16 @@ export global BarButtonsHost {
65
65
  in property <BarButtonItem> toggle;
66
66
  // The command palette (`host/palette`; `token` "" while there is none).
67
67
  in property <BarButtonItem> palette;
68
+ // The adjust column's toggle (`host/adjust`, player 0.7.6; `token` ""
69
+ // while there is none).
70
+ in property <BarButtonItem> adjust;
68
71
  // Set when a (deprecated) `BarButtons` exists in this window.
69
72
  in-out property <bool> placed;
70
73
  // The width the player gives its strip (the player sets it).
71
74
  in-out property <length> room;
72
- // The player's own cells, always shown (step 3b adds Adjust).
73
- private property <int> own: (root.palette.token != "" ? 1 : 0) + (root.toggle.token != "" ? 1 : 0);
75
+ // The player's own cells, always shown: Command Palette, Adjust,
76
+ // Toggle Sidebar.
77
+ private property <int> own: (root.palette.token != "" ? 1 : 0) + (root.adjust.token != "" ? 1 : 0) + (root.toggle.token != "" ? 1 : 0);
74
78
  // The cells a bar must always leave free, 41px each: the player's own,
75
79
  // and the `»` cell whenever any plugin button exists (drawn only on
76
80
  // overflow, but always reserved, so `»` never covers the bar). Never
@@ -52,6 +52,9 @@ export global Icons {
52
52
  // small cross (AI), the default 14 × 12 box; bar buttons use them.
53
53
  if name == "store" { return "M 2.5 4 H 11.5 L 10.9 9.6 Q 10.8 10.5 9.9 10.5 H 4.1 Q 3.2 10.5 3.1 9.6 Z M 5.2 4 V 3.3 A 1.8 1.8 0 0 1 8.8 3.3 V 4"; }
54
54
  if name == "ai" { return "M 6 1.5 Q 6.6 5.4 10.5 6 Q 6.6 6.6 6 10.5 Q 5.4 6.6 1.5 6 Q 5.4 5.4 6 1.5 Z M 11.5 1.2 V 3.8 M 10.2 2.5 H 12.8"; }
55
+ // Three sliders with their knobs (the adjust column, `host/adjust`),
56
+ // the default 14 × 12 box.
57
+ if name == "sliders" { return "M 1.5 2.5 H 7.6 M 10.4 2.5 H 12.5 M 10.4 2.5 A 1.4 1.4 0 1 1 7.6 2.5 A 1.4 1.4 0 1 1 10.4 2.5 Z M 1.5 6 H 3.1 M 5.9 6 H 12.5 M 5.9 6 A 1.4 1.4 0 1 1 3.1 6 A 1.4 1.4 0 1 1 5.9 6 Z M 1.5 9.5 H 6.6 M 9.4 9.5 H 12.5 M 9.4 9.5 A 1.4 1.4 0 1 1 6.6 9.5 A 1.4 1.4 0 1 1 9.4 9.5 Z"; }
55
58
  return "";
56
59
  }
57
60
  // Line width.