@vestra-player/sdk 1.6.9 → 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,17 @@
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
+
3
15
  ## 1.6.9
4
16
 
5
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.
@@ -29,7 +41,7 @@
29
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).
30
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.
31
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.
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.
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.
33
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.
34
46
  - The [theme-widgets example](examples/theme-widgets) shows a slider's two commands (F7, F8) as key caps and in its tooltip.
35
47
 
Binary file
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 &&
@@ -1654,9 +1654,27 @@ export declare class TestHost implements Transport {
1654
1654
  /**
1655
1655
  * Export jobs by job id. Each state query advances a job one step
1656
1656
  * (`queued` → `running` → `completed`; `cancelling` → `cancelled`).
1657
- * 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.
1658
1662
  */
1659
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;
1660
1678
  /**
1661
1679
  * `openStream`'s transport: a local stream (a Unix socket on macOS and
1662
1680
  * Linux, a named pipe on Windows) or, with `"tcp"`, loopback TCP where the
@@ -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" ||
@@ -1809,9 +1827,24 @@ export class TestHost {
1809
1827
  /**
1810
1828
  * Export jobs by job id. Each state query advances a job one step
1811
1829
  * (`queued` → `running` → `completed`; `cancelling` → `cancelled`).
1812
- * Wire-level.
1830
+ * A running clip reports half its `progress`; a finished clip's
1831
+ * `outcome` has `start` and `end` (a stream copy starts 1 s before the
1832
+ * snapshot time, as if snapped to a keyframe, and its destination gets
1833
+ * the extension of its container: `.mkv` for `container: "mkv"`, else
1834
+ * `.mp4`). Wire-level.
1813
1835
  */
1814
1836
  jobs = new Map();
1837
+ /**
1838
+ * Set it to make every running export end `failed` with this error on
1839
+ * its next state query, as the player reports one (`unsupported` for a
1840
+ * stream copy it cannot make, `export-failed` otherwise); `null` (the
1841
+ * default) completes them.
1842
+ *
1843
+ * @since 1.6.10
1844
+ */
1845
+ exportFailure = null;
1846
+ /** What each job was asked to write, for its outcome. */
1847
+ #exportJobs = new Map();
1815
1848
  /**
1816
1849
  * `openStream`'s transport: a local stream (a Unix socket on macOS and
1817
1850
  * Linux, a named pipe on Windows) or, with `"tcp"`, loopback TCP where the
@@ -5637,8 +5670,18 @@ export class TestHost {
5637
5670
  "jobs.start": (p) => {
5638
5671
  if (!this.snapshots.has(Number(p.snapshotId)))
5639
5672
  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")))
5673
+ const kind = String(p.kind);
5674
+ const snapshot = this.snapshots.get(Number(p.snapshotId));
5675
+ const clip = /^(rawMp4|processedMp4|rawCopy)$/.test(kind);
5676
+ if (!(/^(raw|processed)(Png|Jpeg|Bmp)$/.test(kind) || clip) ||
5677
+ (p.quality !== undefined && !kind.endsWith("Jpeg")) ||
5678
+ (p.encoding !== undefined &&
5679
+ (!kind.endsWith("Mp4") || !validEncoding(p.encoding))) ||
5680
+ (p.container !== undefined &&
5681
+ (kind !== "rawCopy" ||
5682
+ !["auto", "mp4", "mkv"].includes(String(p.container)))) ||
5683
+ (clip && typeof p.end !== "number") ||
5684
+ !kind.startsWith(snapshot.kind))
5642
5685
  throw new HostError("invalid", `unknown job kind ${p.kind}`);
5643
5686
  this.exports.push(structuredClone(p));
5644
5687
  const jobId = ++this.#next.job;
@@ -5648,23 +5691,71 @@ export class TestHost {
5648
5691
  publicationStarted: false,
5649
5692
  facts: null,
5650
5693
  outcome: null,
5694
+ progress: null,
5695
+ });
5696
+ this.#exportJobs.set(jobId, {
5697
+ destination: String(p.destination),
5698
+ // A copy starts 1 s early (on a "keyframe"), the player's own way of
5699
+ // saying the start moved; a re-encode starts on the frame.
5700
+ clip: clip
5701
+ ? {
5702
+ start: kind === "rawCopy"
5703
+ ? Math.max(0, snapshot.time - 1)
5704
+ : snapshot.time,
5705
+ end: Number(p.end),
5706
+ container: kind !== "rawCopy"
5707
+ ? null
5708
+ : p.container === "mkv"
5709
+ ? "mkv"
5710
+ : "mp4",
5711
+ }
5712
+ : null,
5651
5713
  });
5652
5714
  return { jobId, status: "queued", snapshotId: p.snapshotId };
5653
5715
  },
5654
- // Each read advances a job one step: queued → running → completed.
5716
+ // Each read advances a job one step: queued → running → completed. A
5717
+ // clip reports half its progress while running.
5655
5718
  "jobs.state": (p) => {
5656
5719
  const job = this.jobs.get(Number(p.jobId));
5657
5720
  if (!job)
5658
5721
  throw new HostError("missing", "job");
5659
5722
  const current = structuredClone(job);
5660
- if (job.status === "queued")
5723
+ const made = this.#exportJobs.get(job.jobId);
5724
+ const clip = made?.clip;
5725
+ if (job.status === "queued") {
5661
5726
  job.status = "running";
5662
- else if (job.status === "running")
5727
+ if (clip) {
5728
+ const total = clip.end - clip.start;
5729
+ job.progress = { done: total / 2, total };
5730
+ }
5731
+ }
5732
+ else if (job.status === "running" && this.exportFailure)
5733
+ Object.assign(job, {
5734
+ status: "failed",
5735
+ outcome: { error: { ...this.exportFailure } },
5736
+ });
5737
+ else if (job.status === "running") {
5738
+ const destination = clip?.container === "mkv"
5739
+ ? made.destination.replace(/(\.(mp4|m4v|mov|mkv|webm))?$/i, ".mkv")
5740
+ : clip?.container === "mp4"
5741
+ ? made.destination.replace(/(\.(mp4|m4v|mov|mkv|webm))?$/i, ".mp4")
5742
+ : "/tmp/output";
5663
5743
  Object.assign(job, {
5664
5744
  status: "completed",
5665
5745
  publicationStarted: true,
5666
- outcome: { destination: "/tmp/output" },
5746
+ outcome: clip
5747
+ ? { destination, start: clip.start, end: clip.end }
5748
+ : { destination },
5749
+ ...(job.progress
5750
+ ? {
5751
+ progress: {
5752
+ done: job.progress.total,
5753
+ total: job.progress.total,
5754
+ },
5755
+ }
5756
+ : {}),
5667
5757
  });
5758
+ }
5668
5759
  else if (job.status === "cancelling")
5669
5760
  Object.assign(job, {
5670
5761
  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. |
@@ -8294,11 +8295,30 @@ export interface Snapshot extends Disposable {
8294
8295
  * Start exporting; the job runs in the host's offline worker.
8295
8296
  * One snapshot can be exported several times.
8296
8297
  *
8298
+ * A **stream copy** (`format: "copy"`, player 0.7.6) writes the video
8299
+ * stream and the snapshot's audio track as they are in the file, without
8300
+ * decoding: lossless and about as fast as the disk, but a copy can only
8301
+ * begin on a keyframe. It starts at the video's last keyframe at or before
8302
+ * the snapshot time (its first keyframe when the video itself starts
8303
+ * later; keyframes an MP4 edit list discards do not count) and is cut at
8304
+ * `end`, where it may run a few frames past `end`. An open GOP can write
8305
+ * a frame or two that present before the keyframe; `start` includes them.
8306
+ * The finished job's `outcome` gives the `start` and `end` actually
8307
+ * written; a plugin that previews the cut (with `plugin.tools.run`
8308
+ * and ffprobe's packet flags, say) shows those once the job is done.
8309
+ * Copies need a raw snapshot (effects and burned-in subtitles need a
8310
+ * re-encode) and leave subtitle tracks and chapters out. `auto` writes
8311
+ * MP4 for H.264, HEVC, AV1 or MPEG-4 video with AAC, MP3, AC-3, E-AC-3 or
8312
+ * ALAC audio (or none), and MKV for anything else. A copy the player
8313
+ * cannot make (no video stream, a cover image, an external audio track,
8314
+ * codecs `mp4` cannot hold) ends `failed` with the code `unsupported`.
8315
+ *
8297
8316
  * @capability media.read and media.control
8298
8317
  * @throws `released` (rejects) The snapshot was already released.
8299
8318
  * @throws `invalid` (rejects) Bad options (`quality` outside 1–100, a
8300
8319
  * relative destination, a clip `end` not after the snapshot time or
8301
- * past the duration).
8320
+ * past the duration, an {@link ExportEncoding} value outside its set,
8321
+ * `copy` from a processed snapshot).
8302
8322
  * @throws `busy` (rejects) This plugin runs 2 exports (or the player 8).
8303
8323
  * @throws `unavailable` (rejects) No FFmpeg, or the export worker could not
8304
8324
  * start.
@@ -8318,18 +8338,48 @@ export interface Snapshot extends Disposable {
8318
8338
  * if (end.status !== "completed") await plugin.osd.show("Export {}", [end.status]);
8319
8339
  * ```
8320
8340
  *
8341
+ * @example Cut without re-encoding and report where it starts
8342
+ * The clip is copied as it is in the file. A copy starts on a keyframe,
8343
+ * so the OSD says where it actually starts; a file the player cannot
8344
+ * copy ends with `unsupported`, and the plugin offers a re-encode instead.
8345
+ * ```ts
8346
+ * declare const snapshot: Snapshot;
8347
+ * // ---cut---
8348
+ * const job = await snapshot.export({ format: "copy", destination: "/tmp/clip.mp4", end: snapshot.time + 20 });
8349
+ * const state = await job.done;
8350
+ * const outcome = state.outcome;
8351
+ * // The copy starts on a keyframe: outcome.start says where.
8352
+ * if (outcome && "destination" in outcome)
8353
+ * await plugin.osd.show("Saved {} from {} s", [outcome.destination, (outcome.start ?? snapshot.time).toFixed(1)]);
8354
+ * // Codecs the container cannot hold, a cover image, …
8355
+ * else if (outcome && "error" in outcome && outcome.error.code === "unsupported")
8356
+ * await plugin.osd.show("Fast cut is not possible for this file");
8357
+ * ```
8358
+ *
8321
8359
  * @since 1.0.0
8322
8360
  */
8323
8361
  export(options: ExportOptions): Promise<ExportJob>;
8324
8362
  }
8325
8363
  /**
8326
8364
  * One frame as PNG, JPEG (`quality` 1–100, default 90) or BMP (24-bit), or
8327
- * a clip as MP4.
8365
+ * a clip: re-encoded as MP4 (`mp4`, frame-accurate, optional
8366
+ * {@link ExportEncoding}) or copied without re-encoding (`copy`, player
8367
+ * 0.7.6, starting on a keyframe).
8328
8368
  *
8329
8369
  * `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.
8370
+ * choose the format (a copy's extension is set to its container).
8371
+ * `sampleTime` picks the frame (default: the snapshot time); `end` is the
8372
+ * clip's end in source seconds. On Windows the export worker needs FFmpeg
8373
+ * 6.1 or newer.
8374
+ *
8375
+ * **Which players have `copy`, `encoding` and the progress and range of
8376
+ * a job**: player 0.7.6 and later. Declare `"minPlayerVersion": "0.7.6"`,
8377
+ * or, to keep older players and offer these only where they exist, compare
8378
+ * {@link AppInfo.version | plugin.app.version} with `0.7.6`, as for any
8379
+ * newer API. Do not probe by trying: on an older player the `plugin`
8380
+ * object comes from that player's own, older SDK, which rejects `copy` with
8381
+ * `invalid` but builds a re-encode request without the `encoding` it does
8382
+ * not know, so the clip is encoded with the fixed settings and no error.
8333
8383
  *
8334
8384
  * @since 1.0.0
8335
8385
  * @category Media
@@ -8351,19 +8401,132 @@ export type ExportOptions = {
8351
8401
  /** Integer 1–100, default 90. */
8352
8402
  quality?: number;
8353
8403
  } | {
8354
- /** A clip from the snapshot time. */
8404
+ /** A clip from the snapshot time, re-encoded as H.264 + AAC. */
8355
8405
  format: "mp4";
8356
8406
  /** Absolute path of the new file. */
8357
8407
  destination: string;
8358
8408
  /** Clip end, source seconds. */
8359
8409
  end: number;
8410
+ /**
8411
+ * Encoder settings (player 0.7.6); without it, the settings before
8412
+ * they existed.
8413
+ *
8414
+ * @since 1.6.10
8415
+ */
8416
+ encoding?: ExportEncoding;
8417
+ } | {
8418
+ /**
8419
+ * A clip copied stream by stream, without re-encoding (player 0.7.6):
8420
+ * lossless and about as fast as the disk. It starts on the video
8421
+ * keyframe at or before the snapshot time; see {@link Snapshot.export}.
8422
+ *
8423
+ * @since 1.6.10
8424
+ */
8425
+ format: "copy";
8426
+ /**
8427
+ * Absolute path of the new file. The player gives it the extension of
8428
+ * the container it writes (`.mp4` or `.mkv`); the finished job's
8429
+ * `outcome.destination` is the actual path.
8430
+ */
8431
+ destination: string;
8432
+ /** Clip end, source seconds. */
8433
+ end: number;
8434
+ /**
8435
+ * `auto` (default): MP4 when the codecs fit it, otherwise MKV. `mp4`
8436
+ * fails with `unsupported` when they do not; `mkv` always writes MKV.
8437
+ */
8438
+ container?: "auto" | "mp4" | "mkv";
8360
8439
  };
8440
+ /**
8441
+ * The encoder settings of a re-encoded clip (`format: "mp4"`, player
8442
+ * 0.7.6): a closed set of values, never encoder arguments. Every field is
8443
+ * optional; the defaults are the settings clips had before: CRF 18, the
8444
+ * fastest speed, the source's size and frame rate, AAC 192 kb/s, one thread.
8445
+ * A value outside its set rejects the export with `invalid`. `videoBitrate:
8446
+ * "source"` replaces the CRF with the source's bitrate, for a "Same as
8447
+ * source" preset.
8448
+ *
8449
+ * @example A small file
8450
+ * A 720p, 30 fps clip at a lower quality, encoded with the cores the player
8451
+ * can spare.
8452
+ * ```ts
8453
+ * declare const snapshot: Snapshot;
8454
+ * // ---cut---
8455
+ * // Each field is a ceiling or a choice from a closed set.
8456
+ * const job = await snapshot.export({
8457
+ * format: "mp4",
8458
+ * destination: "/tmp/small.mp4",
8459
+ * end: snapshot.time + 30,
8460
+ * encoding: { crf: 26, maxHeight: 720, fps: 30, audioBitrate: 128, threads: "auto" },
8461
+ * });
8462
+ * ```
8463
+ *
8464
+ * @since 1.6.10
8465
+ * @category Media
8466
+ */
8467
+ export interface ExportEncoding {
8468
+ /**
8469
+ * H.264 quality, an integer 14–30 (x264's CRF): lower is a better picture
8470
+ * and a bigger file. Default 18.
8471
+ */
8472
+ crf?: number;
8473
+ /**
8474
+ * Encoding effort: `faster` (default), `balanced` or `smaller` (slower,
8475
+ * a smaller file at the same quality); x264's `veryfast`, `medium` and
8476
+ * `slow`.
8477
+ */
8478
+ speed?: "faster" | "balanced" | "smaller";
8479
+ /**
8480
+ * The tallest picture: a taller one is scaled down to this height,
8481
+ * keeping its shape; a shorter one is left as it is. Default: the
8482
+ * source's size.
8483
+ */
8484
+ maxHeight?: 1080 | 720 | 480;
8485
+ /**
8486
+ * The highest frame rate: a faster source drops frames to it; a slower
8487
+ * one is left as it is. Default: the source's rate.
8488
+ */
8489
+ fps?: 60 | 30;
8490
+ /** AAC audio bitrate in kb/s. Default 192. */
8491
+ audioBitrate?: 128 | 192 | 256;
8492
+ /**
8493
+ * `single` (default) encodes on one thread, leaving the other cores to
8494
+ * playback. `auto` lets the player use more: half the logical
8495
+ * processors, at most 8, so playback and the interface keep their rate.
8496
+ * From player 0.7.6 every export runs at below-normal priority (as
8497
+ * HandBrake and OBS run their encoders), with either setting.
8498
+ */
8499
+ threads?: "single" | "auto";
8500
+ /**
8501
+ * `source`: aim at the source video's average bitrate instead of a
8502
+ * quality level (peaks up to 1.5 times it), so the file comes out about
8503
+ * the size of that stretch of the source: a "Same as source" preset. Not
8504
+ * together with `crf` (`invalid`). A source whose bitrate the player
8505
+ * cannot tell ends the job `failed` with `unsupported`.
8506
+ */
8507
+ videoBitrate?: "source";
8508
+ }
8361
8509
  /**
8362
8510
  * An export job's state. `status` is `queued`, `running`, `cancelling`,
8363
8511
  * `completed`, `failed` or `cancelled`; once terminal, `outcome` is
8364
8512
  * `{ destination }`, `{ error: { code, message } }` or `{ cancelled: true }`.
8365
8513
  * `facts` describes what the worker produced (versions, range, streams).
8366
8514
  *
8515
+ * From player 0.7.6:
8516
+ * - a finished clip's `outcome` also has `start` and `end`, the source
8517
+ * times of its first frame and of the end of its last frame as written: a
8518
+ * copy's earliest written frame (its keyframe, or the leading frames of
8519
+ * an open GOP) and actual end; a re-encode's start and start
8520
+ * plus the encoded length.
8521
+ * - `progress` is `{ done, total }` in seconds of the clip while FFmpeg
8522
+ * writes it (at most 4 updates a second; the SDK reads it about every
8523
+ * 350 ms), `null` while unknown: before writing starts, and always for a
8524
+ * frame. Show an indeterminate bar then. After `done` reaches `total` the
8525
+ * player still checks the file before the job completes.
8526
+ * - `error.code` is `export-failed`, or `unsupported` for an export the
8527
+ * player cannot make as asked (a stream copy it cannot make, a
8528
+ * `videoBitrate: "source"` whose source bitrate it cannot tell).
8529
+ *
8367
8530
  * @since 1.0.0
8368
8531
  * @category Media
8369
8532
  */
package/dist/wire.d.ts CHANGED
@@ -798,6 +798,11 @@ export declare function createWire(transport: Transport): {
798
798
  } | {
799
799
  kind: "rawMp4" | "processedMp4";
800
800
  end: number;
801
+ encoding?: import("./media.ts").WireEncoding;
802
+ } | {
803
+ kind: "rawCopy";
804
+ end: number;
805
+ container?: "auto" | "mp4" | "mkv";
801
806
  })) => Promise<{
802
807
  jobId: number;
803
808
  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.10: 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.10",
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": {