@vestra-player/sdk 1.5.37 → 1.5.39

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.
@@ -15,6 +15,7 @@ import { IMAGE_LIMITS, UI_VALUE_LIMITS } from "./values.ts";
15
15
  import type { CommandDeclaration } from "../commands.ts";
16
16
  import type { SourceState } from "../source.ts";
17
17
  import type { SubtitleState } from "../subtitles.ts";
18
+ import { VIDEO_PROCESSING_LIMITS } from "../processing.ts";
18
19
  import type { RenderingMode, RenderingPreference, RenderingReason, RenderSettings, UpscalingReason, ViewSettings } from "./types.ts";
19
20
  import type { MediaJob } from "../media.ts";
20
21
  import type { LanguagePreference, Locale, Plugin, TemplateArgs, TextValue, ThemeId } from "./types.ts";
@@ -41,6 +42,8 @@ export { SECRET_LIMITS };
41
42
  export { SESSION_STORAGE_LIMITS };
42
43
  /** The player's limits on loaded subtitle files, which the test host enforces as well. */
43
44
  export { SUBTITLE_LIMITS };
45
+ /** The player's bounds on convolution, lut3d and shader-channel requests, which the test host enforces as well. */
46
+ export { VIDEO_PROCESSING_LIMITS };
44
47
  /** The names bindings are made of. */
45
48
  export { BINDING_INPUTS };
46
49
  /** The colours of the player's palettes, by `Theme` token. */
@@ -535,6 +538,16 @@ export declare class TestHost implements Transport {
535
538
  readonly calls: Call[];
536
539
  /** The host events the plugin subscribed to (needs `events` etc.). */
537
540
  readonly subscriptions: Set<string>;
541
+ /**
542
+ * The subscribed events the plugin really listens to: an `onChange` and
543
+ * the like, or a binding that reads the state. The SDK's own copies of
544
+ * the state (what keeps `plugin.player.state` current) are left out.
545
+ * Empty means the player may stop the plugin while nothing uses it
546
+ * (unless it holds something else: a window, a panel, a filter…).
547
+ *
548
+ * @since 1.5.38
549
+ */
550
+ readonly listening: Set<string>;
538
551
  /**
539
552
  * The player's source facts (phase, position, duration, paused, rate,
540
553
  * audio tracks, loop, ...). Wire-level shape; change it with
@@ -1331,6 +1344,64 @@ export declare class TestHost implements Transport {
1331
1344
  * @since 1.0.0
1332
1345
  */
1333
1346
  failVideoRender(code: "failed" | "unsupported", message: string): void;
1347
+ /**
1348
+ * The shader channel (`plugin.video.shaders(id)`, SDK 1.5.39): the lists
1349
+ * the player holds for this plugin's handles by id (the real paths, after
1350
+ * following links), and how many shaders other plugins hold, which count
1351
+ * against {@link VIDEO_PROCESSING_LIMITS}`.shaders` (`limit`). Files must
1352
+ * be under {@link TestHost.fileRoots}; under software rendering
1353
+ * (`videoRender.gpu = false`) a non-empty list fails with `unsupported`.
1354
+ *
1355
+ * @since 1.5.39
1356
+ */
1357
+ readonly videoShaders: {
1358
+ /** This plugin's lists by handle id; a cleared handle has none. */
1359
+ handles: Map<string, string[]>;
1360
+ /** Shaders other plugins hold (0–16). */
1361
+ others: number;
1362
+ };
1363
+ /**
1364
+ * Where the plugin's own files may be: its package directory and its
1365
+ * data directory ({@link startPlugin} sets both when the list is still
1366
+ * empty). `lut3d` files and shader-channel files outside them fail with
1367
+ * `invalid`, as in the player.
1368
+ *
1369
+ * @since 1.5.39
1370
+ */
1371
+ fileRoots: string[];
1372
+ /**
1373
+ * What the player pushes to a shader handle when mpv cannot compile a
1374
+ * shader (code `failed`; the player cannot tell whose shader failed, so
1375
+ * every handle with shaders hears it) or the player switches to software
1376
+ * rendering (`unsupported`; use {@link TestHost.failVideoRender}, which
1377
+ * also tells the handles). Pushed only to a handle that holds shaders.
1378
+ *
1379
+ * @capability None.
1380
+ *
1381
+ * @example Make the plugin's shader fail to compile
1382
+ * The plugin's shader handle reports `failed`, as when mpv rejects a
1383
+ * shader. The host pushes the failure only to a handle that holds
1384
+ * shaders, which its accepted `set` made it; the SDK updates the
1385
+ * handle's `status` when the push arrives, so the test settles first.
1386
+ * ```ts
1387
+ * import assert from "node:assert/strict";
1388
+ * import { join } from "node:path";
1389
+ * import { writeFileSync } from "node:fs";
1390
+ * declare const host: TestHost;
1391
+ * // ---cut---
1392
+ * const file = join(plugin.dataDirectory, "sharpen.glsl");
1393
+ * writeFileSync(file, "//!HOOK MAIN\n");
1394
+ * const shaders = plugin.video.shaders("sharpen");
1395
+ * shaders.set([file]); // the handle now holds a shader
1396
+ * await host.settle();
1397
+ * host.failVideoShaders("sharpen", "shader compile failed: ERROR: 0:3: ...");
1398
+ * await host.settle();
1399
+ * assert.equal(shaders.status.state, "failed");
1400
+ * ```
1401
+ *
1402
+ * @since 1.5.39
1403
+ */
1404
+ failVideoShaders(id: string, message: string): void;
1334
1405
  /**
1335
1406
  * The player's processing (filter) state. Wire-level; may change in minor
1336
1407
  * versions. `resources` holds each `video.filter` / `audio.filter` chain the
@@ -1671,9 +1742,9 @@ export declare class TestHost implements Transport {
1671
1742
  * import activate from "../main.ts";
1672
1743
  *
1673
1744
  * const host = new TestHost();
1674
- * host.videoRender.gpu = false; // software draws now (Windows)
1745
+ * host.videoRender.gpu = false; // software draws now (Linux)
1675
1746
  * host.rendering.startedWith = "software";
1676
- * host.rendering.reason = { code: "no-gpu-backend", message: "No GPU renderer on Windows" };
1747
+ * host.rendering.reason = { code: "no-gpu-backend", message: "this player has no GPU renderer on Linux yet" };
1677
1748
  * host.rendering.gpu = { available: false, reason: host.rendering.reason };
1678
1749
  * const { stop } = await startPlugin(activate, {
1679
1750
  * directory: new URL("..", import.meta.url),
@@ -1681,7 +1752,7 @@ export declare class TestHost implements Transport {
1681
1752
  * });
1682
1753
  * const item = host.command("gpu-rendering");
1683
1754
  * assert.equal(item.state.enabled, false);
1684
- * assert.equal(item.state.reason, "No GPU renderer on Windows");
1755
+ * assert.equal(item.state.reason, "this player has no GPU renderer on Linux yet");
1685
1756
  * await stop();
1686
1757
  * ```
1687
1758
  *
@@ -64,12 +64,12 @@
64
64
  * @packageDocumentation
65
65
  * @module @vestra-player/sdk/testing
66
66
  */
67
- import { existsSync, readFileSync } from "node:fs";
67
+ import { existsSync, openSync, readFileSync, readSync, closeSync, realpathSync, } from "node:fs";
68
68
  import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
69
69
  import { createServer } from "node:net";
70
70
  import { randomBytes } from "node:crypto";
71
71
  import { tmpdir } from "node:os";
72
- import { extname, isAbsolute, join } from "node:path";
72
+ import { extname, isAbsolute, join, relative } from "node:path";
73
73
  import { fileURLToPath } from "node:url";
74
74
  import { HostError } from "../wire.js";
75
75
  import { localSocketPath } from "./local.js";
@@ -82,7 +82,8 @@ import { BINDING_INPUTS } from "./bindings.js";
82
82
  import { THEME_PALETTES } from "../palettes.js";
83
83
  import { IMAGE_LIMITS, UI_VALUE_LIMITS, valueLimitProblem } from "./values.js";
84
84
  import { validStep } from "./player.js";
85
- import { nodeProblem } from "../processing.js";
85
+ import { nodeProblem, VIDEO_PROCESSING_LIMITS } from "../processing.js";
86
+ import { shaderIdProblem, shaderListProblem } from "./shaders.js";
86
87
  import { RENDER_DEFAULTS, renderProblem, VIEW_DEFAULTS, viewProblem, } from "./view.js";
87
88
  import { createPlugin } from "./index.js";
88
89
  import { THEMES } from "./appearance.js";
@@ -107,6 +108,8 @@ export { SECRET_LIMITS };
107
108
  export { SESSION_STORAGE_LIMITS };
108
109
  /** The player's limits on loaded subtitle files, which the test host enforces as well. */
109
110
  export { SUBTITLE_LIMITS };
111
+ /** The player's bounds on convolution, lut3d and shader-channel requests, which the test host enforces as well. */
112
+ export { VIDEO_PROCESSING_LIMITS };
110
113
  /** The names bindings are made of. */
111
114
  export { BINDING_INPUTS };
112
115
  /** The colours of the player's palettes, by `Theme` token. */
@@ -188,6 +191,70 @@ const sessionCost = (key, value) => Buffer.byteLength(key) +
188
191
  SESSION_STORAGE_LIMITS.entryBytes;
189
192
  const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
190
193
  const tick = () => new Promise((resolve) => setImmediate(resolve));
194
+ /**
195
+ * The player's `playback/plugin_files.rs`: a file the plugin names for its
196
+ * video processing is an absolute path inside one of `roots` after
197
+ * following links, a regular file of at most `max` bytes. Returns the real
198
+ * path or throws the player's error.
199
+ */
200
+ function ownFile(path, roots, what, max) {
201
+ if (!isAbsolute(path))
202
+ throw new HostError("invalid", `${what} path must be absolute: ${path}`);
203
+ let real;
204
+ try {
205
+ real = realpathSync(path);
206
+ }
207
+ catch {
208
+ throw new HostError("missing", `${what} not found: ${path}`);
209
+ }
210
+ const inside = roots.some((root) => {
211
+ let base;
212
+ try {
213
+ base = realpathSync(root);
214
+ }
215
+ catch {
216
+ return false;
217
+ }
218
+ const rel = relative(base, real);
219
+ return rel === "" || (!/^\.\.([\\/]|$)/.test(rel) && !isAbsolute(rel));
220
+ });
221
+ if (!inside)
222
+ throw new HostError("invalid", `${what} must be in the plugin's package or data directory: ${path}`);
223
+ const stat = statSync(real);
224
+ if (!stat.isFile())
225
+ throw new HostError("invalid", `${what} is not a file: ${path}`);
226
+ if (stat.size > max)
227
+ throw new HostError("invalid", `${what} is larger than ${max / 1024 / 1024} MiB: ${path}`);
228
+ return real;
229
+ }
230
+ /** The `LUT_3D_SIZE` check of `plugin_files::lut` on the first 64 KiB. */
231
+ function cubeProblem(file) {
232
+ const buffer = Buffer.alloc(64 * 1024);
233
+ const fd = openSync(file, "r");
234
+ let read;
235
+ try {
236
+ read = readSync(fd, buffer, 0, buffer.length, 0);
237
+ }
238
+ finally {
239
+ closeSync(fd);
240
+ }
241
+ for (const line of buffer.subarray(0, read).toString("utf8").split("\n")) {
242
+ const [word, value] = line.trim().split(/\s+/);
243
+ if (word === "LUT_3D_SIZE") {
244
+ const size = Number(value);
245
+ return Number.isInteger(size) &&
246
+ size >= 2 &&
247
+ size <= VIDEO_PROCESSING_LIMITS.lutSize
248
+ ? null
249
+ : `LUT_3D_SIZE must be 2 to ${VIDEO_PROCESSING_LIMITS.lutSize}`;
250
+ }
251
+ if (word === "LUT_1D_SIZE")
252
+ return "a 1D LUT; lut3d needs LUT_3D_SIZE";
253
+ if (word !== undefined && word !== "" && Number.isFinite(Number(word)))
254
+ break;
255
+ }
256
+ return "no LUT_3D_SIZE line before the data";
257
+ }
191
258
  function initialSource() {
192
259
  return {
193
260
  kind: "media",
@@ -333,6 +400,16 @@ export class TestHost {
333
400
  calls = [];
334
401
  /** The host events the plugin subscribed to (needs `events` etc.). */
335
402
  subscriptions = new Set();
403
+ /**
404
+ * The subscribed events the plugin really listens to: an `onChange` and
405
+ * the like, or a binding that reads the state. The SDK's own copies of
406
+ * the state (what keeps `plugin.player.state` current) are left out.
407
+ * Empty means the player may stop the plugin while nothing uses it
408
+ * (unless it holds something else: a window, a panel, a filter…).
409
+ *
410
+ * @since 1.5.38
411
+ */
412
+ listening = new Set();
336
413
  /**
337
414
  * The player's source facts (phase, position, duration, paused, rate,
338
415
  * audio tracks, loop, ...). Wire-level shape; change it with
@@ -1301,6 +1378,78 @@ export class TestHost {
1301
1378
  state: "failed",
1302
1379
  error: { code, message },
1303
1380
  });
1381
+ // The player tells every shader handle too (`failVideoShaders`).
1382
+ // and, like the player, removes their lists.
1383
+ if (code === "unsupported") {
1384
+ for (const [id, paths] of this.videoShaders.handles)
1385
+ if (paths.length > 0)
1386
+ this.emit("video.shaders.status", {
1387
+ id,
1388
+ state: "failed",
1389
+ error: { code, message },
1390
+ });
1391
+ this.videoShaders.handles.clear();
1392
+ }
1393
+ }
1394
+ /**
1395
+ * The shader channel (`plugin.video.shaders(id)`, SDK 1.5.39): the lists
1396
+ * the player holds for this plugin's handles by id (the real paths, after
1397
+ * following links), and how many shaders other plugins hold, which count
1398
+ * against {@link VIDEO_PROCESSING_LIMITS}`.shaders` (`limit`). Files must
1399
+ * be under {@link TestHost.fileRoots}; under software rendering
1400
+ * (`videoRender.gpu = false`) a non-empty list fails with `unsupported`.
1401
+ *
1402
+ * @since 1.5.39
1403
+ */
1404
+ videoShaders = { handles: new Map(), others: 0 };
1405
+ /**
1406
+ * Where the plugin's own files may be: its package directory and its
1407
+ * data directory ({@link startPlugin} sets both when the list is still
1408
+ * empty). `lut3d` files and shader-channel files outside them fail with
1409
+ * `invalid`, as in the player.
1410
+ *
1411
+ * @since 1.5.39
1412
+ */
1413
+ fileRoots = [];
1414
+ /**
1415
+ * What the player pushes to a shader handle when mpv cannot compile a
1416
+ * shader (code `failed`; the player cannot tell whose shader failed, so
1417
+ * every handle with shaders hears it) or the player switches to software
1418
+ * rendering (`unsupported`; use {@link TestHost.failVideoRender}, which
1419
+ * also tells the handles). Pushed only to a handle that holds shaders.
1420
+ *
1421
+ * @capability None.
1422
+ *
1423
+ * @example Make the plugin's shader fail to compile
1424
+ * The plugin's shader handle reports `failed`, as when mpv rejects a
1425
+ * shader. The host pushes the failure only to a handle that holds
1426
+ * shaders, which its accepted `set` made it; the SDK updates the
1427
+ * handle's `status` when the push arrives, so the test settles first.
1428
+ * ```ts
1429
+ * import assert from "node:assert/strict";
1430
+ * import { join } from "node:path";
1431
+ * import { writeFileSync } from "node:fs";
1432
+ * declare const host: TestHost;
1433
+ * // ---cut---
1434
+ * const file = join(plugin.dataDirectory, "sharpen.glsl");
1435
+ * writeFileSync(file, "//!HOOK MAIN\n");
1436
+ * const shaders = plugin.video.shaders("sharpen");
1437
+ * shaders.set([file]); // the handle now holds a shader
1438
+ * await host.settle();
1439
+ * host.failVideoShaders("sharpen", "shader compile failed: ERROR: 0:3: ...");
1440
+ * await host.settle();
1441
+ * assert.equal(shaders.status.state, "failed");
1442
+ * ```
1443
+ *
1444
+ * @since 1.5.39
1445
+ */
1446
+ failVideoShaders(id, message) {
1447
+ if ((this.videoShaders.handles.get(id)?.length ?? 0) > 0)
1448
+ this.emit("video.shaders.status", {
1449
+ id,
1450
+ state: "failed",
1451
+ error: { code: "failed", message },
1452
+ });
1304
1453
  }
1305
1454
  /**
1306
1455
  * The player's processing (filter) state. Wire-level; may change in minor
@@ -1600,9 +1749,9 @@ export class TestHost {
1600
1749
  * import activate from "../main.ts";
1601
1750
  *
1602
1751
  * const host = new TestHost();
1603
- * host.videoRender.gpu = false; // software draws now (Windows)
1752
+ * host.videoRender.gpu = false; // software draws now (Linux)
1604
1753
  * host.rendering.startedWith = "software";
1605
- * host.rendering.reason = { code: "no-gpu-backend", message: "No GPU renderer on Windows" };
1754
+ * host.rendering.reason = { code: "no-gpu-backend", message: "this player has no GPU renderer on Linux yet" };
1606
1755
  * host.rendering.gpu = { available: false, reason: host.rendering.reason };
1607
1756
  * const { stop } = await startPlugin(activate, {
1608
1757
  * directory: new URL("..", import.meta.url),
@@ -1610,7 +1759,7 @@ export class TestHost {
1610
1759
  * });
1611
1760
  * const item = host.command("gpu-rendering");
1612
1761
  * assert.equal(item.state.enabled, false);
1613
- * assert.equal(item.state.reason, "No GPU renderer on Windows");
1762
+ * assert.equal(item.state.reason, "this player has no GPU renderer on Linux yet");
1614
1763
  * await stop();
1615
1764
  * ```
1616
1765
  *
@@ -3377,8 +3526,18 @@ export class TestHost {
3377
3526
  };
3378
3527
  }
3379
3528
  #defaults = {
3380
- "events.subscribe": (p) => void this.subscriptions.add(String(p.event)),
3381
- "events.unsubscribe": (p) => void this.subscriptions.delete(String(p.event)),
3529
+ "events.subscribe": (p) => {
3530
+ const event = String(p.event);
3531
+ this.subscriptions.add(event);
3532
+ if (p.mirror === true)
3533
+ this.listening.delete(event);
3534
+ else
3535
+ this.listening.add(event);
3536
+ },
3537
+ "events.unsubscribe": (p) => {
3538
+ this.subscriptions.delete(String(p.event));
3539
+ this.listening.delete(String(p.event));
3540
+ },
3382
3541
  "commands.register": (p) => {
3383
3542
  const id = String(p.id);
3384
3543
  // The player's built-in menu ids (MENU_IDS) in the first path segment.
@@ -3965,6 +4124,34 @@ export class TestHost {
3965
4124
  this.setVideoRenderOwner(null);
3966
4125
  return null;
3967
4126
  },
4127
+ // The player's capability layer and `video_shaders.rs`.
4128
+ "video.shaders.set": (p) => {
4129
+ const idProblem = shaderIdProblem(p.id);
4130
+ if (idProblem)
4131
+ throw new HostError("invalid", idProblem);
4132
+ if (!Array.isArray(p.paths) ||
4133
+ p.paths.some((path) => typeof path !== "string"))
4134
+ throw new HostError("invalid", "paths must be an array of paths");
4135
+ const id = p.id;
4136
+ const list = p.paths;
4137
+ const listProblem = shaderListProblem(list);
4138
+ if (listProblem?.startsWith("at most"))
4139
+ throw new HostError("limit", listProblem);
4140
+ const paths = list.map((path) => ownFile(path, this.fileRoots, "shader", VIDEO_PROCESSING_LIMITS.shaderBytes));
4141
+ if (paths.length > 0 && !this.videoRender.gpu)
4142
+ throw new HostError("unsupported", "shaders need GPU rendering; the player renders in software");
4143
+ const mine = [...this.videoShaders.handles]
4144
+ .filter(([other]) => other !== id)
4145
+ .reduce((sum, [, held]) => sum + held.length, 0);
4146
+ const others = mine + this.videoShaders.others;
4147
+ if (others + paths.length > VIDEO_PROCESSING_LIMITS.shaders)
4148
+ throw new HostError("limit", `at most ${VIDEO_PROCESSING_LIMITS.shaders} shaders across all plugins: ${others} are in use, this list has ${paths.length}`);
4149
+ if (paths.length > 0)
4150
+ this.videoShaders.handles.set(id, paths);
4151
+ else
4152
+ this.videoShaders.handles.delete(id);
4153
+ return { state: this.source.phase === "ready" ? "applied" : "waiting" };
4154
+ },
3968
4155
  "timeline.set": (p) => {
3969
4156
  if (p.sourceId !== undefined &&
3970
4157
  p.sourceId !== this.source.stamp.generation)
@@ -4192,6 +4379,30 @@ export class TestHost {
4192
4379
  status: "failed",
4193
4380
  error: { code: "invalid", message: (problem ?? blend ?? delay) },
4194
4381
  });
4382
+ // `lut3d` files: the plugin's own `.cube` files (`plugin_files.rs`).
4383
+ if (stage === "video")
4384
+ for (const node of (p.nodes ?? []))
4385
+ if (node.kind === "lut3d") {
4386
+ let cube;
4387
+ try {
4388
+ const real = ownFile(node.file, this.fileRoots, "lut3d file", VIDEO_PROCESSING_LIMITS.lutBytes);
4389
+ cube = /\.cube$/i.test(real)
4390
+ ? cubeProblem(real)
4391
+ : "lut3d file must be a .cube file";
4392
+ }
4393
+ catch (error) {
4394
+ const { code, message } = error;
4395
+ return finish({ status: "failed", error: { code, message } });
4396
+ }
4397
+ if (cube)
4398
+ return finish({
4399
+ status: "failed",
4400
+ error: {
4401
+ code: "invalid",
4402
+ message: `lut3d file ${node.file}: ${cube}`,
4403
+ },
4404
+ });
4405
+ }
4195
4406
  if (this.source.phase !== "ready")
4196
4407
  return finish({
4197
4408
  status: "failed",
@@ -4824,6 +5035,8 @@ export async function startPlugin(activate, options = {}) {
4824
5035
  for (const [key, value] of options.sessionStorage ?? [])
4825
5036
  host.sessionStorage.set(key, sortedJson(value));
4826
5037
  mkdirSync(data, { recursive: true });
5038
+ if (host.fileRoots.length === 0)
5039
+ host.fileRoots = [directory, data];
4827
5040
  const instance = await createPlugin(host, manifest, directory, data, host.app);
4828
5041
  await activate(instance.plugin);
4829
5042
  // Like the player: a position or resolver another plugin holds is not an
@@ -16,7 +16,7 @@ export declare class TimelineService {
16
16
  accept(marks: WireMark[]): void;
17
17
  read(): Promise<void>;
18
18
  watch(listener: (marks: readonly TimelineMark[]) => void): {
19
- dispose: () => Promise<undefined>;
19
+ dispose: () => Promise<void>;
20
20
  };
21
21
  }
22
22
  export declare const timelineOf: (runtime: Runtime) => TimelineService;
@@ -27,13 +27,14 @@ export class TimelineService {
27
27
  if (this.#started)
28
28
  return;
29
29
  this.#started = true;
30
+ // A mirror: a plugin listens through `watch` and bindings.
30
31
  this.#runtime.on("timeline.changed", (data) => {
31
32
  const marks = data?.marks;
32
33
  if (Array.isArray(marks))
33
34
  this.accept(marks);
34
35
  else
35
36
  void this.read();
36
- });
37
+ }, "events", true);
37
38
  }
38
39
  accept(marks) {
39
40
  const next = view(marks);
@@ -51,7 +52,13 @@ export class TimelineService {
51
52
  }
52
53
  watch(listener) {
53
54
  this.#listeners.add(listener);
54
- return { dispose: async () => void this.#listeners.delete(listener) };
55
+ const held = this.#runtime.hold("timeline.changed");
56
+ return {
57
+ dispose: async () => {
58
+ this.#listeners.delete(listener);
59
+ await held.dispose();
60
+ },
61
+ };
55
62
  }
56
63
  }
57
64
  export const timelineOf = (runtime) => runtime.service("timeline", () => new TimelineService(runtime));
@@ -60,6 +67,7 @@ export function timeline(scope) {
60
67
  return {
61
68
  get marks() {
62
69
  service().start();
70
+ scope.runtime.reading("timeline.changed");
63
71
  return service().marks;
64
72
  },
65
73
  onChange(listener) {