supervision 0.2.0-next.1 → 0.2.0-next.2

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 (63) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +508 -127
  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 +6 -0
  9. package/dist/media/media-source.d.ts.map +1 -1
  10. package/dist/media/video-engine-media-source.d.ts.map +1 -1
  11. package/dist/media/video-engine-media-source.js +1 -1
  12. package/dist/render-preparation/prepared-window-timeline.d.ts +1 -0
  13. package/dist/render-preparation/prepared-window-timeline.d.ts.map +1 -1
  14. package/dist/renderers/media-frame-navigation.d.ts +17 -0
  15. package/dist/renderers/media-frame-navigation.d.ts.map +1 -0
  16. package/dist/renderers/media-renderer-core.d.ts.map +1 -1
  17. package/dist/renderers/media-renderer-scene.d.ts +1 -0
  18. package/dist/renderers/media-renderer-scene.d.ts.map +1 -1
  19. package/dist/renderers/media-renderer-transport.d.ts +4 -1
  20. package/dist/renderers/media-renderer-transport.d.ts.map +1 -1
  21. package/dist/renderers/pixi-media-scene.d.ts.map +1 -1
  22. package/dist/renderers/prepared-annotation-window.d.ts.map +1 -1
  23. package/dist/renderers/presented-frame-channel.d.ts +8 -6
  24. package/dist/renderers/presented-frame-channel.d.ts.map +1 -1
  25. package/dist/sessions/media-session-defaults.d.ts.map +1 -1
  26. package/dist/sessions/media-session-detections.d.ts.map +1 -1
  27. package/dist/sessions/media-session.d.ts.map +1 -1
  28. package/dist/tracking.worker.js.map +1 -1
  29. package/dist/types/media-frame-clock.d.ts +16 -0
  30. package/dist/types/media-frame-clock.d.ts.map +1 -0
  31. package/dist/types/media-frame-navigation.d.ts +28 -0
  32. package/dist/types/media-frame-navigation.d.ts.map +1 -0
  33. package/dist/types/media-renderer-display.d.ts +12 -0
  34. package/dist/types/media-renderer-display.d.ts.map +1 -0
  35. package/dist/types/media-renderer.d.ts +13 -0
  36. package/dist/types/media-renderer.d.ts.map +1 -1
  37. package/dist/types/media-session.d.ts +15 -5
  38. package/dist/types/media-session.d.ts.map +1 -1
  39. package/dist/types/render-preparation.d.ts +8 -5
  40. package/dist/types/render-preparation.d.ts.map +1 -1
  41. package/dist/{video-engine-media-source-CUXnOJOV.js → video-engine-media-source-DR-x1UfK.js} +35 -2
  42. package/dist/video-engine-media-source-DR-x1UfK.js.map +1 -0
  43. package/dist/web-video-engine/analysis.js +16 -0
  44. package/dist/web-video-engine/canvas-sink-scrub-cursor.d.ts +6 -1
  45. package/dist/web-video-engine/decode-scheduler.d.ts +8 -2
  46. package/dist/web-video-engine/decode-session.d.ts +5 -2
  47. package/dist/web-video-engine/decode-source.d.ts +2 -0
  48. package/dist/web-video-engine/engine-core.d.ts +9 -0
  49. package/dist/web-video-engine/engine.js +103 -11
  50. package/dist/web-video-engine/engine.worker.js +492 -31
  51. package/dist/web-video-engine/frame-cache.d.ts +2 -0
  52. package/dist/web-video-engine/pausable-deadline.d.ts +11 -0
  53. package/dist/web-video-engine/scrub-controller.d.ts +8 -4
  54. package/dist/web-video-engine/scrub-cursor.d.ts +3 -0
  55. package/dist/web-video-engine/video-engine.d.ts +12 -1
  56. package/dist/web-video-engine/worker-protocol.d.ts +9 -1
  57. package/node_modules/supervision-js-core/dist/detections/buffered-detection-timeline.d.ts.map +1 -1
  58. package/node_modules/supervision-js-core/dist/index.js +77 -41
  59. package/node_modules/supervision-js-core/dist/index.js.map +1 -1
  60. package/node_modules/supervision-js-core/dist/types/detection-timeline.d.ts +8 -3
  61. package/node_modules/supervision-js-core/dist/types/detection-timeline.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. 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
@@ -27697,9 +27773,13 @@
27697
27773
  async seekToFrame(frame) {
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
+ const outputGeneration = this.outputGeneration;
27777
+ const decoded = await this.trackOutputRead(this.provider.getFrame(this.trackInfo.timeline.timeAt(frame.index)));
27778
+ if (this.closed || outputGeneration !== this.outputGeneration || !decoded) {
27779
+ if (decoded?.kind === "sample")
27780
+ decoded.sample.close();
27702
27781
  return null;
27782
+ }
27703
27783
  this.emitFrame(decoded);
27704
27784
  return this.lastEmittedFrame;
27705
27785
  }
@@ -27727,11 +27807,13 @@
27727
27807
  attachPlay(startS) {
27728
27808
  if (this.closed)
27729
27809
  return;
27730
- void this.iterator?.return();
27810
+ if (this.iterator)
27811
+ void this.trackOutputRead(this.iterator.return());
27731
27812
  this.iterator = this.provider.frames(startS);
27732
27813
  }
27733
27814
  detachPlay() {
27734
- void this.iterator?.return();
27815
+ if (this.iterator)
27816
+ void this.trackOutputRead(this.iterator.return());
27735
27817
  this.iterator = null;
27736
27818
  }
27737
27819
  next() {
@@ -27776,6 +27858,7 @@
27776
27858
  }
27777
27859
  async close() {
27778
27860
  this.closed = true;
27861
+ this.outputReads.clear();
27779
27862
  this.pendingSeekTargetS = null;
27780
27863
  this.currentState = ScrubCursorState.Closed;
27781
27864
  this.listeners.clear();
@@ -27809,11 +27892,14 @@
27809
27892
  async runSeek(t, keyOnly) {
27810
27893
  if (this.closed)
27811
27894
  return;
27895
+ const outputGeneration = this.outputGeneration;
27812
27896
  this.currentState = ScrubCursorState.Seeking;
27813
- const frame = await this.provider.getFrame(t);
27814
- if (this.closed)
27815
- return;
27816
- if (frame)
27897
+ const frame = await this.trackOutputRead(this.provider.getFrame(t));
27898
+ if (this.closed || outputGeneration !== this.outputGeneration) {
27899
+ if (frame?.kind === "sample")
27900
+ frame.sample.close();
27901
+ }
27902
+ else if (frame)
27817
27903
  this.emitFrame(frame, keyOnly);
27818
27904
  if (!this.closed)
27819
27905
  this.currentState = ScrubCursorState.Idle;
@@ -27826,7 +27912,7 @@
27826
27912
  // otherwise we would race the iterator state.
27827
27913
  if (this.seekDraining)
27828
27914
  return;
27829
- const next = (await iterator.next()).value ?? null;
27915
+ const next = (await this.trackOutputRead(iterator.next())).value ?? null;
27830
27916
  if (this.closed || iterator !== this.iterator || !next) {
27831
27917
  if (next?.kind === "sample")
27832
27918
  next.sample.close();
@@ -27910,6 +27996,7 @@
27910
27996
  FrameTier["Preview"] = "preview";
27911
27997
  })(FrameTier || (FrameTier = {}));
27912
27998
  class FrameCache {
27999
+ options;
27913
28000
  exact;
27914
28001
  preview;
27915
28002
  exactHits = 0;
@@ -27918,6 +28005,7 @@
27918
28005
  bucketMs;
27919
28006
  exactBudgetBytes;
27920
28007
  constructor(options) {
28008
+ this.options = options;
27921
28009
  const exactWidth = Math.max(1, Math.round(options.exactWidth));
27922
28010
  const exactHeight = Math.max(1, Math.round(options.exactHeight));
27923
28011
  const aspect = exactWidth / exactHeight;
@@ -27937,6 +28025,13 @@
27937
28025
  this.exact = new TierStore(exactCapacity, exactWidth, exactHeight, FRAME_IDENTITY_KEY_GRID_MS);
27938
28026
  this.preview = new TierStore(Math.max(0, Math.floor(options.previewCapacity)), previewWidth, previewHeight, PREVIEW_KEY_GRID_MS);
27939
28027
  }
28028
+ resized(width, height) {
28029
+ return new FrameCache({
28030
+ ...this.options,
28031
+ exactWidth: width,
28032
+ exactHeight: height,
28033
+ });
28034
+ }
27940
28035
  /**
27941
28036
  * Store a crisp full-resolution frame for the exact (scrub) tier under
27942
28037
  * `frame`, the timeline's name for it. `timestampMs` is the decoded
@@ -28663,6 +28758,9 @@
28663
28758
  cache;
28664
28759
  keyframeIndex;
28665
28760
  trackInfo;
28761
+ outputGeneration = 0;
28762
+ resizingOutput = false;
28763
+ outputReads = new Set();
28666
28764
  exactTolMs;
28667
28765
  previewTolMs;
28668
28766
  now;
@@ -28777,6 +28875,47 @@
28777
28875
  this.pendingSeekTargetS === null &&
28778
28876
  !this.seekDraining);
28779
28877
  }
28878
+ async resizeOutput({ width, height }) {
28879
+ if (!this.provider.resizeOutput) {
28880
+ throw new WebVideoEngineError(WebVideoEngineErrorCode.PresentationMismatch, "this decode source cannot resize frame output in place");
28881
+ }
28882
+ if (this.closed ||
28883
+ (width === this.trackInfo.decodeWidth &&
28884
+ height === this.trackInfo.decodeHeight))
28885
+ return false;
28886
+ this.outputGeneration++;
28887
+ this.resizingOutput = true;
28888
+ this.pendingSeekTargetS = null;
28889
+ const returning = this.iterator?.return();
28890
+ this.iterator = null;
28891
+ this.nextPending = 0;
28892
+ this.abandonPrefetch();
28893
+ this.lastEmittedFrame = null;
28894
+ try {
28895
+ await this.prefetchTask?.catch(() => undefined);
28896
+ await Promise.allSettled([
28897
+ ...this.outputReads,
28898
+ ...(returning ? [returning] : []),
28899
+ ]);
28900
+ if (this.closed)
28901
+ return false;
28902
+ await this.provider.resizeOutput({ width, height });
28903
+ if (this.closed)
28904
+ return false;
28905
+ this.trackInfo = {
28906
+ ...this.trackInfo,
28907
+ decodeWidth: width,
28908
+ decodeHeight: height,
28909
+ };
28910
+ const oldCache = this.cache;
28911
+ this.cache = oldCache.resized(width, height);
28912
+ oldCache.clear();
28913
+ return true;
28914
+ }
28915
+ finally {
28916
+ this.resizingOutput = false;
28917
+ }
28918
+ }
28780
28919
  async open() {
28781
28920
  // Seed at the track's real first timestamp, not a hardcoded 0: a trimmed
28782
28921
  // or offset clip's first frame is not at the origin, so getFrame(0) would
@@ -28996,7 +29135,9 @@
28996
29135
  this.enterMode(AccessMode.Playing);
28997
29136
  // Signal any running sweep to bail; the first pull awaits its teardown.
28998
29137
  this.abandonPrefetch();
28999
- void this.iterator?.return();
29138
+ if (this.iterator) {
29139
+ void this.trackOutputRead(this.iterator.return());
29140
+ }
29000
29141
  this.iterator = this.provider.frames(startS);
29001
29142
  // Reset the in-flight latch for the new session. An old-iterator pull may
29002
29143
  // still be settling; its finally is iterator-identity-aware (see next()),
@@ -29008,7 +29149,9 @@
29008
29149
  this.nextInFlight = Promise.resolve();
29009
29150
  }
29010
29151
  detachPlay() {
29011
- void this.iterator?.return();
29152
+ if (this.iterator) {
29153
+ void this.trackOutputRead(this.iterator.return());
29154
+ }
29012
29155
  this.iterator = null;
29013
29156
  // A pull in flight at detach never decrements, since its finally only
29014
29157
  // fires for the iterator it was issued against. Left stranded, the count
@@ -29056,8 +29199,9 @@
29056
29199
  return null;
29057
29200
  this.enterMode(AccessMode.Stepping);
29058
29201
  this.abandonPrefetch();
29202
+ const outputGeneration = this.outputGeneration;
29059
29203
  const decoded = await this.withDecodeWatchdog(this.provider.getFrame(this.trackInfo.timeline.timeAt(frame.index)));
29060
- if (this.closed || !decoded) {
29204
+ if (this.closed || outputGeneration !== this.outputGeneration || !decoded) {
29061
29205
  if (decoded?.kind === "sample")
29062
29206
  decoded.sample.close();
29063
29207
  return null;
@@ -29090,6 +29234,7 @@
29090
29234
  }
29091
29235
  async close() {
29092
29236
  this.closed = true;
29237
+ this.outputReads.clear();
29093
29238
  this.abandonPrefetch();
29094
29239
  this.pendingSeekTargetS = null;
29095
29240
  this.currentState = ScrubCursorState.Closed;
@@ -29168,6 +29313,7 @@
29168
29313
  }
29169
29314
  }
29170
29315
  async runExactSeek(t) {
29316
+ const outputGeneration = this.outputGeneration;
29171
29317
  this.exactSeeks += 1;
29172
29318
  const ms = t * 1000;
29173
29319
  const cached = this.cache.getForFrame(this.frameAtTime(t), ms, this.previewTolMs);
@@ -29188,7 +29334,7 @@
29188
29334
  const previewPaintedAt = painted && cached.tier === FrameTier.Preview ? this.now() : null;
29189
29335
  const startedAt = this.now();
29190
29336
  const frame = await this.withDecodeWatchdog(this.provider.getFrame(t));
29191
- if (this.closed || !frame) {
29337
+ if (this.closed || outputGeneration !== this.outputGeneration || !frame) {
29192
29338
  if (frame?.kind === "sample")
29193
29339
  frame.sample.close();
29194
29340
  return;
@@ -29255,13 +29401,14 @@
29255
29401
  : null, targetMs);
29256
29402
  }
29257
29403
  async runKeySeek(t) {
29404
+ const outputGeneration = this.outputGeneration;
29258
29405
  this.keySeeks += 1;
29259
29406
  // getFrame already anchors on the enclosing keyframe, so re-anchoring the
29260
29407
  // request collapses every target within a GOP onto that one frame. A
29261
29408
  // source carrying a single keyframe answers its whole timeline with the
29262
29409
  // first frame.
29263
29410
  const frame = await this.withDecodeWatchdog(this.provider.getFrame(t));
29264
- if (this.closed || !frame) {
29411
+ if (this.closed || outputGeneration !== this.outputGeneration || !frame) {
29265
29412
  if (frame?.kind === "sample")
29266
29413
  frame.sample.close();
29267
29414
  return;
@@ -29319,7 +29466,12 @@
29319
29466
  * decoder having a bad moment from one that will never answer again.
29320
29467
  */
29321
29468
  withDecodeWatchdog(decode) {
29322
- return this.watchdogged(decode, true);
29469
+ return this.watchdogged(this.trackOutputRead(decode), true);
29470
+ }
29471
+ trackOutputRead(read) {
29472
+ this.outputReads.add(read);
29473
+ void read.then(() => this.outputReads.delete(read), () => this.outputReads.delete(read));
29474
+ return read;
29323
29475
  }
29324
29476
  /**
29325
29477
  * The same watchdog over a container metadata read. Deliberately unbooked:
@@ -29447,6 +29599,11 @@
29447
29599
  return;
29448
29600
  }
29449
29601
  this.provider = openFrameProvider(handle);
29602
+ this.outputReads.clear();
29603
+ await this.provider.resizeOutput?.({
29604
+ width: this.trackInfo.decodeWidth,
29605
+ height: this.trackInfo.decodeHeight,
29606
+ });
29450
29607
  // Disposing the old input invalidated the old probe, so the index
29451
29608
  // has to be rebuilt against the new one. It starts empty, and
29452
29609
  // nothing else re-walks it: the source's keyframes are exactly
@@ -29527,6 +29684,8 @@
29527
29684
  * is never used twice at once, then runs under a captured generation token.
29528
29685
  */
29529
29686
  schedulePrefetch(aroundS) {
29687
+ if (this.resizingOutput)
29688
+ return;
29530
29689
  if (this.mode === AccessMode.Playing)
29531
29690
  return;
29532
29691
  this.abandonPrefetch();
@@ -29639,7 +29798,7 @@
29639
29798
  }
29640
29799
  }
29641
29800
  finally {
29642
- void iter.return();
29801
+ void this.trackOutputRead(iter.return());
29643
29802
  }
29644
29803
  }
29645
29804
  /**
@@ -31373,6 +31532,17 @@ ${VERTEX_STAGE}
31373
31532
  * crosses the duration.
31374
31533
  */
31375
31534
  beginPlay(startS) {
31535
+ const { timeline } = this.deps.cursor.track;
31536
+ if (this.playSessionLive &&
31537
+ this.deps.clock.playing &&
31538
+ this.lastDeliveryS !== null &&
31539
+ !this.paintedSincePlay &&
31540
+ timeline.indexAtOrBefore(this.playAnchorS) ===
31541
+ timeline.indexAtOrBefore(startS)) {
31542
+ return;
31543
+ }
31544
+ if (this.lastDeliveryS !== null)
31545
+ this.endPlay();
31376
31546
  this.endedFired = false;
31377
31547
  this.playAnchorS = startS;
31378
31548
  // The realtime needles are per-session rates, not lifetime totals: a
@@ -31401,6 +31571,7 @@ ${VERTEX_STAGE}
31401
31571
  this.lastPresentTickS = null;
31402
31572
  this.resetCadenceTracking();
31403
31573
  this.clearPlayQueue();
31574
+ this.clearStash();
31404
31575
  this.deps.cursor.attachPlay(startS);
31405
31576
  this.deps.cursor.next();
31406
31577
  }
@@ -31420,6 +31591,17 @@ ${VERTEX_STAGE}
31420
31591
  this.resetCadenceTracking();
31421
31592
  this.clearPlayQueue();
31422
31593
  }
31594
+ holdOutput() {
31595
+ this.stop();
31596
+ this.clearStash();
31597
+ this.clearPlayQueue();
31598
+ this.deps.cursor.detachPlay();
31599
+ this.lastDeliveryS = null;
31600
+ }
31601
+ releaseOutput() {
31602
+ if (!this.disposed && this.sink)
31603
+ this.start();
31604
+ }
31423
31605
  /**
31424
31606
  * Cache lookup helper used by the engine on scrub. Asks the cursor for a
31425
31607
  * cached frame near the target and, if it is far enough from what the canvas
@@ -31427,10 +31609,8 @@ ${VERTEX_STAGE}
31427
31609
  * calls cursor.seekTo to land the full-res frame; the cursor's emit listener
31428
31610
  * stashes it for the next tick.
31429
31611
  *
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.
31612
+ * Nearby approximate hits respect cacheSkipNearMs. An exact hit for the
31613
+ * requested frame can advance immediately, without waiting for a decode.
31434
31614
  */
31435
31615
  tryPaintFromCache(timestampMs) {
31436
31616
  if (!this.sink)
@@ -31440,7 +31620,12 @@ ${VERTEX_STAGE}
31440
31620
  return false;
31441
31621
  const hitMs = Math.round(hit.timestampS * 1000);
31442
31622
  if (this.lastPaintedMs !== null) {
31443
- if (Math.abs(hitMs - this.lastPaintedMs) < this.deps.cacheSkipNearMs)
31623
+ const { timeline } = this.deps.cursor.track;
31624
+ const exactTarget = hit.quality === "exact" &&
31625
+ timeline.indexOfDecoded(hit.timestampS) ===
31626
+ timeline.indexAtOrBefore(timestampMs / 1000);
31627
+ if (!exactTarget &&
31628
+ Math.abs(hitMs - this.lastPaintedMs) < this.deps.cacheSkipNearMs)
31444
31629
  return false;
31445
31630
  // And it has to be an improvement on what is showing, measured from
31446
31631
  // where the user is pointing. The check above measures only distance
@@ -31456,10 +31641,30 @@ ${VERTEX_STAGE}
31456
31641
  return false;
31457
31642
  }
31458
31643
  }
31644
+ return this.paintCached(hit);
31645
+ }
31646
+ /** A new commit needs its own presentation even when its exact pixels were
31647
+ * already shown by the preceding scrub. Approximate neighbors cannot answer it. */
31648
+ presentExactFromCache(timestampMs) {
31649
+ if (!this.sink)
31650
+ return false;
31651
+ const hit = this.deps.cursor.peekCached(timestampMs);
31652
+ const { timeline } = this.deps.cursor.track;
31653
+ if (!hit ||
31654
+ hit.quality !== "exact" ||
31655
+ timeline.indexOfDecoded(hit.timestampS) !==
31656
+ timeline.indexAtOrBefore(timestampMs / 1000)) {
31657
+ return false;
31658
+ }
31659
+ return this.paintCached(hit);
31660
+ }
31661
+ paintCached(hit) {
31662
+ if (!this.sink)
31663
+ return false;
31459
31664
  // A cache hit is always a canvas blit, never a live sample, so there is
31460
31665
  // no close obligation here.
31461
31666
  const presented = this.sink.present(hit);
31462
- this.lastPaintedMs = hitMs;
31667
+ this.lastPaintedMs = Math.round(hit.timestampS * 1000);
31463
31668
  this.lastPaintedQuality = hit.quality;
31464
31669
  // This is a full frame on the display canvas and the user sees the
31465
31670
  // picture change, so it is a paint. Reporting only decoded frames left
@@ -32340,6 +32545,45 @@ ${VERTEX_STAGE}
32340
32545
  return residency;
32341
32546
  }
32342
32547
 
32548
+ function createPausableDeadline(timeoutMs, onExpire) {
32549
+ let cancelled = false;
32550
+ let remainingMs = timeoutMs;
32551
+ let startedAtMs = performance.now();
32552
+ let timer = null;
32553
+ const expire = () => {
32554
+ if (cancelled)
32555
+ return;
32556
+ cancelled = true;
32557
+ timer = null;
32558
+ onExpire();
32559
+ };
32560
+ const arm = () => {
32561
+ if (cancelled || timer !== null)
32562
+ return;
32563
+ startedAtMs = performance.now();
32564
+ timer = setTimeout(expire, remainingMs);
32565
+ };
32566
+ arm();
32567
+ return {
32568
+ cancel() {
32569
+ if (cancelled)
32570
+ return;
32571
+ cancelled = true;
32572
+ if (timer !== null)
32573
+ clearTimeout(timer);
32574
+ timer = null;
32575
+ },
32576
+ pause() {
32577
+ if (cancelled || timer === null)
32578
+ return;
32579
+ remainingMs = Math.max(0, remainingMs - (performance.now() - startedAtMs));
32580
+ clearTimeout(timer);
32581
+ timer = null;
32582
+ },
32583
+ resume: arm,
32584
+ };
32585
+ }
32586
+
32343
32587
  function serializeEngineError(error) {
32344
32588
  return { code: error.code, message: error.message };
32345
32589
  }
@@ -32359,6 +32603,8 @@ ${VERTEX_STAGE}
32359
32603
  * the core never emits a competing time that would yank a settled playhead.
32360
32604
  */
32361
32605
  class EngineCore {
32606
+ decodeStrategy = nativeResolution();
32607
+ displayResize = null;
32362
32608
  emit;
32363
32609
  emitDiagnostics;
32364
32610
  emitPresentedFrame;
@@ -32417,6 +32663,8 @@ ${VERTEX_STAGE}
32417
32663
  lastHostFrame = null;
32418
32664
  /** Rejects an older refresh that completes after a newer one in one epoch. */
32419
32665
  lastHostPaintSeq = 0;
32666
+ /** Main-thread visibility pauses only the wait for host presentation. */
32667
+ presentationVisible = true;
32420
32668
  /** Mirrors WebVideoEngine's interactive-seek latch: true when a drag paused a
32421
32669
  * playing engine, so endInteractiveSeek knows to resume. */
32422
32670
  resumeAfterInteractiveSeek = false;
@@ -32457,6 +32705,7 @@ ${VERTEX_STAGE}
32457
32705
  this.abandonAwaitedSeek();
32458
32706
  }
32459
32707
  async load(config) {
32708
+ this.decodeStrategy = config.decodeStrategy ?? nativeResolution();
32460
32709
  this.beginPresentationGeneration();
32461
32710
  this.presentation = config.presentation ?? "canvas";
32462
32711
  this.displayBox =
@@ -32541,11 +32790,163 @@ ${VERTEX_STAGE}
32541
32790
  this.residency?.startWarming();
32542
32791
  return this.metadata;
32543
32792
  }
32793
+ setDisplay(display) {
32794
+ const cursor = this.cursor;
32795
+ if (this.presentation !== "frames" ||
32796
+ this.decodeStrategy.kind !== "displayBox" ||
32797
+ !cursor?.resizeOutput) {
32798
+ return Promise.reject(new WebVideoEngineError(WebVideoEngineErrorCode.PresentationMismatch, "display resizing requires frames presentation with a displayBox strategy"));
32799
+ }
32800
+ const resolved = resolveDecodeDimensions(displayBoxResolution(display), {
32801
+ nativeWidth: cursor.track.width,
32802
+ nativeHeight: cursor.track.height,
32803
+ displayWidth: null,
32804
+ devicePixelRatio: display.devicePixelRatio,
32805
+ });
32806
+ if (!this.displayResize &&
32807
+ resolved.width === cursor.track.decodeWidth &&
32808
+ resolved.height === cursor.track.decodeHeight) {
32809
+ this.displayBox = {
32810
+ cssWidth: display.boxWidth,
32811
+ cssHeight: display.boxHeight,
32812
+ devicePixelRatio: display.devicePixelRatio,
32813
+ };
32814
+ return Promise.resolve(false);
32815
+ }
32816
+ let state = this.displayResize;
32817
+ if (!state) {
32818
+ let cancel = () => { };
32819
+ const aborted = new Promise((resolve) => {
32820
+ cancel = resolve;
32821
+ });
32822
+ state = {
32823
+ tail: Promise.resolve(false),
32824
+ request: 0,
32825
+ navigation: 0,
32826
+ pendingNavigation: false,
32827
+ held: false,
32828
+ cancelled: false,
32829
+ cancel,
32830
+ aborted,
32831
+ };
32832
+ this.displayResize = state;
32833
+ }
32834
+ const active = state;
32835
+ const request = ++active.request;
32836
+ const previous = active.tail;
32837
+ if (active.held)
32838
+ this.abandonAwaitedSeek();
32839
+ const run = (async () => {
32840
+ await previous.catch((error) => {
32841
+ if (active.cancelled || !isResizeSuperseded(error))
32842
+ throw error;
32843
+ });
32844
+ if (active.cancelled)
32845
+ throw displayResizeAborted();
32846
+ if (request !== active.request || active.pendingNavigation)
32847
+ throw displayResizeSuperseded();
32848
+ const dimensions = resolved;
32849
+ this.displayBox = {
32850
+ cssWidth: display.boxWidth,
32851
+ cssHeight: display.boxHeight,
32852
+ devicePixelRatio: display.devicePixelRatio,
32853
+ };
32854
+ if (dimensions.width === cursor.track.decodeWidth &&
32855
+ dimensions.height === cursor.track.decodeHeight)
32856
+ return false;
32857
+ const target = this.lastHostFrame ??
32858
+ this.timeline().landingAt(this.timeline().indexAtOrBefore(this.clock.now()));
32859
+ this.clock.pause();
32860
+ this.clock.seek(target.mediaTimeS);
32861
+ this.controller?.holdOutput();
32862
+ active.held = true;
32863
+ this.abandonAwaitedSeek();
32864
+ this.beginPresentationGeneration();
32865
+ const untilDisposed = (work) => Promise.race([
32866
+ work,
32867
+ active.aborted.then(() => {
32868
+ throw displayResizeAborted();
32869
+ }),
32870
+ ]);
32871
+ await untilDisposed(cursor.resizeOutput(dimensions));
32872
+ if (request !== active.request || active.pendingNavigation)
32873
+ throw displayResizeSuperseded();
32874
+ const wait = this.awaitSeekLanding(target.frame, false, true);
32875
+ void wait.presented.catch(() => undefined);
32876
+ const decoded = await untilDisposed(cursor.seekToFrame(target.frame));
32877
+ if (request !== active.request || active.pendingNavigation) {
32878
+ this.controller?.holdOutput();
32879
+ this.abandonAwaitedSeek();
32880
+ throw displayResizeSuperseded();
32881
+ }
32882
+ if (!decoded)
32883
+ throw new WebVideoEngineError(WebVideoEngineErrorCode.DecoderStalled, "resized frame could not be decoded");
32884
+ this.controller?.releaseOutput();
32885
+ await untilDisposed(wait.presented);
32886
+ if (request !== active.request || active.pendingNavigation)
32887
+ throw displayResizeSuperseded();
32888
+ return true;
32889
+ })();
32890
+ const settled = run
32891
+ .catch((error) => {
32892
+ if (active.held) {
32893
+ this.abandonAwaitedSeek();
32894
+ this.controller?.holdOutput();
32895
+ }
32896
+ if (!isResizeSuperseded(error)) {
32897
+ this.playing = false;
32898
+ if (!active.cancelled)
32899
+ this.emitStatus(PlaybackStatus.Paused);
32900
+ }
32901
+ throw error;
32902
+ })
32903
+ .finally(() => {
32904
+ if (active.tail !== settled)
32905
+ return;
32906
+ this.displayResize = null;
32907
+ this.controller?.releaseOutput();
32908
+ if ((active.held || !this.clock.playing) &&
32909
+ !active.cancelled &&
32910
+ !active.pendingNavigation &&
32911
+ this.playing) {
32912
+ this.clock.play();
32913
+ this.controller?.beginPlay(this.clock.now());
32914
+ this.emitStatus(PlaybackStatus.Playing);
32915
+ }
32916
+ });
32917
+ active.tail = settled;
32918
+ return settled;
32919
+ }
32920
+ async afterDisplayResize(run, superseded) {
32921
+ const state = this.displayResize;
32922
+ const navigation = ++state.navigation;
32923
+ state.pendingNavigation = true;
32924
+ state.held = true;
32925
+ this.controller?.holdOutput();
32926
+ this.abandonAwaitedSeek();
32927
+ this.beginPresentationGeneration();
32928
+ while (this.displayResize === state)
32929
+ await state.tail.catch((error) => {
32930
+ if (state.cancelled || !isResizeSuperseded(error))
32931
+ throw error;
32932
+ });
32933
+ if (state.cancelled)
32934
+ throw displayResizeAborted();
32935
+ if (navigation !== state.navigation)
32936
+ return superseded;
32937
+ if (this.playing)
32938
+ this.clock.play();
32939
+ return run();
32940
+ }
32544
32941
  play() {
32545
32942
  if (!this.cursor || !this.controller)
32546
32943
  return;
32547
32944
  if (this.decodeFailure)
32548
32945
  return this.republishFailure();
32946
+ if (this.displayResize) {
32947
+ this.playing = true;
32948
+ return;
32949
+ }
32549
32950
  // Resume-from-end: a play after the clock crossed duration would tick
32550
32951
  // straight to Ended. Snap back to 0 first.
32551
32952
  const durS = this.durationMs / 1000;
@@ -32594,7 +32995,7 @@ ${VERTEX_STAGE}
32594
32995
  }
32595
32996
  }
32596
32997
  this.controller?.endPlay();
32597
- if (wasPlaying)
32998
+ if (wasPlaying && !this.displayResize)
32598
32999
  this.abandonAwaitedSeek();
32599
33000
  if (this.decodeFailure)
32600
33001
  return this.republishFailure();
@@ -32635,6 +33036,17 @@ ${VERTEX_STAGE}
32635
33036
  * thread snapped the gesture and wrote it. Paints a cache preview, then
32636
33037
  * walks the cursor. */
32637
33038
  scrub(frameIndex, intent = "gesture") {
33039
+ if (this.displayResize) {
33040
+ void this.afterDisplayResize(() => this.scrub(frameIndex, intent), undefined).catch((error) => {
33041
+ if (error instanceof WebVideoEngineError &&
33042
+ error.code === WebVideoEngineErrorCode.Aborted)
33043
+ return;
33044
+ this.handleTerminalFailure(error instanceof WebVideoEngineError
33045
+ ? error
33046
+ : new WebVideoEngineError(WebVideoEngineErrorCode.BackendCrashed, "display resize failed", error));
33047
+ });
33048
+ return;
33049
+ }
32638
33050
  if (!this.cursor)
32639
33051
  return;
32640
33052
  const target = this.timeline().landingAt(frameIndex);
@@ -32667,7 +33079,6 @@ ${VERTEX_STAGE}
32667
33079
  // crisp frame up, and then the wait is over before the walk restarts.
32668
33080
  if (!this.awaitedSeek)
32669
33081
  this.closePlaySeekWait();
32670
- this.controller?.endPlay();
32671
33082
  this.controller?.beginPlay(tSec);
32672
33083
  }
32673
33084
  /** Closes the wait of a seek served by re-anchoring, at the crisp paint that
@@ -32689,6 +33100,8 @@ ${VERTEX_STAGE}
32689
33100
  this.playSeekMaxMs = waitMs;
32690
33101
  }
32691
33102
  async commit(frameIndex) {
33103
+ if (this.displayResize)
33104
+ return this.afterDisplayResize(() => this.commit(frameIndex), null);
32692
33105
  if (!this.cursor)
32693
33106
  return null;
32694
33107
  if (this.decodeFailure)
@@ -32698,7 +33111,9 @@ ${VERTEX_STAGE}
32698
33111
  this.lastStepLanded = null;
32699
33112
  const wait = this.awaitSeekLanding(target.frame, false, true);
32700
33113
  this.clock.seek(tSec);
32701
- this.controller?.tryPaintFromCache(tSec * 1000);
33114
+ if (!this.controller?.presentExactFromCache(tSec * 1000)) {
33115
+ this.controller?.tryPaintFromCache(tSec * 1000);
33116
+ }
32702
33117
  // While playing, moving the playhead means re-anchoring the playback
32703
33118
  // walk, exactly as scrub does. Seeking the cursor instead leaves the walk
32704
33119
  // where it was, so playback carries on from the old position and the seek
@@ -32746,6 +33161,8 @@ ${VERTEX_STAGE}
32746
33161
  return landed;
32747
33162
  }
32748
33163
  async seekToKey(timeMs) {
33164
+ if (this.displayResize)
33165
+ return this.afterDisplayResize(() => this.seekToKey(timeMs), null);
32749
33166
  if (!this.cursor)
32750
33167
  return null;
32751
33168
  if (this.decodeFailure)
@@ -32809,6 +33226,14 @@ ${VERTEX_STAGE}
32809
33226
  * other.
32810
33227
  */
32811
33228
  async step(direction) {
33229
+ if (this.displayResize) {
33230
+ return this.afterDisplayResize(async () => {
33231
+ const landed = await this.step(direction);
33232
+ if (this.playing)
33233
+ this.controller?.beginPlay(this.clock.now());
33234
+ return landed;
33235
+ }, null);
33236
+ }
32812
33237
  if (!this.cursor || !this.controller)
32813
33238
  return null;
32814
33239
  const timeline = this.timeline();
@@ -32887,6 +33312,10 @@ ${VERTEX_STAGE}
32887
33312
  this.play();
32888
33313
  }
32889
33314
  async dispose() {
33315
+ if (this.displayResize) {
33316
+ this.displayResize.cancelled = true;
33317
+ this.displayResize.cancel();
33318
+ }
32890
33319
  this.playing = false;
32891
33320
  this.diagnosticsStop();
32892
33321
  // A capture still running is evidence someone asked for; keep it, marked
@@ -32957,7 +33386,7 @@ ${VERTEX_STAGE}
32957
33386
  return { generation, presented: Promise.resolve(null) };
32958
33387
  }
32959
33388
  const presented = new Promise((resolve, reject) => {
32960
- const presentationTimer = setTimeout(() => {
33389
+ const presentationDeadline = createPausableDeadline(HANG_RECOVERY.PRESENTATION_LATCH_TIMEOUT_MS, () => {
32961
33390
  const awaited = this.awaitedSeek;
32962
33391
  if (!awaited ||
32963
33392
  awaited.generation !== generation ||
@@ -32970,20 +33399,33 @@ ${VERTEX_STAGE}
32970
33399
  awaited.reject(error);
32971
33400
  else
32972
33401
  awaited.resolve(null);
32973
- }, HANG_RECOVERY.PRESENTATION_LATCH_TIMEOUT_MS);
33402
+ });
32974
33403
  const awaited = {
32975
33404
  generation,
32976
33405
  keyOnly,
32977
- presentationTimer,
33406
+ presentationDeadline,
32978
33407
  reject: (error) => reject(error),
32979
33408
  rejectOnFailure,
32980
33409
  resolve,
32981
33410
  target,
32982
33411
  };
32983
33412
  this.awaitedSeek = awaited;
33413
+ if (!this.presentationVisible)
33414
+ presentationDeadline.pause();
32984
33415
  });
32985
33416
  return { generation, presented };
32986
33417
  }
33418
+ /** Main-thread document visibility; decoding/watchdogs remain active. */
33419
+ setPresentationVisibility(visible) {
33420
+ this.presentationVisible = visible;
33421
+ const awaited = this.awaitedSeek;
33422
+ if (!awaited)
33423
+ return;
33424
+ if (visible)
33425
+ awaited.presentationDeadline.resume();
33426
+ else
33427
+ awaited.presentationDeadline.pause();
33428
+ }
32987
33429
  isCurrentSeek(generation) {
32988
33430
  return generation === this.seekGeneration;
32989
33431
  }
@@ -32997,7 +33439,7 @@ ${VERTEX_STAGE}
32997
33439
  if (!awaited)
32998
33440
  return;
32999
33441
  this.awaitedSeek = null;
33000
- clearTimeout(awaited.presentationTimer);
33442
+ awaited.presentationDeadline.cancel();
33001
33443
  awaited.resolve(null);
33002
33444
  }
33003
33445
  /** Ends an impossible landing. Awaitable movement rejects with the engine's
@@ -33007,7 +33449,7 @@ ${VERTEX_STAGE}
33007
33449
  if (!awaited)
33008
33450
  return;
33009
33451
  this.awaitedSeek = null;
33010
- clearTimeout(awaited.presentationTimer);
33452
+ awaited.presentationDeadline.cancel();
33011
33453
  if (awaited.rejectOnFailure)
33012
33454
  awaited.reject(error);
33013
33455
  else
@@ -33104,7 +33546,7 @@ ${VERTEX_STAGE}
33104
33546
  if (answeredSeek && this.awaitedSeek === answeredSeek) {
33105
33547
  this.seekLanded = frameId;
33106
33548
  this.awaitedSeek = null;
33107
- clearTimeout(answeredSeek.presentationTimer);
33549
+ answeredSeek.presentationDeadline.cancel();
33108
33550
  this.closePlaySeekWait();
33109
33551
  // Paused paints normally never move the playhead, but the landing
33110
33552
  // of an awaited seek is the one paint that says where the request
@@ -33486,6 +33928,17 @@ ${VERTEX_STAGE}
33486
33928
  const wanted = Math.round((windowMs / 1000) * DIAGNOSTICS.BROADCAST_HZ);
33487
33929
  return Math.min(DIAGNOSTICS.TRACE_SNAPSHOT_CAP, Math.max(1, wanted));
33488
33930
  }
33931
+ function displayResizeAborted() {
33932
+ return new WebVideoEngineError(WebVideoEngineErrorCode.Aborted, "display resize aborted by disposal");
33933
+ }
33934
+ function displayResizeSuperseded() {
33935
+ return new WebVideoEngineError(WebVideoEngineErrorCode.Aborted, "display resize superseded");
33936
+ }
33937
+ function isResizeSuperseded(error) {
33938
+ return (error instanceof WebVideoEngineError &&
33939
+ error.code === WebVideoEngineErrorCode.Aborted &&
33940
+ error.message === "display resize superseded");
33941
+ }
33489
33942
 
33490
33943
  function toSerializedError(error) {
33491
33944
  if (error instanceof WebVideoEngineError)
@@ -33511,6 +33964,9 @@ ${VERTEX_STAGE}
33511
33964
  case "unbindCanvas":
33512
33965
  engine.setCanvas(null);
33513
33966
  return;
33967
+ case "setPresentationVisibility":
33968
+ engine.setPresentationVisibility(command.visible);
33969
+ return;
33514
33970
  case "scrub":
33515
33971
  engine.scrub(command.frameIndex, command.intent);
33516
33972
  return;
@@ -33552,6 +34008,11 @@ ${VERTEX_STAGE}
33552
34008
  engine.play();
33553
34009
  return { type: "ack", requestId: command.requestId };
33554
34010
  });
34011
+ case "setDisplay":
34012
+ return settle(post, command.requestId, async () => {
34013
+ const outputChanged = await engine.setDisplay(command.display);
34014
+ return { type: "ack", requestId: command.requestId, outputChanged };
34015
+ });
33555
34016
  case "commit":
33556
34017
  return settle(post, command.requestId, async () => {
33557
34018
  const landing = await engine.commit(command.frameIndex);