supervision 0.2.0-next.1 → 0.2.0-next.3

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.
Files changed (64) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +557 -134
  4. package/dist/index.js.map +1 -1
  5. package/dist/mask-preparation.worker.js.map +1 -1
  6. package/dist/media/media-frame-clock.d.ts +4 -0
  7. package/dist/media/media-frame-clock.d.ts.map +1 -0
  8. package/dist/media/media-source.d.ts +8 -0
  9. package/dist/media/media-source.d.ts.map +1 -1
  10. package/dist/media/mediabunny-media-source.d.ts.map +1 -1
  11. package/dist/media/video-engine-media-source.d.ts.map +1 -1
  12. package/dist/media/video-engine-media-source.js +1 -1
  13. package/dist/render-preparation/prepared-window-timeline.d.ts +1 -0
  14. package/dist/render-preparation/prepared-window-timeline.d.ts.map +1 -1
  15. package/dist/renderers/media-frame-navigation.d.ts +17 -0
  16. package/dist/renderers/media-frame-navigation.d.ts.map +1 -0
  17. package/dist/renderers/media-renderer-core.d.ts.map +1 -1
  18. package/dist/renderers/media-renderer-scene.d.ts +1 -0
  19. package/dist/renderers/media-renderer-scene.d.ts.map +1 -1
  20. package/dist/renderers/media-renderer-transport.d.ts +4 -1
  21. package/dist/renderers/media-renderer-transport.d.ts.map +1 -1
  22. package/dist/renderers/pixi-media-scene.d.ts.map +1 -1
  23. package/dist/renderers/prepared-annotation-window.d.ts.map +1 -1
  24. package/dist/renderers/presented-frame-channel.d.ts +8 -6
  25. package/dist/renderers/presented-frame-channel.d.ts.map +1 -1
  26. package/dist/sessions/media-session-defaults.d.ts.map +1 -1
  27. package/dist/sessions/media-session-detections.d.ts.map +1 -1
  28. package/dist/sessions/media-session.d.ts.map +1 -1
  29. package/dist/tracking.worker.js.map +1 -1
  30. package/dist/types/media-frame-clock.d.ts +16 -0
  31. package/dist/types/media-frame-clock.d.ts.map +1 -0
  32. package/dist/types/media-frame-navigation.d.ts +28 -0
  33. package/dist/types/media-frame-navigation.d.ts.map +1 -0
  34. package/dist/types/media-renderer-display.d.ts +12 -0
  35. package/dist/types/media-renderer-display.d.ts.map +1 -0
  36. package/dist/types/media-renderer.d.ts +13 -0
  37. package/dist/types/media-renderer.d.ts.map +1 -1
  38. package/dist/types/media-session.d.ts +15 -5
  39. package/dist/types/media-session.d.ts.map +1 -1
  40. package/dist/types/render-preparation.d.ts +8 -5
  41. package/dist/types/render-preparation.d.ts.map +1 -1
  42. package/dist/{video-engine-media-source-CUXnOJOV.js → video-engine-media-source-DR-x1UfK.js} +35 -2
  43. package/dist/video-engine-media-source-DR-x1UfK.js.map +1 -0
  44. package/dist/web-video-engine/analysis.js +16 -0
  45. package/dist/web-video-engine/canvas-sink-scrub-cursor.d.ts +7 -2
  46. package/dist/web-video-engine/decode-scheduler.d.ts +9 -3
  47. package/dist/web-video-engine/decode-session.d.ts +5 -2
  48. package/dist/web-video-engine/decode-source.d.ts +2 -0
  49. package/dist/web-video-engine/engine-core.d.ts +10 -0
  50. package/dist/web-video-engine/engine.js +131 -15
  51. package/dist/web-video-engine/engine.worker.js +513 -36
  52. package/dist/web-video-engine/frame-cache.d.ts +2 -0
  53. package/dist/web-video-engine/pausable-deadline.d.ts +11 -0
  54. package/dist/web-video-engine/scrub-controller.d.ts +8 -4
  55. package/dist/web-video-engine/scrub-cursor.d.ts +6 -5
  56. package/dist/web-video-engine/video-engine.d.ts +16 -1
  57. package/dist/web-video-engine/worker-protocol.d.ts +9 -1
  58. package/node_modules/supervision-js-core/dist/detections/buffered-detection-timeline.d.ts.map +1 -1
  59. package/node_modules/supervision-js-core/dist/index.js +77 -41
  60. package/node_modules/supervision-js-core/dist/index.js.map +1 -1
  61. package/node_modules/supervision-js-core/dist/types/detection-timeline.d.ts +8 -3
  62. package/node_modules/supervision-js-core/dist/types/detection-timeline.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/dist/video-engine-media-source-CUXnOJOV.js.map +0 -1
@@ -26172,6 +26172,19 @@
26172
26172
  return queued.timestampS;
26173
26173
  return this.reachable;
26174
26174
  }
26175
+ async resizeOutput({ width, height }) {
26176
+ await this.exclusive(async () => {
26177
+ if (this.closed || this.outputWidth === undefined)
26178
+ return;
26179
+ if (this.outputWidth === width && this.outputHeight === height)
26180
+ return;
26181
+ this.quiesce();
26182
+ this.outputWidth = width;
26183
+ this.outputHeight = height;
26184
+ this.exhausted = true;
26185
+ this.epoch++;
26186
+ });
26187
+ }
26175
26188
  /** The frame at or before `targetS`, or null when nothing precedes it. */
26176
26189
  async frameAt(targetS) {
26177
26190
  const landed = await this.exclusive(() => this.land(targetS));
@@ -26701,6 +26714,25 @@
26701
26714
  function nativeResolution() {
26702
26715
  return { kind: "native" };
26703
26716
  }
26717
+ /**
26718
+ * Decode at the size the frame will actually occupy inside a box the consumer
26719
+ * describes: native aspect fitted into boxWidth x boxHeight, times the clamped
26720
+ * device pixel ratio. Letterboxing is why the box is two numbers and not one,
26721
+ * since a portrait source in a landscape box paints far narrower than the box.
26722
+ *
26723
+ * This is the strategy for presentation "frames". There the consumer owns the
26724
+ * compositor, so it is the only side that knows the box, and viewportResolution
26725
+ * has nothing to read.
26726
+ */
26727
+ function displayBoxResolution(options) {
26728
+ return {
26729
+ kind: "displayBox",
26730
+ boxWidth: options.boxWidth,
26731
+ boxHeight: options.boxHeight,
26732
+ devicePixelRatio: options.devicePixelRatio,
26733
+ maxDevicePixelRatio: options.maxDevicePixelRatio ?? 2,
26734
+ };
26735
+ }
26704
26736
  /** Runs the descriptor against a context to produce a target decode width. */
26705
26737
  function strategyWidth(strategy, ctx) {
26706
26738
  switch (strategy.kind) {
@@ -27120,6 +27152,9 @@
27120
27152
  ? sampleAtPresentationTime(sample, timeline.fromSourceTime(sample.timestamp))
27121
27153
  : null;
27122
27154
  return {
27155
+ ...(source.resizeOutput
27156
+ ? { resizeOutput: source.resizeOutput.bind(source) }
27157
+ : {}),
27123
27158
  async frameAt(targetS) {
27124
27159
  return wrap(await source.frameAt(timeline.toSourceTime(targetS)));
27125
27160
  },
@@ -27172,6 +27207,7 @@
27172
27207
  };
27173
27208
  return {
27174
27209
  decodePath: "sample",
27210
+ resizeOutput: async () => undefined,
27175
27211
  // Pool-bound: every outstanding frame is a slot the sink cannot refill.
27176
27212
  playReadAhead: 1,
27177
27213
  track: handle.track,
@@ -27207,6 +27243,9 @@
27207
27243
  function sessionProvider(handle) {
27208
27244
  return {
27209
27245
  decodePath: "session",
27246
+ ...(handle.session.resizeOutput
27247
+ ? { resizeOutput: handle.session.resizeOutput.bind(handle.session) }
27248
+ : {}),
27210
27249
  playReadAhead: PLAYBACK.READ_AHEAD_CANVAS,
27211
27250
  track: handle.track,
27212
27251
  keyframeProbe: handle.keyframeProbe,
@@ -27630,6 +27669,8 @@
27630
27669
  class CanvasSinkScrubCursor {
27631
27670
  provider;
27632
27671
  trackInfo;
27672
+ outputGeneration = 0;
27673
+ outputReads = new Set();
27633
27674
  disposeSource;
27634
27675
  /**
27635
27676
  * Forward-playback iterator. Lives only between attachPlay and detachPlay so
@@ -27669,6 +27710,41 @@
27669
27710
  this.pendingSeekTargetS === null &&
27670
27711
  !this.seekDraining);
27671
27712
  }
27713
+ async resizeOutput({ width, height }) {
27714
+ if (!this.provider.resizeOutput) {
27715
+ throw new WebVideoEngineError(WebVideoEngineErrorCode.PresentationMismatch, "this decode source cannot resize frame output in place");
27716
+ }
27717
+ if (this.closed ||
27718
+ (width === this.trackInfo.decodeWidth &&
27719
+ height === this.trackInfo.decodeHeight))
27720
+ return false;
27721
+ this.outputGeneration++;
27722
+ this.pendingSeekTargetS = null;
27723
+ const returning = this.iterator?.return();
27724
+ this.iterator = null;
27725
+ this.nextPending = 0;
27726
+ this.lastEmittedFrame = null;
27727
+ await Promise.allSettled([
27728
+ ...this.outputReads,
27729
+ ...(returning ? [returning] : []),
27730
+ ]);
27731
+ if (this.closed)
27732
+ return false;
27733
+ await this.provider.resizeOutput({ width, height });
27734
+ if (this.closed)
27735
+ return false;
27736
+ this.trackInfo = {
27737
+ ...this.trackInfo,
27738
+ decodeWidth: width,
27739
+ decodeHeight: height,
27740
+ };
27741
+ return true;
27742
+ }
27743
+ trackOutputRead(read) {
27744
+ this.outputReads.add(read);
27745
+ void read.then(() => this.outputReads.delete(read), () => this.outputReads.delete(read));
27746
+ return read;
27747
+ }
27672
27748
  async open() {
27673
27749
  // Seed the first paint via the same random-access path used for every
27674
27750
  // seek. Cheaper than a one-shot iterator just for the seed. Seed at the
@@ -27694,12 +27770,20 @@
27694
27770
  * lands on that frame and no other. Nothing here walks, compares or skips,
27695
27771
  * so a step across a frame boundary needs no epsilon.
27696
27772
  */
27697
- async seekToFrame(frame) {
27773
+ async seekToFrame(frame, isPresentationCurrent) {
27698
27774
  if (this.closed)
27699
27775
  return null;
27700
- const decoded = await this.provider.getFrame(this.trackInfo.timeline.timeAt(frame.index));
27701
- if (this.closed || !decoded)
27776
+ this.outputGeneration += 1;
27777
+ const outputGeneration = this.outputGeneration;
27778
+ const decoded = await this.trackOutputRead(this.provider.getFrame(this.trackInfo.timeline.timeAt(frame.index)));
27779
+ if (this.closed ||
27780
+ outputGeneration !== this.outputGeneration ||
27781
+ isPresentationCurrent?.() === false ||
27782
+ !decoded) {
27783
+ if (decoded?.kind === "sample")
27784
+ decoded.sample.close();
27702
27785
  return null;
27786
+ }
27703
27787
  this.emitFrame(decoded);
27704
27788
  return this.lastEmittedFrame;
27705
27789
  }
@@ -27727,11 +27811,13 @@
27727
27811
  attachPlay(startS) {
27728
27812
  if (this.closed)
27729
27813
  return;
27730
- void this.iterator?.return();
27814
+ if (this.iterator)
27815
+ void this.trackOutputRead(this.iterator.return());
27731
27816
  this.iterator = this.provider.frames(startS);
27732
27817
  }
27733
27818
  detachPlay() {
27734
- void this.iterator?.return();
27819
+ if (this.iterator)
27820
+ void this.trackOutputRead(this.iterator.return());
27735
27821
  this.iterator = null;
27736
27822
  }
27737
27823
  next() {
@@ -27776,6 +27862,7 @@
27776
27862
  }
27777
27863
  async close() {
27778
27864
  this.closed = true;
27865
+ this.outputReads.clear();
27779
27866
  this.pendingSeekTargetS = null;
27780
27867
  this.currentState = ScrubCursorState.Closed;
27781
27868
  this.listeners.clear();
@@ -27809,11 +27896,14 @@
27809
27896
  async runSeek(t, keyOnly) {
27810
27897
  if (this.closed)
27811
27898
  return;
27899
+ const outputGeneration = this.outputGeneration;
27812
27900
  this.currentState = ScrubCursorState.Seeking;
27813
- const frame = await this.provider.getFrame(t);
27814
- if (this.closed)
27815
- return;
27816
- if (frame)
27901
+ const frame = await this.trackOutputRead(this.provider.getFrame(t));
27902
+ if (this.closed || outputGeneration !== this.outputGeneration) {
27903
+ if (frame?.kind === "sample")
27904
+ frame.sample.close();
27905
+ }
27906
+ else if (frame)
27817
27907
  this.emitFrame(frame, keyOnly);
27818
27908
  if (!this.closed)
27819
27909
  this.currentState = ScrubCursorState.Idle;
@@ -27826,7 +27916,7 @@
27826
27916
  // otherwise we would race the iterator state.
27827
27917
  if (this.seekDraining)
27828
27918
  return;
27829
- const next = (await iterator.next()).value ?? null;
27919
+ const next = (await this.trackOutputRead(iterator.next())).value ?? null;
27830
27920
  if (this.closed || iterator !== this.iterator || !next) {
27831
27921
  if (next?.kind === "sample")
27832
27922
  next.sample.close();
@@ -27910,6 +28000,7 @@
27910
28000
  FrameTier["Preview"] = "preview";
27911
28001
  })(FrameTier || (FrameTier = {}));
27912
28002
  class FrameCache {
28003
+ options;
27913
28004
  exact;
27914
28005
  preview;
27915
28006
  exactHits = 0;
@@ -27918,6 +28009,7 @@
27918
28009
  bucketMs;
27919
28010
  exactBudgetBytes;
27920
28011
  constructor(options) {
28012
+ this.options = options;
27921
28013
  const exactWidth = Math.max(1, Math.round(options.exactWidth));
27922
28014
  const exactHeight = Math.max(1, Math.round(options.exactHeight));
27923
28015
  const aspect = exactWidth / exactHeight;
@@ -27937,6 +28029,13 @@
27937
28029
  this.exact = new TierStore(exactCapacity, exactWidth, exactHeight, FRAME_IDENTITY_KEY_GRID_MS);
27938
28030
  this.preview = new TierStore(Math.max(0, Math.floor(options.previewCapacity)), previewWidth, previewHeight, PREVIEW_KEY_GRID_MS);
27939
28031
  }
28032
+ resized(width, height) {
28033
+ return new FrameCache({
28034
+ ...this.options,
28035
+ exactWidth: width,
28036
+ exactHeight: height,
28037
+ });
28038
+ }
27940
28039
  /**
27941
28040
  * Store a crisp full-resolution frame for the exact (scrub) tier under
27942
28041
  * `frame`, the timeline's name for it. `timestampMs` is the decoded
@@ -28663,6 +28762,9 @@
28663
28762
  cache;
28664
28763
  keyframeIndex;
28665
28764
  trackInfo;
28765
+ outputGeneration = 0;
28766
+ resizingOutput = false;
28767
+ outputReads = new Set();
28666
28768
  exactTolMs;
28667
28769
  previewTolMs;
28668
28770
  now;
@@ -28777,6 +28879,47 @@
28777
28879
  this.pendingSeekTargetS === null &&
28778
28880
  !this.seekDraining);
28779
28881
  }
28882
+ async resizeOutput({ width, height }) {
28883
+ if (!this.provider.resizeOutput) {
28884
+ throw new WebVideoEngineError(WebVideoEngineErrorCode.PresentationMismatch, "this decode source cannot resize frame output in place");
28885
+ }
28886
+ if (this.closed ||
28887
+ (width === this.trackInfo.decodeWidth &&
28888
+ height === this.trackInfo.decodeHeight))
28889
+ return false;
28890
+ this.outputGeneration++;
28891
+ this.resizingOutput = true;
28892
+ this.pendingSeekTargetS = null;
28893
+ const returning = this.iterator?.return();
28894
+ this.iterator = null;
28895
+ this.nextPending = 0;
28896
+ this.abandonPrefetch();
28897
+ this.lastEmittedFrame = null;
28898
+ try {
28899
+ await this.prefetchTask?.catch(() => undefined);
28900
+ await Promise.allSettled([
28901
+ ...this.outputReads,
28902
+ ...(returning ? [returning] : []),
28903
+ ]);
28904
+ if (this.closed)
28905
+ return false;
28906
+ await this.provider.resizeOutput({ width, height });
28907
+ if (this.closed)
28908
+ return false;
28909
+ this.trackInfo = {
28910
+ ...this.trackInfo,
28911
+ decodeWidth: width,
28912
+ decodeHeight: height,
28913
+ };
28914
+ const oldCache = this.cache;
28915
+ this.cache = oldCache.resized(width, height);
28916
+ oldCache.clear();
28917
+ return true;
28918
+ }
28919
+ finally {
28920
+ this.resizingOutput = false;
28921
+ }
28922
+ }
28780
28923
  async open() {
28781
28924
  // Seed at the track's real first timestamp, not a hardcoded 0: a trimmed
28782
28925
  // or offset clip's first frame is not at the origin, so getFrame(0) would
@@ -28996,7 +29139,9 @@
28996
29139
  this.enterMode(AccessMode.Playing);
28997
29140
  // Signal any running sweep to bail; the first pull awaits its teardown.
28998
29141
  this.abandonPrefetch();
28999
- void this.iterator?.return();
29142
+ if (this.iterator) {
29143
+ void this.trackOutputRead(this.iterator.return());
29144
+ }
29000
29145
  this.iterator = this.provider.frames(startS);
29001
29146
  // Reset the in-flight latch for the new session. An old-iterator pull may
29002
29147
  // still be settling; its finally is iterator-identity-aware (see next()),
@@ -29008,7 +29153,9 @@
29008
29153
  this.nextInFlight = Promise.resolve();
29009
29154
  }
29010
29155
  detachPlay() {
29011
- void this.iterator?.return();
29156
+ if (this.iterator) {
29157
+ void this.trackOutputRead(this.iterator.return());
29158
+ }
29012
29159
  this.iterator = null;
29013
29160
  // A pull in flight at detach never decrements, since its finally only
29014
29161
  // fires for the iterator it was issued against. Left stranded, the count
@@ -29051,13 +29198,18 @@
29051
29198
  * reuses whatever decode position the session already holds, with no
29052
29199
  * iterator opened per press.
29053
29200
  */
29054
- async seekToFrame(frame) {
29201
+ async seekToFrame(frame, isPresentationCurrent) {
29055
29202
  if (this.closed)
29056
29203
  return null;
29204
+ this.outputGeneration += 1;
29057
29205
  this.enterMode(AccessMode.Stepping);
29058
29206
  this.abandonPrefetch();
29207
+ const outputGeneration = this.outputGeneration;
29059
29208
  const decoded = await this.withDecodeWatchdog(this.provider.getFrame(this.trackInfo.timeline.timeAt(frame.index)));
29060
- if (this.closed || !decoded) {
29209
+ if (this.closed ||
29210
+ outputGeneration !== this.outputGeneration ||
29211
+ isPresentationCurrent?.() === false ||
29212
+ !decoded) {
29061
29213
  if (decoded?.kind === "sample")
29062
29214
  decoded.sample.close();
29063
29215
  return null;
@@ -29090,6 +29242,7 @@
29090
29242
  }
29091
29243
  async close() {
29092
29244
  this.closed = true;
29245
+ this.outputReads.clear();
29093
29246
  this.abandonPrefetch();
29094
29247
  this.pendingSeekTargetS = null;
29095
29248
  this.currentState = ScrubCursorState.Closed;
@@ -29168,6 +29321,7 @@
29168
29321
  }
29169
29322
  }
29170
29323
  async runExactSeek(t) {
29324
+ const outputGeneration = this.outputGeneration;
29171
29325
  this.exactSeeks += 1;
29172
29326
  const ms = t * 1000;
29173
29327
  const cached = this.cache.getForFrame(this.frameAtTime(t), ms, this.previewTolMs);
@@ -29188,7 +29342,7 @@
29188
29342
  const previewPaintedAt = painted && cached.tier === FrameTier.Preview ? this.now() : null;
29189
29343
  const startedAt = this.now();
29190
29344
  const frame = await this.withDecodeWatchdog(this.provider.getFrame(t));
29191
- if (this.closed || !frame) {
29345
+ if (this.closed || outputGeneration !== this.outputGeneration || !frame) {
29192
29346
  if (frame?.kind === "sample")
29193
29347
  frame.sample.close();
29194
29348
  return;
@@ -29255,13 +29409,14 @@
29255
29409
  : null, targetMs);
29256
29410
  }
29257
29411
  async runKeySeek(t) {
29412
+ const outputGeneration = this.outputGeneration;
29258
29413
  this.keySeeks += 1;
29259
29414
  // getFrame already anchors on the enclosing keyframe, so re-anchoring the
29260
29415
  // request collapses every target within a GOP onto that one frame. A
29261
29416
  // source carrying a single keyframe answers its whole timeline with the
29262
29417
  // first frame.
29263
29418
  const frame = await this.withDecodeWatchdog(this.provider.getFrame(t));
29264
- if (this.closed || !frame) {
29419
+ if (this.closed || outputGeneration !== this.outputGeneration || !frame) {
29265
29420
  if (frame?.kind === "sample")
29266
29421
  frame.sample.close();
29267
29422
  return;
@@ -29319,7 +29474,12 @@
29319
29474
  * decoder having a bad moment from one that will never answer again.
29320
29475
  */
29321
29476
  withDecodeWatchdog(decode) {
29322
- return this.watchdogged(decode, true);
29477
+ return this.watchdogged(this.trackOutputRead(decode), true);
29478
+ }
29479
+ trackOutputRead(read) {
29480
+ this.outputReads.add(read);
29481
+ void read.then(() => this.outputReads.delete(read), () => this.outputReads.delete(read));
29482
+ return read;
29323
29483
  }
29324
29484
  /**
29325
29485
  * The same watchdog over a container metadata read. Deliberately unbooked:
@@ -29447,6 +29607,11 @@
29447
29607
  return;
29448
29608
  }
29449
29609
  this.provider = openFrameProvider(handle);
29610
+ this.outputReads.clear();
29611
+ await this.provider.resizeOutput?.({
29612
+ width: this.trackInfo.decodeWidth,
29613
+ height: this.trackInfo.decodeHeight,
29614
+ });
29450
29615
  // Disposing the old input invalidated the old probe, so the index
29451
29616
  // has to be rebuilt against the new one. It starts empty, and
29452
29617
  // nothing else re-walks it: the source's keyframes are exactly
@@ -29527,6 +29692,8 @@
29527
29692
  * is never used twice at once, then runs under a captured generation token.
29528
29693
  */
29529
29694
  schedulePrefetch(aroundS) {
29695
+ if (this.resizingOutput)
29696
+ return;
29530
29697
  if (this.mode === AccessMode.Playing)
29531
29698
  return;
29532
29699
  this.abandonPrefetch();
@@ -29639,7 +29806,7 @@
29639
29806
  }
29640
29807
  }
29641
29808
  finally {
29642
- void iter.return();
29809
+ void this.trackOutputRead(iter.return());
29643
29810
  }
29644
29811
  }
29645
29812
  /**
@@ -31373,6 +31540,17 @@ ${VERTEX_STAGE}
31373
31540
  * crosses the duration.
31374
31541
  */
31375
31542
  beginPlay(startS) {
31543
+ const { timeline } = this.deps.cursor.track;
31544
+ if (this.playSessionLive &&
31545
+ this.deps.clock.playing &&
31546
+ this.lastDeliveryS !== null &&
31547
+ !this.paintedSincePlay &&
31548
+ timeline.indexAtOrBefore(this.playAnchorS) ===
31549
+ timeline.indexAtOrBefore(startS)) {
31550
+ return;
31551
+ }
31552
+ if (this.lastDeliveryS !== null)
31553
+ this.endPlay();
31376
31554
  this.endedFired = false;
31377
31555
  this.playAnchorS = startS;
31378
31556
  // The realtime needles are per-session rates, not lifetime totals: a
@@ -31401,6 +31579,7 @@ ${VERTEX_STAGE}
31401
31579
  this.lastPresentTickS = null;
31402
31580
  this.resetCadenceTracking();
31403
31581
  this.clearPlayQueue();
31582
+ this.clearStash();
31404
31583
  this.deps.cursor.attachPlay(startS);
31405
31584
  this.deps.cursor.next();
31406
31585
  }
@@ -31420,6 +31599,17 @@ ${VERTEX_STAGE}
31420
31599
  this.resetCadenceTracking();
31421
31600
  this.clearPlayQueue();
31422
31601
  }
31602
+ holdOutput() {
31603
+ this.stop();
31604
+ this.clearStash();
31605
+ this.clearPlayQueue();
31606
+ this.deps.cursor.detachPlay();
31607
+ this.lastDeliveryS = null;
31608
+ }
31609
+ releaseOutput() {
31610
+ if (!this.disposed && this.sink)
31611
+ this.start();
31612
+ }
31423
31613
  /**
31424
31614
  * Cache lookup helper used by the engine on scrub. Asks the cursor for a
31425
31615
  * cached frame near the target and, if it is far enough from what the canvas
@@ -31427,10 +31617,8 @@ ${VERTEX_STAGE}
31427
31617
  * calls cursor.seekTo to land the full-res frame; the cursor's emit listener
31428
31618
  * stashes it for the next tick.
31429
31619
  *
31430
- * The skip-near-current check rejects hits whose timestamp falls within
31431
- * cacheSkipNearMs of the currently-displayed frame. This is the knob
31432
- * consumers tune to control single-frame-step UX: a higher value forces a
31433
- * fresh decode on tighter movements, a value of 0 always serves the cache.
31620
+ * Nearby approximate hits respect cacheSkipNearMs. An exact hit for the
31621
+ * requested frame can advance immediately, without waiting for a decode.
31434
31622
  */
31435
31623
  tryPaintFromCache(timestampMs) {
31436
31624
  if (!this.sink)
@@ -31440,7 +31628,12 @@ ${VERTEX_STAGE}
31440
31628
  return false;
31441
31629
  const hitMs = Math.round(hit.timestampS * 1000);
31442
31630
  if (this.lastPaintedMs !== null) {
31443
- if (Math.abs(hitMs - this.lastPaintedMs) < this.deps.cacheSkipNearMs)
31631
+ const { timeline } = this.deps.cursor.track;
31632
+ const exactTarget = hit.quality === "exact" &&
31633
+ timeline.indexOfDecoded(hit.timestampS) ===
31634
+ timeline.indexAtOrBefore(timestampMs / 1000);
31635
+ if (!exactTarget &&
31636
+ Math.abs(hitMs - this.lastPaintedMs) < this.deps.cacheSkipNearMs)
31444
31637
  return false;
31445
31638
  // And it has to be an improvement on what is showing, measured from
31446
31639
  // where the user is pointing. The check above measures only distance
@@ -31456,10 +31649,30 @@ ${VERTEX_STAGE}
31456
31649
  return false;
31457
31650
  }
31458
31651
  }
31652
+ return this.paintCached(hit);
31653
+ }
31654
+ /** A new commit needs its own presentation even when its exact pixels were
31655
+ * already shown by the preceding scrub. Approximate neighbors cannot answer it. */
31656
+ presentExactFromCache(timestampMs) {
31657
+ if (!this.sink)
31658
+ return false;
31659
+ const hit = this.deps.cursor.peekCached(timestampMs);
31660
+ const { timeline } = this.deps.cursor.track;
31661
+ if (!hit ||
31662
+ hit.quality !== "exact" ||
31663
+ timeline.indexOfDecoded(hit.timestampS) !==
31664
+ timeline.indexAtOrBefore(timestampMs / 1000)) {
31665
+ return false;
31666
+ }
31667
+ return this.paintCached(hit);
31668
+ }
31669
+ paintCached(hit) {
31670
+ if (!this.sink)
31671
+ return false;
31459
31672
  // A cache hit is always a canvas blit, never a live sample, so there is
31460
31673
  // no close obligation here.
31461
31674
  const presented = this.sink.present(hit);
31462
- this.lastPaintedMs = hitMs;
31675
+ this.lastPaintedMs = Math.round(hit.timestampS * 1000);
31463
31676
  this.lastPaintedQuality = hit.quality;
31464
31677
  // This is a full frame on the display canvas and the user sees the
31465
31678
  // picture change, so it is a paint. Reporting only decoded frames left
@@ -32340,6 +32553,45 @@ ${VERTEX_STAGE}
32340
32553
  return residency;
32341
32554
  }
32342
32555
 
32556
+ function createPausableDeadline(timeoutMs, onExpire) {
32557
+ let cancelled = false;
32558
+ let remainingMs = timeoutMs;
32559
+ let startedAtMs = performance.now();
32560
+ let timer = null;
32561
+ const expire = () => {
32562
+ if (cancelled)
32563
+ return;
32564
+ cancelled = true;
32565
+ timer = null;
32566
+ onExpire();
32567
+ };
32568
+ const arm = () => {
32569
+ if (cancelled || timer !== null)
32570
+ return;
32571
+ startedAtMs = performance.now();
32572
+ timer = setTimeout(expire, remainingMs);
32573
+ };
32574
+ arm();
32575
+ return {
32576
+ cancel() {
32577
+ if (cancelled)
32578
+ return;
32579
+ cancelled = true;
32580
+ if (timer !== null)
32581
+ clearTimeout(timer);
32582
+ timer = null;
32583
+ },
32584
+ pause() {
32585
+ if (cancelled || timer === null)
32586
+ return;
32587
+ remainingMs = Math.max(0, remainingMs - (performance.now() - startedAtMs));
32588
+ clearTimeout(timer);
32589
+ timer = null;
32590
+ },
32591
+ resume: arm,
32592
+ };
32593
+ }
32594
+
32343
32595
  function serializeEngineError(error) {
32344
32596
  return { code: error.code, message: error.message };
32345
32597
  }
@@ -32359,6 +32611,8 @@ ${VERTEX_STAGE}
32359
32611
  * the core never emits a competing time that would yank a settled playhead.
32360
32612
  */
32361
32613
  class EngineCore {
32614
+ decodeStrategy = nativeResolution();
32615
+ displayResize = null;
32362
32616
  emit;
32363
32617
  emitDiagnostics;
32364
32618
  emitPresentedFrame;
@@ -32404,6 +32658,7 @@ ${VERTEX_STAGE}
32404
32658
  * flight and publishes itself as the playhead. */
32405
32659
  awaitedSeek = null;
32406
32660
  seekGeneration = 0;
32661
+ stepCommandGeneration = 0;
32407
32662
  /** Wall stamp of a seek being served by re-anchoring the playback walk, or
32408
32663
  * null when no such seek is waiting on its crisp frame. */
32409
32664
  playSeekStartedAtMs = null;
@@ -32417,6 +32672,8 @@ ${VERTEX_STAGE}
32417
32672
  lastHostFrame = null;
32418
32673
  /** Rejects an older refresh that completes after a newer one in one epoch. */
32419
32674
  lastHostPaintSeq = 0;
32675
+ /** Main-thread visibility pauses only the wait for host presentation. */
32676
+ presentationVisible = true;
32420
32677
  /** Mirrors WebVideoEngine's interactive-seek latch: true when a drag paused a
32421
32678
  * playing engine, so endInteractiveSeek knows to resume. */
32422
32679
  resumeAfterInteractiveSeek = false;
@@ -32457,6 +32714,7 @@ ${VERTEX_STAGE}
32457
32714
  this.abandonAwaitedSeek();
32458
32715
  }
32459
32716
  async load(config) {
32717
+ this.decodeStrategy = config.decodeStrategy ?? nativeResolution();
32460
32718
  this.beginPresentationGeneration();
32461
32719
  this.presentation = config.presentation ?? "canvas";
32462
32720
  this.displayBox =
@@ -32541,11 +32799,164 @@ ${VERTEX_STAGE}
32541
32799
  this.residency?.startWarming();
32542
32800
  return this.metadata;
32543
32801
  }
32802
+ setDisplay(display) {
32803
+ const cursor = this.cursor;
32804
+ if (this.presentation !== "frames" ||
32805
+ this.decodeStrategy.kind !== "displayBox" ||
32806
+ !cursor?.resizeOutput) {
32807
+ return Promise.reject(new WebVideoEngineError(WebVideoEngineErrorCode.PresentationMismatch, "display resizing requires frames presentation with a displayBox strategy"));
32808
+ }
32809
+ const resolved = resolveDecodeDimensions(displayBoxResolution(display), {
32810
+ nativeWidth: cursor.track.width,
32811
+ nativeHeight: cursor.track.height,
32812
+ displayWidth: null,
32813
+ devicePixelRatio: display.devicePixelRatio,
32814
+ });
32815
+ if (!this.displayResize &&
32816
+ resolved.width === cursor.track.decodeWidth &&
32817
+ resolved.height === cursor.track.decodeHeight) {
32818
+ this.displayBox = {
32819
+ cssWidth: display.boxWidth,
32820
+ cssHeight: display.boxHeight,
32821
+ devicePixelRatio: display.devicePixelRatio,
32822
+ };
32823
+ return Promise.resolve(false);
32824
+ }
32825
+ let state = this.displayResize;
32826
+ if (!state) {
32827
+ let cancel = () => { };
32828
+ const aborted = new Promise((resolve) => {
32829
+ cancel = resolve;
32830
+ });
32831
+ state = {
32832
+ tail: Promise.resolve(false),
32833
+ request: 0,
32834
+ navigation: 0,
32835
+ pendingNavigation: false,
32836
+ held: false,
32837
+ cancelled: false,
32838
+ cancel,
32839
+ aborted,
32840
+ };
32841
+ this.displayResize = state;
32842
+ }
32843
+ const active = state;
32844
+ const request = ++active.request;
32845
+ const previous = active.tail;
32846
+ if (active.held)
32847
+ this.abandonAwaitedSeek();
32848
+ const run = (async () => {
32849
+ await previous.catch((error) => {
32850
+ if (active.cancelled || !isResizeSuperseded(error))
32851
+ throw error;
32852
+ });
32853
+ if (active.cancelled)
32854
+ throw displayResizeAborted();
32855
+ if (request !== active.request || active.pendingNavigation)
32856
+ throw displayResizeSuperseded();
32857
+ const dimensions = resolved;
32858
+ this.displayBox = {
32859
+ cssWidth: display.boxWidth,
32860
+ cssHeight: display.boxHeight,
32861
+ devicePixelRatio: display.devicePixelRatio,
32862
+ };
32863
+ if (dimensions.width === cursor.track.decodeWidth &&
32864
+ dimensions.height === cursor.track.decodeHeight)
32865
+ return false;
32866
+ const target = this.lastHostFrame ??
32867
+ this.timeline().landingAt(this.timeline().indexAtOrBefore(this.clock.now()));
32868
+ this.clock.pause();
32869
+ this.clock.seek(target.mediaTimeS);
32870
+ this.controller?.holdOutput();
32871
+ active.held = true;
32872
+ this.abandonAwaitedSeek();
32873
+ this.beginPresentationGeneration();
32874
+ const untilDisposed = (work) => Promise.race([
32875
+ work,
32876
+ active.aborted.then(() => {
32877
+ throw displayResizeAborted();
32878
+ }),
32879
+ ]);
32880
+ await untilDisposed(cursor.resizeOutput(dimensions));
32881
+ if (request !== active.request || active.pendingNavigation)
32882
+ throw displayResizeSuperseded();
32883
+ const wait = this.awaitSeekLanding(target.frame, false, true);
32884
+ void wait.presented.catch(() => undefined);
32885
+ const decoded = await untilDisposed(cursor.seekToFrame(target.frame));
32886
+ if (request !== active.request || active.pendingNavigation) {
32887
+ this.controller?.holdOutput();
32888
+ this.abandonAwaitedSeek();
32889
+ throw displayResizeSuperseded();
32890
+ }
32891
+ if (!decoded)
32892
+ throw new WebVideoEngineError(WebVideoEngineErrorCode.DecoderStalled, "resized frame could not be decoded");
32893
+ this.controller?.releaseOutput();
32894
+ await untilDisposed(wait.presented);
32895
+ if (request !== active.request || active.pendingNavigation)
32896
+ throw displayResizeSuperseded();
32897
+ return true;
32898
+ })();
32899
+ const settled = run
32900
+ .catch((error) => {
32901
+ if (active.held) {
32902
+ this.abandonAwaitedSeek();
32903
+ this.controller?.holdOutput();
32904
+ }
32905
+ if (!isResizeSuperseded(error)) {
32906
+ this.playing = false;
32907
+ if (!active.cancelled)
32908
+ this.emitStatus(PlaybackStatus.Paused);
32909
+ }
32910
+ throw error;
32911
+ })
32912
+ .finally(() => {
32913
+ if (active.tail !== settled)
32914
+ return;
32915
+ this.displayResize = null;
32916
+ this.controller?.releaseOutput();
32917
+ if ((active.held || !this.clock.playing) &&
32918
+ !active.cancelled &&
32919
+ !active.pendingNavigation &&
32920
+ this.playing) {
32921
+ this.clock.play();
32922
+ this.controller?.beginPlay(this.clock.now());
32923
+ this.emitStatus(PlaybackStatus.Playing);
32924
+ }
32925
+ });
32926
+ active.tail = settled;
32927
+ return settled;
32928
+ }
32929
+ async afterDisplayResize(run, superseded) {
32930
+ const state = this.displayResize;
32931
+ const navigation = ++state.navigation;
32932
+ state.pendingNavigation = true;
32933
+ state.held = true;
32934
+ this.controller?.holdOutput();
32935
+ this.abandonAwaitedSeek();
32936
+ this.beginPresentationGeneration();
32937
+ while (this.displayResize === state)
32938
+ await state.tail.catch((error) => {
32939
+ if (state.cancelled || !isResizeSuperseded(error))
32940
+ throw error;
32941
+ });
32942
+ if (state.cancelled)
32943
+ throw displayResizeAborted();
32944
+ if (navigation !== state.navigation)
32945
+ return superseded;
32946
+ if (this.playing)
32947
+ this.clock.play();
32948
+ return run();
32949
+ }
32544
32950
  play() {
32545
32951
  if (!this.cursor || !this.controller)
32546
32952
  return;
32547
32953
  if (this.decodeFailure)
32548
32954
  return this.republishFailure();
32955
+ if (this.displayResize) {
32956
+ this.playing = true;
32957
+ return;
32958
+ }
32959
+ this.stepCommandGeneration += 1;
32549
32960
  // Resume-from-end: a play after the clock crossed duration would tick
32550
32961
  // straight to Ended. Snap back to 0 first.
32551
32962
  const durS = this.durationMs / 1000;
@@ -32593,8 +33004,9 @@ ${VERTEX_STAGE}
32593
33004
  });
32594
33005
  }
32595
33006
  }
33007
+ this.stepCommandGeneration += 1;
32596
33008
  this.controller?.endPlay();
32597
- if (wasPlaying)
33009
+ if (wasPlaying && !this.displayResize)
32598
33010
  this.abandonAwaitedSeek();
32599
33011
  if (this.decodeFailure)
32600
33012
  return this.republishFailure();
@@ -32635,6 +33047,17 @@ ${VERTEX_STAGE}
32635
33047
  * thread snapped the gesture and wrote it. Paints a cache preview, then
32636
33048
  * walks the cursor. */
32637
33049
  scrub(frameIndex, intent = "gesture") {
33050
+ if (this.displayResize) {
33051
+ void this.afterDisplayResize(() => this.scrub(frameIndex, intent), undefined).catch((error) => {
33052
+ if (error instanceof WebVideoEngineError &&
33053
+ error.code === WebVideoEngineErrorCode.Aborted)
33054
+ return;
33055
+ this.handleTerminalFailure(error instanceof WebVideoEngineError
33056
+ ? error
33057
+ : new WebVideoEngineError(WebVideoEngineErrorCode.BackendCrashed, "display resize failed", error));
33058
+ });
33059
+ return;
33060
+ }
32638
33061
  if (!this.cursor)
32639
33062
  return;
32640
33063
  const target = this.timeline().landingAt(frameIndex);
@@ -32667,7 +33090,6 @@ ${VERTEX_STAGE}
32667
33090
  // crisp frame up, and then the wait is over before the walk restarts.
32668
33091
  if (!this.awaitedSeek)
32669
33092
  this.closePlaySeekWait();
32670
- this.controller?.endPlay();
32671
33093
  this.controller?.beginPlay(tSec);
32672
33094
  }
32673
33095
  /** Closes the wait of a seek served by re-anchoring, at the crisp paint that
@@ -32689,6 +33111,8 @@ ${VERTEX_STAGE}
32689
33111
  this.playSeekMaxMs = waitMs;
32690
33112
  }
32691
33113
  async commit(frameIndex) {
33114
+ if (this.displayResize)
33115
+ return this.afterDisplayResize(() => this.commit(frameIndex), null);
32692
33116
  if (!this.cursor)
32693
33117
  return null;
32694
33118
  if (this.decodeFailure)
@@ -32698,7 +33122,9 @@ ${VERTEX_STAGE}
32698
33122
  this.lastStepLanded = null;
32699
33123
  const wait = this.awaitSeekLanding(target.frame, false, true);
32700
33124
  this.clock.seek(tSec);
32701
- this.controller?.tryPaintFromCache(tSec * 1000);
33125
+ if (!this.controller?.presentExactFromCache(tSec * 1000)) {
33126
+ this.controller?.tryPaintFromCache(tSec * 1000);
33127
+ }
32702
33128
  // While playing, moving the playhead means re-anchoring the playback
32703
33129
  // walk, exactly as scrub does. Seeking the cursor instead leaves the walk
32704
33130
  // where it was, so playback carries on from the old position and the seek
@@ -32746,6 +33172,8 @@ ${VERTEX_STAGE}
32746
33172
  return landed;
32747
33173
  }
32748
33174
  async seekToKey(timeMs) {
33175
+ if (this.displayResize)
33176
+ return this.afterDisplayResize(() => this.seekToKey(timeMs), null);
32749
33177
  if (!this.cursor)
32750
33178
  return null;
32751
33179
  if (this.decodeFailure)
@@ -32809,6 +33237,14 @@ ${VERTEX_STAGE}
32809
33237
  * other.
32810
33238
  */
32811
33239
  async step(direction) {
33240
+ if (this.displayResize) {
33241
+ return this.afterDisplayResize(async () => {
33242
+ const landed = await this.step(direction);
33243
+ if (this.playing)
33244
+ this.controller?.beginPlay(this.clock.now());
33245
+ return landed;
33246
+ }, null);
33247
+ }
32812
33248
  if (!this.cursor || !this.controller)
32813
33249
  return null;
32814
33250
  const timeline = this.timeline();
@@ -32822,9 +33258,14 @@ ${VERTEX_STAGE}
32822
33258
  const target = timeline.landingAt(base.index + direction);
32823
33259
  if (target.frame.index === base.index)
32824
33260
  return null;
32825
- this.beginPresentationGeneration();
32826
- const next = await this.cursor.seekToFrame(target.frame);
32827
- if (!next)
33261
+ this.abandonAwaitedSeek();
33262
+ const commandGeneration = ++this.stepCommandGeneration;
33263
+ const generation = this.beginPresentationGeneration();
33264
+ const next = await this.cursor.seekToFrame(target.frame, () => this.isCurrentSeek(generation) &&
33265
+ commandGeneration === this.stepCommandGeneration);
33266
+ if (!next ||
33267
+ !this.isCurrentSeek(generation) ||
33268
+ commandGeneration !== this.stepCommandGeneration)
32828
33269
  return null;
32829
33270
  this.lastStepLanded = target.frame;
32830
33271
  this.clock.seek(target.mediaTimeS);
@@ -32887,6 +33328,10 @@ ${VERTEX_STAGE}
32887
33328
  this.play();
32888
33329
  }
32889
33330
  async dispose() {
33331
+ if (this.displayResize) {
33332
+ this.displayResize.cancelled = true;
33333
+ this.displayResize.cancel();
33334
+ }
32890
33335
  this.playing = false;
32891
33336
  this.diagnosticsStop();
32892
33337
  // A capture still running is evidence someone asked for; keep it, marked
@@ -32957,7 +33402,7 @@ ${VERTEX_STAGE}
32957
33402
  return { generation, presented: Promise.resolve(null) };
32958
33403
  }
32959
33404
  const presented = new Promise((resolve, reject) => {
32960
- const presentationTimer = setTimeout(() => {
33405
+ const presentationDeadline = createPausableDeadline(HANG_RECOVERY.PRESENTATION_LATCH_TIMEOUT_MS, () => {
32961
33406
  const awaited = this.awaitedSeek;
32962
33407
  if (!awaited ||
32963
33408
  awaited.generation !== generation ||
@@ -32970,20 +33415,33 @@ ${VERTEX_STAGE}
32970
33415
  awaited.reject(error);
32971
33416
  else
32972
33417
  awaited.resolve(null);
32973
- }, HANG_RECOVERY.PRESENTATION_LATCH_TIMEOUT_MS);
33418
+ });
32974
33419
  const awaited = {
32975
33420
  generation,
32976
33421
  keyOnly,
32977
- presentationTimer,
33422
+ presentationDeadline,
32978
33423
  reject: (error) => reject(error),
32979
33424
  rejectOnFailure,
32980
33425
  resolve,
32981
33426
  target,
32982
33427
  };
32983
33428
  this.awaitedSeek = awaited;
33429
+ if (!this.presentationVisible)
33430
+ presentationDeadline.pause();
32984
33431
  });
32985
33432
  return { generation, presented };
32986
33433
  }
33434
+ /** Main-thread document visibility; decoding/watchdogs remain active. */
33435
+ setPresentationVisibility(visible) {
33436
+ this.presentationVisible = visible;
33437
+ const awaited = this.awaitedSeek;
33438
+ if (!awaited)
33439
+ return;
33440
+ if (visible)
33441
+ awaited.presentationDeadline.resume();
33442
+ else
33443
+ awaited.presentationDeadline.pause();
33444
+ }
32987
33445
  isCurrentSeek(generation) {
32988
33446
  return generation === this.seekGeneration;
32989
33447
  }
@@ -32997,7 +33455,7 @@ ${VERTEX_STAGE}
32997
33455
  if (!awaited)
32998
33456
  return;
32999
33457
  this.awaitedSeek = null;
33000
- clearTimeout(awaited.presentationTimer);
33458
+ awaited.presentationDeadline.cancel();
33001
33459
  awaited.resolve(null);
33002
33460
  }
33003
33461
  /** Ends an impossible landing. Awaitable movement rejects with the engine's
@@ -33007,7 +33465,7 @@ ${VERTEX_STAGE}
33007
33465
  if (!awaited)
33008
33466
  return;
33009
33467
  this.awaitedSeek = null;
33010
- clearTimeout(awaited.presentationTimer);
33468
+ awaited.presentationDeadline.cancel();
33011
33469
  if (awaited.rejectOnFailure)
33012
33470
  awaited.reject(error);
33013
33471
  else
@@ -33104,7 +33562,7 @@ ${VERTEX_STAGE}
33104
33562
  if (answeredSeek && this.awaitedSeek === answeredSeek) {
33105
33563
  this.seekLanded = frameId;
33106
33564
  this.awaitedSeek = null;
33107
- clearTimeout(answeredSeek.presentationTimer);
33565
+ answeredSeek.presentationDeadline.cancel();
33108
33566
  this.closePlaySeekWait();
33109
33567
  // Paused paints normally never move the playhead, but the landing
33110
33568
  // of an awaited seek is the one paint that says where the request
@@ -33486,6 +33944,17 @@ ${VERTEX_STAGE}
33486
33944
  const wanted = Math.round((windowMs / 1000) * DIAGNOSTICS.BROADCAST_HZ);
33487
33945
  return Math.min(DIAGNOSTICS.TRACE_SNAPSHOT_CAP, Math.max(1, wanted));
33488
33946
  }
33947
+ function displayResizeAborted() {
33948
+ return new WebVideoEngineError(WebVideoEngineErrorCode.Aborted, "display resize aborted by disposal");
33949
+ }
33950
+ function displayResizeSuperseded() {
33951
+ return new WebVideoEngineError(WebVideoEngineErrorCode.Aborted, "display resize superseded");
33952
+ }
33953
+ function isResizeSuperseded(error) {
33954
+ return (error instanceof WebVideoEngineError &&
33955
+ error.code === WebVideoEngineErrorCode.Aborted &&
33956
+ error.message === "display resize superseded");
33957
+ }
33489
33958
 
33490
33959
  function toSerializedError(error) {
33491
33960
  if (error instanceof WebVideoEngineError)
@@ -33511,6 +33980,9 @@ ${VERTEX_STAGE}
33511
33980
  case "unbindCanvas":
33512
33981
  engine.setCanvas(null);
33513
33982
  return;
33983
+ case "setPresentationVisibility":
33984
+ engine.setPresentationVisibility(command.visible);
33985
+ return;
33514
33986
  case "scrub":
33515
33987
  engine.scrub(command.frameIndex, command.intent);
33516
33988
  return;
@@ -33552,6 +34024,11 @@ ${VERTEX_STAGE}
33552
34024
  engine.play();
33553
34025
  return { type: "ack", requestId: command.requestId };
33554
34026
  });
34027
+ case "setDisplay":
34028
+ return settle(post, command.requestId, async () => {
34029
+ const outputChanged = await engine.setDisplay(command.display);
34030
+ return { type: "ack", requestId: command.requestId, outputChanged };
34031
+ });
33555
34032
  case "commit":
33556
34033
  return settle(post, command.requestId, async () => {
33557
34034
  const landing = await engine.commit(command.frameIndex);