akarisub 1.1.2 → 1.1.4

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 (48) hide show
  1. package/README.md +2 -2
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/dist/COPYRIGHT +21 -22
  4. package/dist/ts/akarisub.d.ts +20 -40
  5. package/dist/ts/akarisub.d.ts.map +1 -1
  6. package/dist/ts/akarisub.js +291 -545
  7. package/dist/ts/akarisub.js.map +1 -1
  8. package/dist/ts/bitmap-settlements.d.ts +14 -0
  9. package/dist/ts/bitmap-settlements.d.ts.map +1 -0
  10. package/dist/ts/bitmap-settlements.js +23 -0
  11. package/dist/ts/bitmap-settlements.js.map +1 -0
  12. package/dist/ts/color-space.d.ts +1 -0
  13. package/dist/ts/color-space.d.ts.map +1 -1
  14. package/dist/ts/color-space.js +10 -1
  15. package/dist/ts/color-space.js.map +1 -1
  16. package/dist/ts/cue-events.d.ts +2 -0
  17. package/dist/ts/cue-events.d.ts.map +1 -1
  18. package/dist/ts/cue-events.js +2 -0
  19. package/dist/ts/cue-events.js.map +1 -1
  20. package/dist/ts/font-reload.d.ts +8 -0
  21. package/dist/ts/font-reload.d.ts.map +1 -0
  22. package/dist/ts/font-reload.js +21 -0
  23. package/dist/ts/font-reload.js.map +1 -0
  24. package/dist/ts/gpu-recovery.d.ts +50 -0
  25. package/dist/ts/gpu-recovery.d.ts.map +1 -0
  26. package/dist/ts/gpu-recovery.js +158 -0
  27. package/dist/ts/gpu-recovery.js.map +1 -0
  28. package/dist/ts/prepared-presentation.d.ts +55 -0
  29. package/dist/ts/prepared-presentation.d.ts.map +1 -0
  30. package/dist/ts/prepared-presentation.js +366 -0
  31. package/dist/ts/prepared-presentation.js.map +1 -0
  32. package/dist/ts/timing.d.ts.map +1 -1
  33. package/dist/ts/timing.js +31 -17
  34. package/dist/ts/timing.js.map +1 -1
  35. package/dist/ts/track-lifecycle.d.ts +47 -0
  36. package/dist/ts/track-lifecycle.d.ts.map +1 -0
  37. package/dist/ts/track-lifecycle.js +187 -0
  38. package/dist/ts/track-lifecycle.js.map +1 -0
  39. package/dist/ts/types.d.ts +6 -0
  40. package/dist/ts/types.d.ts.map +1 -1
  41. package/dist/ts/utils.d.ts.map +1 -1
  42. package/dist/ts/utils.js +3 -1
  43. package/dist/ts/utils.js.map +1 -1
  44. package/dist/ts/worker.js +150 -261
  45. package/dist/ts/worker.js.map +1 -1
  46. package/package.json +5 -5
  47. package/pkg/akarisub-mt.wasm +0 -0
  48. package/pkg/akarisub.wasm +0 -0
@@ -1,4 +1,6 @@
1
- import { classifyPerformanceWarnings, parsePreloadTrackSource } from './cue-events';
1
+ import { GPURecovery } from './gpu-recovery';
2
+ import { PreparedPresentation } from './prepared-presentation';
3
+ import { classifyPerformanceWarnings, parsePreloadTrackSource, resolveCueTracking } from './cue-events';
2
4
  import { parseStreamingTrackOptions } from './streaming';
3
5
  import { normalizeFontFamilySource } from './font-subsets';
4
6
  import { computeCanvasSize, getVideoPosition, fixAlpha } from './utils';
@@ -6,7 +8,7 @@ import { canvas2dContextSettings, createSubtitleImageData, defaultVideoColorProf
6
8
  import { selectWasmBinary } from './wasm-capabilities';
7
9
  import { WebGPURenderer, isWebGPUSupported } from './webgpu-renderer';
8
10
  import { WebGL2Renderer, isWebGL2Supported } from './webgl2-renderer';
9
- import { compensatedMediaTime, compositorScheduleLeadMs, estimateRefreshIntervalMs, isStalePresentation, normalizeFrameTimeline, predictFrameDisplayTimeMs, presentedFrameIndex, presentationLeadSeconds, resolvePresentationMediaTime, selectRenderMediaTime, subtitleTimeForFrame, updateTimingCompensation } from './timing';
11
+ import { compensatedMediaTime, estimateRefreshIntervalMs, isStalePresentation, normalizeFrameTimeline, predictFrameDisplayTimeMs, presentedFrameIndex, presentationLeadSeconds, resolvePresentationMediaTime, selectRenderMediaTime, subtitleTimeForFrame, updateTimingCompensation } from './timing';
10
12
  import { isVideoFrameLike, videoFrameCallbackMetadata } from './video-frame';
11
13
  import { getDefaultFontUrl, getMtWasmGlueUrl, getMtWasmUrl, getWasmGlueUrl, getWasmUrl } from './wasm';
12
14
  import { webYCbCrMap } from './utils';
@@ -45,7 +47,6 @@ const isLikelyWebKit = () => {
45
47
  export default class AkariSub extends EventTarget {
46
48
  static MAX_PENDING_DEMANDS = 3;
47
49
  static MAX_FONT_BYTES = 32 * 1024 * 1024;
48
- static WEBGL_RESTORE_TIMEOUT_MS = 3000;
49
50
  static _hasAlphaBug = null;
50
51
  static _hasBitmapBug = null;
51
52
  _loaded;
@@ -53,6 +54,7 @@ export default class AkariSub extends EventTarget {
53
54
  _destroyedSignal;
54
55
  _resolveDestroyed;
55
56
  _pendingWorkerRejectors = new Set();
57
+ _pendingTrackReadyWaiters = new Map();
56
58
  _onDemandRender;
57
59
  _offscreenRender;
58
60
  _video;
@@ -85,12 +87,17 @@ export default class AkariSub extends EventTarget {
85
87
  _demandTimings = new Map();
86
88
  _adaptiveTiming;
87
89
  _frameTimeline;
88
- _preparedFrames = new Map();
89
- _stagedCanvases = new Set();
90
- _stageFrameIndices = new Map();
91
- _stageDisplayTimes = new Map();
92
- _committedStage = null;
93
- _scheduledPreparedFrame = null;
90
+ _presentationOwner;
91
+ get _presentation() {
92
+ return this._presentationOwner ??= new PreparedPresentation({
93
+ canvas: () => this._canvas,
94
+ release: stage => this._releaseGPUStage(stage),
95
+ committed: frame => this._gpuRecovery.commit(this._snapshotRenderedCanvas(frame.stage, frame.gpuStage === true)),
96
+ refreshInterval: () => estimateRefreshIntervalMs(this._refreshSamples ?? []),
97
+ now: () => performance.now(),
98
+ documentTime: () => Number(document.timeline?.currentTime)
99
+ });
100
+ }
94
101
  _predictedDisplayTimes = new Map();
95
102
  _displayClockOffsets = [];
96
103
  _displayGridAnchorMs;
@@ -102,8 +109,10 @@ export default class AkariSub extends EventTarget {
102
109
  _lastRefreshRafTime;
103
110
  _prepareQueue = [];
104
111
  _prepareRequests = new Map();
112
+ _lastRvfcMediaTime = null;
105
113
  _nextPrepareId = 1;
106
114
  _prepareForce = true;
115
+ _prepareFailureEpoch = -1;
107
116
  _timingCompensationSeconds = 0;
108
117
  _rvfcHandle = null;
109
118
  _rvfcGeneration = 0;
@@ -120,14 +129,46 @@ export default class AkariSub extends EventTarget {
120
129
  _boundHandleRVFC;
121
130
  _gpuRenderer = null;
122
131
  _rendererType = 'canvas2d';
123
- _gpuRecovering = null;
124
- _gpuRecoveryGeneration = 0;
125
- _gpuRecoveryTimer = null;
132
+ _gpuRecoveryOwner;
133
+ get _gpuRecovery() {
134
+ return this._gpuRecoveryOwner ??= new GPURecovery({
135
+ current: () => this._gpuRenderer,
136
+ use: renderer => { this._gpuRenderer = renderer; },
137
+ begin: reason => {
138
+ this._emitPerformanceWarning({ kind: 'renderer-recovery', reason, rendererType: this._rendererType });
139
+ this._renderEpoch++;
140
+ this._pendingDemandTimes.length = 0;
141
+ this._demandTimings.clear();
142
+ this._clearPreparedFrames(false);
143
+ this._prepareForce = true;
144
+ this._showGPURecoveryFrame();
145
+ },
146
+ replay: async (renderer) => {
147
+ this._applyGpuColorManagement();
148
+ await this._restoreRetainedGPUFrame(renderer);
149
+ },
150
+ recovered: reason => {
151
+ this._activateBaseCanvas(this._currentExactFrameIndex());
152
+ this._setRendererType(this._rendererType, reason);
153
+ this._hideGPURecoveryFrame();
154
+ this._prepareForce = true;
155
+ this._syncVideoClock();
156
+ },
157
+ fallback: (reason, error) => {
158
+ console.warn(`[AkariSub] GPU recovery failed after ${reason}; falling back to Canvas2D.`, error);
159
+ this._adoptGPURecoveryCanvas();
160
+ this._setRendererType('canvas2d', reason);
161
+ void this.sendMessage('setAsyncRender', { value: false });
162
+ this._onCanvasFallback?.();
163
+ this._prepareForce = true;
164
+ this._syncVideoClock();
165
+ },
166
+ discard: snapshot => this._discardGPUSnapshot(snapshot)
167
+ });
168
+ }
126
169
  _recoveryCanvas = null;
127
170
  _recoveryCtx = null;
128
- _lastGPUFrame = null;
129
171
  _gpuSnapshotPool = [];
130
- _nextGPUSnapshotSequence = 1;
131
172
  _onCanvasFallback;
132
173
  _onCueEnter;
133
174
  _onCueExit;
@@ -139,8 +180,7 @@ export default class AkariSub extends EventTarget {
139
180
  _lastRenderWidth = 0;
140
181
  _lastRenderHeight = 0;
141
182
  _gpuBitmapImages = [];
142
- /** Seconds added to video time when sampling the track. */
143
- timeOffset;
183
+ _timeOffset;
144
184
  /** When true, the worker prints libass and renderer logs. */
145
185
  debug;
146
186
  /** Extra scale applied to the subtitle canvas before height limits. */
@@ -218,7 +258,7 @@ export default class AkariSub extends EventTarget {
218
258
  // ASS composition remains opt-in because it regresses several ordinary and
219
259
  // animation-heavy custom-canvas workloads despite helping dense overlays.
220
260
  this._offscreenRender = canTransferOffscreen && !canUseGPURenderer && wantsOffscreenRender;
221
- this.timeOffset = options.timeOffset || 0;
261
+ this._timeOffset = options.timeOffset || 0;
222
262
  this._video = options.video;
223
263
  this._canvas = options.canvas;
224
264
  if (this._video && !this._canvas) {
@@ -313,6 +353,7 @@ export default class AkariSub extends EventTarget {
313
353
  blockingFullTrackWarmup: options.blockingFullTrackWarmup ?? false,
314
354
  fullTrackWarmupStep: options.fullTrackWarmupStep ?? 1,
315
355
  adaptiveBlendLayouts: options.adaptiveBlendLayouts ?? false,
356
+ cueTracking: resolveCueTracking(options.cueTracking),
316
357
  rawAssImageGpu,
317
358
  onDemandRender: this._onDemandRender,
318
359
  initialTime,
@@ -502,125 +543,33 @@ export default class AkariSub extends EventTarget {
502
543
  };
503
544
  return;
504
545
  }
505
- renderer.onContextLost = () => this._beginWebGLRecovery(renderer);
546
+ renderer.onContextLost = () => this._gpuRecovery.waitForRestore(renderer);
506
547
  renderer.onContextRestored = (error) => {
507
548
  if (error) {
508
- this._fallbackFromGPU(renderer, 'context-lost', error);
549
+ this._gpuRecovery.fail(renderer, error);
509
550
  }
510
551
  else {
511
- void this._finishGPURecovery(renderer, 'context-lost');
552
+ void this._gpuRecovery.restore(renderer);
512
553
  }
513
554
  };
514
555
  }
515
556
  /** @internal */
516
- _beginGPURecovery(renderer, reason) {
517
- if (this._destroyed || this._gpuRenderer !== renderer || this._gpuRecovering)
518
- return false;
519
- this._gpuRecovering = renderer;
520
- this._gpuRecoveryGeneration++;
521
- this._emitPerformanceWarning({ kind: 'renderer-recovery', reason, rendererType: this._rendererType });
522
- // GPU-backed prepared canvases cannot survive either kind of loss. Retire
523
- // them without touching the retained CPU frame used by the recovery layer.
524
- this._renderEpoch++;
525
- this._pendingDemandTimes.length = 0;
526
- this._demandTimings.clear();
527
- this._clearPreparedFrames(false);
528
- this._prepareForce = true;
529
- this._showGPURecoveryFrame();
530
- return true;
531
- }
532
- /** @internal */
533
557
  async _recoverWebGPU(renderer) {
534
- if (!this._beginGPURecovery(renderer, 'device-lost'))
535
- return;
536
- const generation = this._gpuRecoveryGeneration;
537
- let replacement = null;
538
- try {
539
- renderer.destroy();
540
- replacement = new WebGPURenderer();
558
+ await this._gpuRecovery.replace(renderer, () => {
559
+ const replacement = new WebGPURenderer();
541
560
  this._bindGPURecovery(replacement);
542
- await replacement.init();
543
- if (this._destroyed || generation !== this._gpuRecoveryGeneration || !this._canvas) {
544
- replacement.destroy();
561
+ return replacement;
562
+ }, async (replacement, isCurrent) => {
563
+ const gpu = replacement;
564
+ await gpu.init();
565
+ if (!isCurrent())
545
566
  return;
546
- }
547
- await replacement.setCanvas(this._canvas, Math.max(1, this._canvas.width || this._lastRenderWidth || 1), Math.max(1, this._canvas.height || this._lastRenderHeight || 1));
548
- if (!replacement.initialized)
567
+ if (!this._canvas)
568
+ throw new Error('Subtitle canvas is unavailable during GPU recovery');
569
+ await gpu.setCanvas(this._canvas, Math.max(1, this._canvas.width || this._lastRenderWidth || 1), Math.max(1, this._canvas.height || this._lastRenderHeight || 1));
570
+ if (!gpu.initialized)
549
571
  throw new Error('Replacement WebGPU device was lost during initialization');
550
- if (this._destroyed || generation !== this._gpuRecoveryGeneration) {
551
- replacement.destroy();
552
- return;
553
- }
554
- this._gpuRenderer = replacement;
555
- await this._finishGPURecovery(replacement, 'device-lost');
556
- }
557
- catch (error) {
558
- if (replacement && this._gpuRenderer !== replacement)
559
- replacement.destroy();
560
- this._fallbackFromGPU(renderer, 'device-lost', error);
561
- }
562
- }
563
- /** @internal */
564
- _beginWebGLRecovery(renderer) {
565
- if (!this._beginGPURecovery(renderer, 'context-lost'))
566
- return;
567
- const generation = this._gpuRecoveryGeneration;
568
- this._gpuRecoveryTimer = setTimeout(() => {
569
- if (generation === this._gpuRecoveryGeneration) {
570
- this._fallbackFromGPU(renderer, 'context-lost', new Error('Timed out waiting for WebGL2 context restoration'));
571
- }
572
- }, AkariSub.WEBGL_RESTORE_TIMEOUT_MS);
573
- }
574
- /** @internal */
575
- async _finishGPURecovery(renderer, reason) {
576
- if (this._destroyed || this._gpuRenderer !== renderer || this._gpuRecovering == null)
577
- return;
578
- try {
579
- this._clearGPURecoveryTimer();
580
- this._applyGpuColorManagement();
581
- await this._restoreRetainedGPUFrame(renderer);
582
- if (this._destroyed || this._gpuRenderer !== renderer)
583
- return;
584
- this._gpuRecovering = null;
585
- this._setRendererType(this._rendererType, reason);
586
- this._hideGPURecoveryFrame();
587
- this._prepareForce = true;
588
- this._syncVideoClock();
589
- }
590
- catch (error) {
591
- this._fallbackFromGPU(renderer, reason, error);
592
- }
593
- }
594
- /** @internal */
595
- _fallbackFromGPU(renderer, reason, error) {
596
- if (this._destroyed || this._gpuRecovering == null)
597
- return;
598
- if (this._gpuRenderer !== renderer && this._gpuRecovering !== renderer)
599
- return;
600
- console.warn(`[AkariSub] GPU recovery failed after ${reason}; falling back to Canvas2D.`, error);
601
- this._clearGPURecoveryTimer();
602
- try {
603
- renderer.destroy();
604
- }
605
- catch {
606
- // The failed device/context may already reject resource cleanup.
607
- }
608
- this._gpuRenderer = null;
609
- this._gpuRecovering = null;
610
- this._gpuRecoveryGeneration++;
611
- this._adoptGPURecoveryCanvas();
612
- this._setRendererType('canvas2d', reason);
613
- void this.sendMessage('setAsyncRender', { value: false });
614
- this._onCanvasFallback?.();
615
- this._prepareForce = true;
616
- this._syncVideoClock();
617
- }
618
- /** @internal */
619
- _clearGPURecoveryTimer() {
620
- if (this._gpuRecoveryTimer == null)
621
- return;
622
- clearTimeout(this._gpuRecoveryTimer);
623
- this._gpuRecoveryTimer = null;
572
+ });
624
573
  }
625
574
  /** Active compositor: WebGPU, WebGL2, or Canvas2D. */
626
575
  get rendererType() {
@@ -666,7 +615,7 @@ export default class AkariSub extends EventTarget {
666
615
  _snapshotRenderImages(images, width, height) {
667
616
  if (!this._gpuSnapshotPool)
668
617
  return null;
669
- const sequence = this._nextGPUSnapshotSequence++;
618
+ const sequence = this._gpuRecovery.sequence();
670
619
  const canRetainRaw = images.every((image) => image.image instanceof ArrayBuffer ||
671
620
  image.image instanceof Uint8Array ||
672
621
  image.image instanceof Uint8ClampedArray);
@@ -733,7 +682,7 @@ export default class AkariSub extends EventTarget {
733
682
  context.filter = 'none';
734
683
  context.clearRect(0, 0, canvas.width, canvas.height);
735
684
  context.drawImage(source, 0, 0);
736
- return { canvas, sequence: this._nextGPUSnapshotSequence++, colorManaged };
685
+ return { canvas, sequence: this._gpuRecovery.sequence(), colorManaged };
737
686
  }
738
687
  catch {
739
688
  this._gpuSnapshotPool.push(canvas);
@@ -742,54 +691,19 @@ export default class AkariSub extends EventTarget {
742
691
  }
743
692
  /** @internal */
744
693
  _discardGPUSnapshot(snapshot) {
745
- if (snapshot && 'canvas' in snapshot)
694
+ if (!this._destroyed && snapshot && 'canvas' in snapshot)
746
695
  this._gpuSnapshotPool.push(snapshot.canvas);
747
696
  }
748
697
  /** @internal */
749
- _commitGPUSnapshot(snapshot) {
750
- if (!snapshot)
751
- return;
752
- if (this._destroyed || (this._lastGPUFrame && snapshot.sequence < this._lastGPUFrame.sequence)) {
753
- this._discardGPUSnapshot(snapshot);
754
- return;
755
- }
756
- const previous = this._lastGPUFrame;
757
- this._lastGPUFrame = snapshot;
758
- if (previous && 'canvas' in previous)
759
- this._gpuSnapshotPool.push(previous.canvas);
760
- }
761
- /** @internal */
762
698
  _materializeGPUSnapshot(snapshot) {
763
699
  if (!snapshot || 'canvas' in snapshot)
764
700
  return snapshot;
765
701
  const materialized = this._rasterizeGPUFrame(snapshot.images, snapshot.width, snapshot.height, snapshot.sequence);
766
- if (materialized && this._lastGPUFrame === snapshot)
767
- this._lastGPUFrame = materialized;
702
+ if (materialized)
703
+ this._gpuRecovery.materialize(snapshot, materialized);
768
704
  return materialized;
769
705
  }
770
706
  /** @internal */
771
- _retainGPUFrameAfter(completion, snapshot) {
772
- if (!snapshot)
773
- return completion;
774
- if (typeof completion === 'boolean') {
775
- if (completion)
776
- this._commitGPUSnapshot(snapshot);
777
- else
778
- this._discardGPUSnapshot(snapshot);
779
- return completion;
780
- }
781
- return completion.then((painted) => {
782
- if (painted)
783
- this._commitGPUSnapshot(snapshot);
784
- else
785
- this._discardGPUSnapshot(snapshot);
786
- return painted;
787
- }, (error) => {
788
- this._discardGPUSnapshot(snapshot);
789
- throw error;
790
- });
791
- }
792
- /** @internal */
793
707
  _currentCanvasFilter() {
794
708
  const target = this._videoColorProfile.matrix ?? this._videoColorSpace;
795
709
  if (!this._lastSubtitleColorSpace || !target)
@@ -797,10 +711,18 @@ export default class AkariSub extends EventTarget {
797
711
  return getColorSpaceFilterUrl(this._lastSubtitleColorSpace, target) ?? 'none';
798
712
  }
799
713
  /** @internal */
714
+ _requiresCanvasColorConversion() {
715
+ const target = this._videoColorProfile?.matrix ?? this._videoColorSpace;
716
+ return (!this._gpuRenderer &&
717
+ !!this._lastSubtitleColorSpace &&
718
+ !!target &&
719
+ this._lastSubtitleColorSpace !== target);
720
+ }
721
+ /** @internal */
800
722
  _showGPURecoveryFrame() {
801
723
  if (!this._canvasParent || !this._canvas)
802
724
  return;
803
- const snapshot = this._materializeGPUSnapshot(this._lastGPUFrame);
725
+ const snapshot = this._materializeGPUSnapshot(this._gpuRecovery.snapshot);
804
726
  const width = snapshot?.canvas.width || this._canvas.width || this._lastRenderWidth || 1;
805
727
  const height = snapshot?.canvas.height || this._canvas.height || this._lastRenderHeight || 1;
806
728
  const canvas = this._recoveryCanvas ?? document.createElement('canvas');
@@ -854,7 +776,7 @@ export default class AkariSub extends EventTarget {
854
776
  }
855
777
  /** @internal */
856
778
  async _restoreRetainedGPUFrame(renderer) {
857
- const snapshot = this._lastGPUFrame;
779
+ const snapshot = this._gpuRecovery.snapshot;
858
780
  let painted;
859
781
  if (!snapshot) {
860
782
  painted = renderer.clear();
@@ -885,7 +807,6 @@ export default class AkariSub extends EventTarget {
885
807
  throw new Error('The recovered GPU renderer rejected the retained subtitle frame');
886
808
  if (renderer instanceof WebGPURenderer)
887
809
  await renderer.submittedWorkDone();
888
- this._activateBaseCanvas(this._currentExactFrameIndex());
889
810
  }
890
811
  /** @internal */
891
812
  _shouldUseHdr() {
@@ -1024,10 +945,18 @@ export default class AkariSub extends EventTarget {
1024
945
  this._canvas.style.width = videoSize.width + 'px';
1025
946
  this._canvas.style.height = videoSize.height + 'px';
1026
947
  }
948
+ else if ((!width || !height) && this._videoFrameClock) {
949
+ const renderSize = computeCanvasSize(this._videoFrameClock.width, this._videoFrameClock.height, this.prescaleFactor, this.prescaleHeightLimit, this.maxRenderHeight);
950
+ width = renderSize.width;
951
+ height = renderSize.height;
952
+ }
1027
953
  this._canvas.style.top = top + 'px';
1028
954
  this._canvas.style.left = left + 'px';
1029
- for (const stage of this._stagedCanvases)
1030
- this._syncStagedCanvasLayout(stage);
955
+ this._presentation.layout(stage => this._syncStagedCanvasLayout(stage));
956
+ // Explicit resize calls can also supply fractional CSS/DPR dimensions.
957
+ // Keep canvas storage, GPU uploads and worker snapshots on the same size.
958
+ width = Math.max(0, Math.floor(width));
959
+ height = Math.max(0, Math.floor(height));
1031
960
  if (width > 0 && height > 0 && (this._canvasctrl.width !== width || this._canvasctrl.height !== height)) {
1032
961
  this._bumpRenderEpoch();
1033
962
  this._canvasctrl.width = width;
@@ -1063,6 +992,7 @@ export default class AkariSub extends EventTarget {
1063
992
  this._videoFrameClock = null;
1064
993
  this._video = video;
1065
994
  this._playstate = video.paused || video.ended;
995
+ this._lastRvfcMediaTime = null;
1066
996
  if (this._onDemandRender) {
1067
997
  if (!this._destroyed && this._video === video) {
1068
998
  this._scheduleRVFC(video);
@@ -1153,6 +1083,7 @@ export default class AkariSub extends EventTarget {
1153
1083
  }
1154
1084
  /** Fetch and load an ASS/SSA track from `url`. */
1155
1085
  setTrackByUrl(url) {
1086
+ this._supersedePendingTrackActivation();
1156
1087
  this._bumpRenderEpoch();
1157
1088
  this.sendMessage('setTrackByUrl', { url });
1158
1089
  this._reAttachOffscreen();
@@ -1161,6 +1092,7 @@ export default class AkariSub extends EventTarget {
1161
1092
  }
1162
1093
  /** Replace the current track with ASS/SSA text or bytes. */
1163
1094
  setTrack(content) {
1095
+ this._supersedePendingTrackActivation();
1164
1096
  this._bumpRenderEpoch();
1165
1097
  this.sendMessage('setTrack', { content }, AkariSub._getSubtitleTransfers(content));
1166
1098
  this._reAttachOffscreen();
@@ -1173,6 +1105,7 @@ export default class AkariSub extends EventTarget {
1173
1105
  * materialized in the main thread.
1174
1106
  */
1175
1107
  setEncryptedTrack(content) {
1108
+ this._supersedePendingTrackActivation();
1176
1109
  this._bumpRenderEpoch();
1177
1110
  this.sendMessage('setEncryptedTrack', { content }, AkariSub._getSubtitleTransfers(undefined, content));
1178
1111
  this._reAttachOffscreen();
@@ -1181,6 +1114,7 @@ export default class AkariSub extends EventTarget {
1181
1114
  }
1182
1115
  /** Unload the current track without destroying the renderer. */
1183
1116
  freeTrack() {
1117
+ this._supersedePendingTrackActivation();
1184
1118
  this._sendMutatingMessage('freeTrack');
1185
1119
  }
1186
1120
  /**
@@ -1191,6 +1125,7 @@ export default class AkariSub extends EventTarget {
1191
1125
  */
1192
1126
  initStreamingTrack(options) {
1193
1127
  const parsed = parseStreamingTrackOptions(options);
1128
+ this._supersedePendingTrackActivation();
1194
1129
  this._bumpRenderEpoch();
1195
1130
  this.sendMessage('initStreamingTrack', { options: parsed }, parsed.header ? AkariSub._getSubtitleTransfers(parsed.header) : undefined);
1196
1131
  this._reAttachOffscreen();
@@ -1265,8 +1200,9 @@ export default class AkariSub extends EventTarget {
1265
1200
  return { id: result.id };
1266
1201
  }
1267
1202
  /**
1268
- * Replace the visible track with a previously preloaded one. The last
1269
- * painted frame stays on screen until the new track's first frame is ready.
1203
+ * Replace the visible track with a previously preloaded one. The returned
1204
+ * promise waits for the matching readiness cycle; paused exact-timeline
1205
+ * renderers also finish their configured frame-prefetch runway.
1270
1206
  */
1271
1207
  async activatePreloadedTrack(id = this._preloadedTrackId ?? -1) {
1272
1208
  const ready = this._workerReady ||
@@ -1277,22 +1213,38 @@ export default class AkariSub extends EventTarget {
1277
1213
  throw new Error('No preloaded track is ready');
1278
1214
  this._bumpRenderEpoch();
1279
1215
  const requestId = this._nextTrackRequestId++;
1280
- const result = await this._fetchFromWorker({
1281
- target: 'activatePreloadedTrack',
1282
- requestId,
1283
- id,
1284
- timeoutMs: null
1285
- });
1286
- if (!result.success || result.id == null) {
1287
- throw new Error(result.error || 'The preloaded track could not be activated');
1216
+ const readiness = this._waitForTrackReady(requestId);
1217
+ try {
1218
+ const result = await this._fetchFromWorker({
1219
+ target: 'activatePreloadedTrack',
1220
+ requestId,
1221
+ id,
1222
+ timeoutMs: null
1223
+ });
1224
+ if (!result.success || result.id == null) {
1225
+ throw new Error(result.error || 'The preloaded track could not be activated');
1226
+ }
1227
+ if (this._preloadedTrackId === result.id)
1228
+ this._preloadedTrackId = null;
1229
+ const requiresCanvasColorConversion = this._requiresCanvasColorConversion();
1230
+ const reattachedOffscreen = !requiresCanvasColorConversion && this._offscreenRender && !!this._ctx;
1231
+ if (!requiresCanvasColorConversion) {
1232
+ this._reAttachOffscreen();
1233
+ if (this._ctx)
1234
+ this._ctx.filter = 'none';
1235
+ }
1236
+ // The matching trackReady already synchronized the clock. Repeat it only
1237
+ // when reattachment replaced the canvas that received that first render.
1238
+ if (reattachedOffscreen)
1239
+ this._syncVideoClock();
1240
+ const readinessError = await readiness.promise;
1241
+ if (readinessError)
1242
+ throw readinessError;
1243
+ return { id: result.id };
1244
+ }
1245
+ finally {
1246
+ readiness.cancel();
1288
1247
  }
1289
- if (this._preloadedTrackId === result.id)
1290
- this._preloadedTrackId = null;
1291
- this._reAttachOffscreen();
1292
- if (this._ctx)
1293
- this._ctx.filter = 'none';
1294
- this._syncVideoClock();
1295
- return { id: result.id };
1296
1248
  }
1297
1249
  /** Tell the worker whether playback is paused. Ignored when a video element is attached. */
1298
1250
  setIsPaused(isPaused) {
@@ -1464,6 +1416,22 @@ export default class AkariSub extends EventTarget {
1464
1416
  this._primePreparedFrames(this._currentExactFrameMediaTime());
1465
1417
  this._dispatchNextPreparation();
1466
1418
  }
1419
+ /** Subtitle time offset in seconds. Changing it discards prepared frames rendered with the previous offset. */
1420
+ get timeOffset() {
1421
+ return this._timeOffset;
1422
+ }
1423
+ set timeOffset(value) {
1424
+ if (!Number.isFinite(value) || value === this._timeOffset)
1425
+ return;
1426
+ const previous = this._timeOffset;
1427
+ this._timeOffset = value;
1428
+ if (typeof previous !== 'number' || this._destroyed)
1429
+ return;
1430
+ this._bumpRenderEpoch();
1431
+ this._syncVideoClock();
1432
+ this._primePreparedFrames(this._currentExactFrameMediaTime());
1433
+ this._dispatchNextPreparation();
1434
+ }
1467
1435
  /** Number of events in the loaded track. */
1468
1436
  async getEventCount() {
1469
1437
  const data = await this._fetchFromWorker({ target: 'getEventCount' });
@@ -1565,12 +1533,11 @@ export default class AkariSub extends EventTarget {
1565
1533
  return true;
1566
1534
  if (metadata.width !== this._videoWidth || metadata.height !== this._videoHeight)
1567
1535
  return false;
1568
- const prepared = this._preparedFrames.get(frameIndex);
1536
+ const prepared = this._presentation.take(frameIndex);
1569
1537
  if (!prepared)
1570
1538
  return false;
1571
- this._preparedFrames.delete(frameIndex);
1572
1539
  if (prepared.width !== this._canvasctrl.width || prepared.height !== this._canvasctrl.height) {
1573
- this._disposePreparedFrame(prepared);
1540
+ this._presentation.dispose(prepared);
1574
1541
  this._prepareForce = true;
1575
1542
  return false;
1576
1543
  }
@@ -1588,7 +1555,7 @@ export default class AkariSub extends EventTarget {
1588
1555
  }
1589
1556
  const event = this._frameBufferReadyEvent;
1590
1557
  this._frameBufferReadyEvent = null;
1591
- this.dispatchEvent(new CustomEvent(event));
1558
+ this.dispatchEvent(new CustomEvent(event.type, event.requestId == null ? undefined : { detail: { requestId: event.requestId } }));
1592
1559
  }
1593
1560
  /** @internal */
1594
1561
  _startRefreshSampling() {
@@ -1633,18 +1600,6 @@ export default class AkariSub extends EventTarget {
1633
1600
  this._gpuRenderer?.releaseCanvas(stage);
1634
1601
  }
1635
1602
  /** @internal */
1636
- _removeStagedCanvas(stage) {
1637
- for (const animation of stage.getAnimations())
1638
- animation.cancel();
1639
- this._releaseGPUStage(stage);
1640
- stage.remove();
1641
- if (this._committedStage === stage)
1642
- this._committedStage = null;
1643
- this._stagedCanvases.delete(stage);
1644
- this._stageFrameIndices.delete(stage);
1645
- this._stageDisplayTimes.delete(stage);
1646
- }
1647
- /** @internal */
1648
1603
  _currentExactFrameIndex() {
1649
1604
  if (!this._frameTimeline)
1650
1605
  return undefined;
@@ -1665,49 +1620,7 @@ export default class AkariSub extends EventTarget {
1665
1620
  }
1666
1621
  /** Make a freshly painted base canvas visible without discarding future prefetch. */
1667
1622
  _activateBaseCanvas(presentedIndex) {
1668
- if (!this._canvas)
1669
- return;
1670
- // A compositor-scheduled frame can become visible before its RVFC arrives.
1671
- // Never let an older demand response roll that already-visible frame back.
1672
- if (presentedIndex != null) {
1673
- const now = performance.now();
1674
- let visibleIndex = this._committedStage ? this._stageFrameIndices.get(this._committedStage) : undefined;
1675
- for (const stage of this._stagedCanvases) {
1676
- const boundary = this._stageDisplayTimes.get(stage);
1677
- const index = this._stageFrameIndices.get(stage);
1678
- if (boundary != null && boundary <= now && index != null && (visibleIndex == null || index > visibleIndex)) {
1679
- visibleIndex = index;
1680
- }
1681
- }
1682
- if (visibleIndex != null && visibleIndex > presentedIndex)
1683
- return;
1684
- }
1685
- for (const animation of this._canvas.getAnimations())
1686
- animation.cancel();
1687
- this._canvas.style.opacity = '1';
1688
- const retainedStages = new Set();
1689
- for (const [index, frame] of [...this._preparedFrames]) {
1690
- const frameIndex = frame.index ?? index;
1691
- if (presentedIndex != null && frameIndex > presentedIndex && frame.stage) {
1692
- for (const animation of frame.animations ?? [])
1693
- animation.cancel();
1694
- frame.animations = undefined;
1695
- frame.scheduled = false;
1696
- frame.committed = false;
1697
- frame.stage.style.opacity = '0';
1698
- retainedStages.add(frame.stage);
1699
- continue;
1700
- }
1701
- this._disposePreparedFrame(frame);
1702
- this._preparedFrames.delete(index);
1703
- }
1704
- this._scheduledPreparedFrame = null;
1705
- for (const stage of [...this._stagedCanvases]) {
1706
- if (!retainedStages.has(stage))
1707
- this._removeStagedCanvas(stage);
1708
- }
1709
- this._committedStage = null;
1710
- this._scheduleNextPreparedFrame();
1623
+ this._presentation.activateBase(presentedIndex);
1711
1624
  }
1712
1625
  /** @internal */
1713
1626
  _activateBaseCanvasAfterGPUWork(renderEpoch = this._renderEpoch, presentedIndex) {
@@ -1734,22 +1647,6 @@ export default class AkariSub extends EventTarget {
1734
1647
  });
1735
1648
  }
1736
1649
  /** @internal */
1737
- _disposePreparedFrame(frame) {
1738
- if (this._scheduledPreparedFrame === frame)
1739
- this._scheduledPreparedFrame = null;
1740
- for (const animation of frame.animations ?? [])
1741
- animation.cancel();
1742
- frame.animations = undefined;
1743
- frame.scheduled = false;
1744
- frame.bitmap?.close();
1745
- frame.bitmap = undefined;
1746
- frame.gpuStage = false;
1747
- if (frame.stage) {
1748
- this._removeStagedCanvas(frame.stage);
1749
- frame.stage = undefined;
1750
- }
1751
- }
1752
- /** @internal */
1753
1650
  _stagePreparedFrame(index, frame, allowWebGPU = true) {
1754
1651
  if (!this._canvasParent || !frame.bitmap || this._destroyed)
1755
1652
  return;
@@ -1761,8 +1658,7 @@ export default class AkariSub extends EventTarget {
1761
1658
  frame.index = index;
1762
1659
  frame.stage = stage;
1763
1660
  frame.ready = false;
1764
- this._stagedCanvases.add(stage);
1765
- this._stageFrameIndices.set(stage, index);
1661
+ this._presentation.register(stage, index);
1766
1662
  let rendered = false;
1767
1663
  const webgpuRenderer = allowWebGPU && this._rendererType === 'webgpu' && this._gpuRenderer instanceof WebGPURenderer
1768
1664
  ? this._gpuRenderer
@@ -1786,18 +1682,17 @@ export default class AkariSub extends EventTarget {
1786
1682
  // desynchronized:true can expose a newly appended but not-yet-painted layer.
1787
1683
  const context = stage.getContext('2d', this._canvas2dSettings());
1788
1684
  if (!context) {
1789
- this._removeStagedCanvas(stage);
1685
+ this._presentation.remove(stage);
1790
1686
  stage = document.createElement('canvas');
1791
1687
  stage.width = frame.width;
1792
1688
  stage.height = frame.height;
1793
1689
  this._syncStagedCanvasLayout(stage);
1794
1690
  stage.style.opacity = '0';
1795
1691
  frame.stage = stage;
1796
- this._stagedCanvases.add(stage);
1797
- this._stageFrameIndices.set(stage, index);
1692
+ this._presentation.register(stage, index);
1798
1693
  const fallbackContext = stage.getContext('2d', this._canvas2dSettings());
1799
1694
  if (!fallbackContext) {
1800
- this._removeStagedCanvas(stage);
1695
+ this._presentation.remove(stage);
1801
1696
  frame.stage = undefined;
1802
1697
  return;
1803
1698
  }
@@ -1824,157 +1719,7 @@ export default class AkariSub extends EventTarget {
1824
1719
  // promise is pending, rejecting it and starving prefetch forever.
1825
1720
  frame.ready = true;
1826
1721
  }
1827
- this._scheduleNextPreparedFrame();
1828
- }
1829
- /** @internal */
1830
- _scheduleNextPreparedFrame() {
1831
- if (this._scheduledPreparedFrame || this._destroyed)
1832
- return;
1833
- let next;
1834
- let nextTime = Number.POSITIVE_INFINITY;
1835
- const now = performance.now();
1836
- for (const frame of this._preparedFrames.values()) {
1837
- const target = frame.targetDisplayTime;
1838
- if (!frame.stage ||
1839
- frame.committed ||
1840
- frame.scheduled ||
1841
- !Number.isFinite(target) ||
1842
- target <= now ||
1843
- target >= nextTime) {
1844
- continue;
1845
- }
1846
- next = frame;
1847
- nextTime = target;
1848
- }
1849
- if (next?.ready)
1850
- this._schedulePreparedFrame(next, nextTime);
1851
- }
1852
- /** @internal */
1853
- _commitPreparedStage(frame) {
1854
- const stage = frame.stage;
1855
- if (!stage || !this._stagedCanvases.has(stage) || this._destroyed)
1856
- return;
1857
- if (!frame.committed) {
1858
- this._commitGPUSnapshot(this._snapshotRenderedCanvas(stage, frame.gpuStage === true));
1859
- }
1860
- for (const animation of frame.animations ?? [])
1861
- animation.cancel();
1862
- frame.animations = undefined;
1863
- frame.scheduled = false;
1864
- frame.committed = true;
1865
- if (this._scheduledPreparedFrame === frame)
1866
- this._scheduledPreparedFrame = null;
1867
- for (const animation of this._canvas.getAnimations())
1868
- animation.cancel();
1869
- this._canvas.style.opacity = '0';
1870
- stage.style.opacity = '1';
1871
- this._committedStage = stage;
1872
- const frameIndex = frame.index ?? this._stageFrameIndices.get(stage);
1873
- for (const candidate of [...this._stagedCanvases]) {
1874
- if (candidate === stage)
1875
- continue;
1876
- for (const animation of candidate.getAnimations())
1877
- animation.cancel();
1878
- const candidateIndex = this._stageFrameIndices.get(candidate);
1879
- const shouldRetire = frame.replaceAll || frameIndex == null || candidateIndex == null || candidateIndex < frameIndex;
1880
- if (shouldRetire) {
1881
- this._removeStagedCanvas(candidate);
1882
- }
1883
- else {
1884
- candidate.style.opacity = '0';
1885
- }
1886
- }
1887
- this._scheduleNextPreparedFrame();
1888
- }
1889
- /** @internal */
1890
- _schedulePreparedFrame(frame, targetDisplayTime) {
1891
- const stage = frame.stage;
1892
- frame.targetDisplayTime = targetDisplayTime;
1893
- if (!stage ||
1894
- !frame.ready ||
1895
- frame.scheduled ||
1896
- frame.committed ||
1897
- this._scheduledPreparedFrame ||
1898
- this._destroyed ||
1899
- targetDisplayTime <= performance.now()) {
1900
- return;
1901
- }
1902
- // Each swap independently hides every other layer. Do not form a single
1903
- // predecessor chain: removing one skipped prefetched frame would otherwise
1904
- // cancel the only animation capable of hiding its predecessor. Future and
1905
- // unscheduled stages are hidden too, but retained for their own later show.
1906
- const previousStages = [...this._stagedCanvases].filter((candidate) => candidate !== stage);
1907
- this._stageDisplayTimes.set(stage, targetDisplayTime);
1908
- const performanceTime = performance.now();
1909
- const compositorSwapTime = targetDisplayTime - compositorScheduleLeadMs(estimateRefreshIntervalMs(this._refreshSamples ?? []));
1910
- const documentTime = Number(document.timeline?.currentTime);
1911
- const hasDocumentTime = Number.isFinite(documentTime);
1912
- const sharedStartTime = hasDocumentTime ? documentTime + (compositorSwapTime - performanceTime) : undefined;
1913
- const animationOptions = {
1914
- delay: hasDocumentTime ? 0 : Math.max(0, compositorSwapTime - performanceTime),
1915
- // A near-zero positive interval keeps the swap compositor-scheduled while
1916
- // allowing its cleanup promise to run in the same refresh. A full 1 ms
1917
- // interval can survive until the next paint when animation composite order
1918
- // temporarily favors an older stage.
1919
- duration: 0.001,
1920
- easing: 'steps(1, jump-start)',
1921
- fill: 'forwards'
1922
- };
1923
- const hiddenLayers = [this._canvas, ...previousStages];
1924
- let showAnimation = null;
1925
- const hideAnimations = [];
1926
- try {
1927
- showAnimation = stage.animate([{ opacity: '0' }, { opacity: '1' }], animationOptions);
1928
- for (const layer of hiddenLayers) {
1929
- hideAnimations.push(layer.animate([{ opacity: '1' }, { opacity: '0' }], animationOptions));
1930
- }
1931
- }
1932
- catch {
1933
- showAnimation?.cancel();
1934
- for (const animation of hideAnimations)
1935
- animation.cancel();
1936
- return;
1937
- }
1938
- if (!showAnimation)
1939
- return;
1940
- if (sharedStartTime != null) {
1941
- showAnimation.startTime = sharedStartTime;
1942
- for (const animation of hideAnimations)
1943
- animation.startTime = sharedStartTime;
1944
- }
1945
- frame.scheduled = true;
1946
- this._scheduledPreparedFrame = frame;
1947
- const swapAnimations = [showAnimation, ...hideAnimations];
1948
- frame.animations = swapAnimations;
1949
- const releaseSwapAnimations = () => {
1950
- for (const animation of swapAnimations)
1951
- animation.cancel();
1952
- if (frame.animations === swapAnimations)
1953
- frame.animations = undefined;
1954
- };
1955
- // The show animation is the authoritative commit. Once it completes, make
1956
- // its state explicit, release this swap's fill animations, and remove all
1957
- // older layers. A later prefetched swap may already have its own independent
1958
- // hide animation on this stage; it is intentionally left untouched.
1959
- void showAnimation.finished.then(() => {
1960
- if (frame.animations !== swapAnimations)
1961
- return;
1962
- if (!frame.committed)
1963
- this._commitPreparedStage(frame);
1964
- releaseSwapAnimations();
1965
- if (this._scheduledPreparedFrame === frame)
1966
- this._scheduledPreparedFrame = null;
1967
- this._scheduleNextPreparedFrame();
1968
- }, () => {
1969
- if (frame.animations !== swapAnimations)
1970
- return;
1971
- releaseSwapAnimations();
1972
- if (this._scheduledPreparedFrame === frame)
1973
- this._scheduledPreparedFrame = null;
1974
- frame.scheduled = false;
1975
- if (!frame.committed)
1976
- this._scheduleNextPreparedFrame();
1977
- });
1722
+ this._presentation.scheduleNext();
1978
1723
  }
1979
1724
  /** @internal */
1980
1725
  _recordPresentationClock(frameIndex, mediaTime, expectedDisplayTime) {
@@ -2009,70 +1754,14 @@ export default class AkariSub extends EventTarget {
2009
1754
  if (!Number.isFinite(targetDisplayTime))
2010
1755
  return;
2011
1756
  this._predictedDisplayTimes.set(frameIndex + 1, targetDisplayTime);
2012
- const prepared = this._preparedFrames.get(frameIndex + 1);
1757
+ const prepared = this._presentation.get(frameIndex + 1);
2013
1758
  if (prepared)
2014
1759
  prepared.targetDisplayTime = targetDisplayTime;
2015
- this._scheduleNextPreparedFrame();
1760
+ this._presentation.scheduleNext();
2016
1761
  }
2017
1762
  /** @internal */
2018
1763
  _clearPreparedFrames(preservePresentation = true) {
2019
- let preservedStage = preservePresentation ? this._committedStage : null;
2020
- if (preservePresentation) {
2021
- const now = performance.now();
2022
- let latestBoundary = preservedStage ? (this._stageDisplayTimes.get(preservedStage) ?? -Infinity) : -Infinity;
2023
- for (const stage of this._stagedCanvases) {
2024
- const boundary = this._stageDisplayTimes.get(stage);
2025
- if (boundary != null && boundary <= now && boundary >= latestBoundary) {
2026
- preservedStage = stage;
2027
- latestBoundary = boundary;
2028
- }
2029
- }
2030
- }
2031
- const preservedFrameIndex = preservedStage ? this._stageFrameIndices.get(preservedStage) : undefined;
2032
- const preservedDisplayTime = preservedStage ? this._stageDisplayTimes.get(preservedStage) : undefined;
2033
- this._scheduledPreparedFrame = null;
2034
- for (const frame of this._preparedFrames.values()) {
2035
- if (frame.stage === preservedStage) {
2036
- for (const animation of frame.animations ?? [])
2037
- animation.cancel();
2038
- frame.animations = undefined;
2039
- frame.scheduled = false;
2040
- frame.bitmap?.close();
2041
- frame.bitmap = undefined;
2042
- continue;
2043
- }
2044
- this._disposePreparedFrame(frame);
2045
- }
2046
- this._preparedFrames.clear();
2047
- for (const stage of [...this._stagedCanvases]) {
2048
- if (stage !== preservedStage)
2049
- this._removeStagedCanvas(stage);
2050
- }
2051
- this._stagedCanvases.clear();
2052
- this._stageFrameIndices.clear();
2053
- this._stageDisplayTimes.clear();
2054
- for (const animation of this._canvas?.getAnimations?.() ?? [])
2055
- animation.cancel();
2056
- if (preservedStage?.isConnected) {
2057
- for (const animation of preservedStage.getAnimations())
2058
- animation.cancel();
2059
- preservedStage.style.opacity = '1';
2060
- this._stagedCanvases.add(preservedStage);
2061
- if (preservedFrameIndex != null)
2062
- this._stageFrameIndices.set(preservedStage, preservedFrameIndex);
2063
- if (preservedDisplayTime != null)
2064
- this._stageDisplayTimes.set(preservedStage, preservedDisplayTime);
2065
- if (this._canvas)
2066
- this._canvas.style.opacity = '0';
2067
- this._committedStage = preservedStage;
2068
- }
2069
- else {
2070
- if (preservedStage)
2071
- this._removeStagedCanvas(preservedStage);
2072
- this._committedStage = null;
2073
- if (this._canvas)
2074
- this._canvas.style.opacity = '1';
2075
- }
1764
+ this._presentation.clear(preservePresentation);
2076
1765
  this._predictedDisplayTimes.clear();
2077
1766
  this._displayClockOffsets.length = 0;
2078
1767
  this._displayGridAnchorMs = undefined;
@@ -2085,26 +1774,16 @@ export default class AkariSub extends EventTarget {
2085
1774
  /** @internal */
2086
1775
  _primePreparedFrames(mediaTime) {
2087
1776
  const timeline = this._frameTimeline;
2088
- if (!timeline || timeline.length === 0 || this.framePrefetch <= 0 || this._destroyed) {
1777
+ if (!timeline ||
1778
+ timeline.length === 0 ||
1779
+ this.framePrefetch <= 0 ||
1780
+ this._destroyed ||
1781
+ (this._prepareFailureEpoch ?? -1) >= 0 && this._prepareFailureEpoch === this._renderEpoch) {
2089
1782
  return;
2090
1783
  }
2091
1784
  const currentIndex = presentedFrameIndex(timeline, mediaTime);
2092
1785
  const lastIndex = Math.min(timeline.length - 1, currentIndex + this.framePrefetch);
2093
- for (const [index, frame] of this._preparedFrames) {
2094
- if (index < currentIndex || index > lastIndex) {
2095
- // A scheduled stage may already be the compositor's visible frame even
2096
- // when Chromium skips/delays its RVFC. Removing it here produces a blank
2097
- // refresh. Its successor's completed hide animation owns stage cleanup.
2098
- if ((frame.scheduled || frame.committed) && index < currentIndex) {
2099
- frame.bitmap?.close();
2100
- frame.bitmap = undefined;
2101
- }
2102
- else {
2103
- this._disposePreparedFrame(frame);
2104
- }
2105
- this._preparedFrames.delete(index);
2106
- }
2107
- }
1786
+ this._presentation.prune(currentIndex, lastIndex);
2108
1787
  const queued = this._prepareQueue.filter((index) => index > currentIndex && index <= lastIndex);
2109
1788
  this._prepareQueue.length = 0;
2110
1789
  this._prepareQueue.push(...queued);
@@ -2114,17 +1793,22 @@ export default class AkariSub extends EventTarget {
2114
1793
  for (const index of this._prepareQueue)
2115
1794
  requested.add(index);
2116
1795
  for (let index = currentIndex + 1; index <= lastIndex; index++) {
2117
- if (!this._preparedFrames.has(index) && !requested.has(index))
1796
+ if (!this._presentation.has(index) && !requested.has(index))
2118
1797
  this._prepareQueue.push(index);
2119
1798
  }
2120
1799
  }
2121
1800
  /** @internal */
2122
1801
  _dispatchNextPreparation() {
2123
- if (this.busy || !this._workerReady || !this._frameTimeline || this.framePrefetch <= 0)
1802
+ if (this.busy ||
1803
+ !this._workerReady ||
1804
+ !this._frameTimeline ||
1805
+ this.framePrefetch <= 0 ||
1806
+ (this._prepareFailureEpoch ?? -1) >= 0 && this._prepareFailureEpoch === this._renderEpoch) {
2124
1807
  return;
1808
+ }
2125
1809
  let index;
2126
1810
  while ((index = this._prepareQueue.shift()) != null) {
2127
- if (!this._preparedFrames.has(index))
1811
+ if (!this._presentation.has(index))
2128
1812
  break;
2129
1813
  }
2130
1814
  if (index == null)
@@ -2148,9 +1832,15 @@ export default class AkariSub extends EventTarget {
2148
1832
  _preparedFrame(data) {
2149
1833
  const request = this._prepareRequests.get(data.prepareId);
2150
1834
  this._prepareRequests.delete(data.prepareId);
1835
+ const validEpoch = request != null && request.renderEpoch === this._renderEpoch && data.renderEpoch === this._renderEpoch;
1836
+ if (data.failed && validEpoch) {
1837
+ // A snapshot failure must not immediately enqueue the same frame again.
1838
+ // Fall back to demand rendering for this epoch and let a later epoch retry.
1839
+ this._prepareFailureEpoch = this._renderEpoch;
1840
+ this._prepareQueue.length = 0;
1841
+ }
2151
1842
  if (request?.presentation) {
2152
1843
  const presentation = request.presentation;
2153
- const validEpoch = request.renderEpoch === this._renderEpoch && data.renderEpoch === this._renderEpoch;
2154
1844
  const currentIndex = this._currentExactFrameIndex();
2155
1845
  const obsolete = currentIndex != null && request.index < currentIndex;
2156
1846
  if (data.bitmap && validEpoch && !obsolete) {
@@ -2185,9 +1875,6 @@ export default class AkariSub extends EventTarget {
2185
1875
  request.renderEpoch === this._renderEpoch &&
2186
1876
  data.renderEpoch === this._renderEpoch &&
2187
1877
  request.index >= currentIndex) {
2188
- const previous = this._preparedFrames.get(request.index);
2189
- if (previous)
2190
- this._disposePreparedFrame(previous);
2191
1878
  const prepared = {
2192
1879
  width: data.width ?? this._canvasctrl.width,
2193
1880
  height: data.height ?? this._canvasctrl.height,
@@ -2195,8 +1882,7 @@ export default class AkariSub extends EventTarget {
2195
1882
  time: data.time
2196
1883
  };
2197
1884
  this._stagePreparedFrame(request.index, prepared);
2198
- this._preparedFrames.set(request.index, prepared);
2199
- this._scheduleNextPreparedFrame();
1885
+ this._presentation.store(request.index, prepared);
2200
1886
  }
2201
1887
  else {
2202
1888
  data.bitmap?.close();
@@ -2209,35 +1895,12 @@ export default class AkariSub extends EventTarget {
2209
1895
  /** @internal */
2210
1896
  _presentPreparedFrame(frame, presentationId, expectedDisplayTime) {
2211
1897
  if (!this._activatePresentation(presentationId, frame.time)) {
2212
- // Scheduling happens on the display clock before RVFC validation. If a
2213
- // newer callback has already advanced the presentation watermark, this
2214
- // stage can still be the compositor's current frame. Never tear down a
2215
- // scheduled stage from a stale callback; the next atomic swap removes it.
2216
- if (frame.stage && (frame.scheduled || frame.committed || this._committedStage === frame.stage)) {
2217
- frame.bitmap?.close();
2218
- frame.bitmap = undefined;
2219
- }
2220
- else {
2221
- this._disposePreparedFrame(frame);
2222
- }
1898
+ this._presentation.reject(frame);
2223
1899
  return;
2224
1900
  }
2225
1901
  const { bitmap, width, height, stage } = frame;
2226
1902
  if (stage) {
2227
- if (!frame.committed || this._committedStage !== stage) {
2228
- for (const animation of frame.animations ?? []) {
2229
- try {
2230
- animation.finish();
2231
- }
2232
- catch {
2233
- animation.cancel();
2234
- }
2235
- }
2236
- this._commitPreparedStage(frame);
2237
- }
2238
- this._stageDisplayTimes.set(stage, Number.isFinite(expectedDisplayTime) ? expectedDisplayTime : performance.now());
2239
- bitmap?.close();
2240
- frame.bitmap = undefined;
1903
+ this._presentation.present(frame, expectedDisplayTime);
2241
1904
  return;
2242
1905
  }
2243
1906
  if (!bitmap)
@@ -2258,11 +1921,11 @@ export default class AkariSub extends EventTarget {
2258
1921
  frame.replaceAll = true;
2259
1922
  this._stagePreparedFrame(-1, frame, false);
2260
1923
  if (frame.stage)
2261
- this._commitPreparedStage(frame);
1924
+ this._presentation.commit(frame);
2262
1925
  return;
2263
1926
  }
2264
1927
  const completion = this._activateBaseCanvasAfterGPUWork(this._renderEpoch, frame.index) ?? true;
2265
- void this._retainGPUFrameAfter(completion, snapshot);
1928
+ void this._gpuRecovery.retainAfter(completion, snapshot);
2266
1929
  return;
2267
1930
  }
2268
1931
  if (this._ctx) {
@@ -2300,6 +1963,7 @@ export default class AkariSub extends EventTarget {
2300
1963
  /** @internal */
2301
1964
  _bumpRenderEpoch() {
2302
1965
  this._renderEpoch++;
1966
+ this._prepareFailureEpoch = -1;
2303
1967
  this._pendingDemandTimes.length = 0;
2304
1968
  this._demandTimings.clear();
2305
1969
  this._clearPreparedFrames();
@@ -2343,6 +2007,16 @@ export default class AkariSub extends EventTarget {
2343
2007
  }
2344
2008
  if (this._destroyed || this._video !== video || generation !== this._rvfcGeneration)
2345
2009
  return;
2010
+ if (this._playstate &&
2011
+ !video.paused &&
2012
+ !video.ended &&
2013
+ !video.seeking &&
2014
+ this._lastRvfcMediaTime != null &&
2015
+ metadata.mediaTime > this._lastRvfcMediaTime) {
2016
+ this._playstate = false;
2017
+ this._syncVideoClock();
2018
+ }
2019
+ this._lastRvfcMediaTime = metadata.mediaTime;
2346
2020
  this._handleRVFC(now, metadata);
2347
2021
  });
2348
2022
  }
@@ -2384,14 +2058,12 @@ export default class AkariSub extends EventTarget {
2384
2058
  switch (event.type) {
2385
2059
  case 'play':
2386
2060
  case 'playing':
2387
- case 'canplay':
2388
2061
  this._playstate = false;
2389
2062
  break;
2390
2063
  case 'pause':
2391
2064
  case 'ended':
2392
2065
  case 'seeking':
2393
2066
  case 'waiting':
2394
- case 'stalled':
2395
2067
  this._playstate = true;
2396
2068
  break;
2397
2069
  case 'seeked':
@@ -2498,15 +2170,14 @@ export default class AkariSub extends EventTarget {
2498
2170
  const frameIndex = presentedFrameIndex(this._frameTimeline, mediaTime);
2499
2171
  if (frameIndex >= 0)
2500
2172
  this._lastPresentedFrameIndex = frameIndex;
2501
- const prepared = this._preparedFrames.get(frameIndex);
2173
+ const prepared = this._presentation.take(frameIndex);
2502
2174
  if (prepared) {
2503
- this._preparedFrames.delete(frameIndex);
2504
2175
  if (prepared.width === this._canvasctrl.width && prepared.height === this._canvasctrl.height) {
2505
2176
  this._presentPreparedFrame(prepared, presentationId, expectedDisplayTime);
2506
2177
  presented = true;
2507
2178
  }
2508
2179
  else {
2509
- this._disposePreparedFrame(prepared);
2180
+ this._presentation.dispose(prepared);
2510
2181
  this._prepareForce = true;
2511
2182
  }
2512
2183
  }
@@ -2730,6 +2401,7 @@ export default class AkariSub extends EventTarget {
2730
2401
  const hasAlphaBug = AkariSub._hasAlphaBug ?? false;
2731
2402
  const bufferCanvas = this._bufferCanvas;
2732
2403
  const bufferCtx = this._bufferCtx;
2404
+ const canWritePreblendedDirectly = data.preblended === true && ctx.filter === 'none';
2733
2405
  for (let i = 0; i < imageCount; i++) {
2734
2406
  const image = images[i];
2735
2407
  if (image.image) {
@@ -2742,11 +2414,20 @@ export default class AkariSub extends EventTarget {
2742
2414
  ? new Uint8ClampedArray(rawImage.buffer, rawImage.byteOffset, rawImage.byteLength)
2743
2415
  : new Uint8ClampedArray(rawImage);
2744
2416
  const fixedData = fixAlpha(rawData, hasAlphaBug);
2417
+ const imageData = createSubtitleImageData(fixedData, imgW, imgH);
2418
+ if (canWritePreblendedDirectly) {
2419
+ // renderBlendCollect guarantees these rectangles are already
2420
+ // source-over flattened and mutually disjoint. The render target
2421
+ // is cleared above, so a direct write preserves the same frame
2422
+ // while avoiding one upload and draw per plane.
2423
+ ctx.putImageData(imageData, image.x, image.y);
2424
+ continue;
2425
+ }
2745
2426
  if (bufferCanvas.width !== imgW || bufferCanvas.height !== imgH) {
2746
2427
  bufferCanvas.width = imgW;
2747
2428
  bufferCanvas.height = imgH;
2748
2429
  }
2749
- bufferCtx.putImageData(createSubtitleImageData(fixedData, imgW, imgH), 0, 0);
2430
+ bufferCtx.putImageData(imageData, 0, 0);
2750
2431
  ctx.drawImage(bufferCanvas, image.x, image.y);
2751
2432
  }
2752
2433
  }
@@ -2797,7 +2478,7 @@ export default class AkariSub extends EventTarget {
2797
2478
  }
2798
2479
  if (painted !== false) {
2799
2480
  const completion = this._activateBaseCanvasAfterGPUWork(data.renderEpoch, presentedIndex) ?? true;
2800
- return this._retainGPUFrameAfter(completion, snapshot);
2481
+ return this._gpuRecovery.retainAfter(completion, snapshot);
2801
2482
  }
2802
2483
  this._discardGPUSnapshot(snapshot);
2803
2484
  return false;
@@ -2842,7 +2523,7 @@ export default class AkariSub extends EventTarget {
2842
2523
  }
2843
2524
  }
2844
2525
  const completion = painted !== false ? (this._activateBaseCanvasAfterGPUWork(data.renderEpoch, presentedIndex) ?? true) : false;
2845
- const retainedCompletion = this._retainGPUFrameAfter(completion, snapshot);
2526
+ const retainedCompletion = this._gpuRecovery.retainAfter(completion, snapshot);
2846
2527
  if (this.debug) {
2847
2528
  data.times.JSRenderTime = Date.now() - (data.times.JSRenderTime || 0) - (data.times.IPCTime || 0);
2848
2529
  let total = 0;
@@ -2863,7 +2544,7 @@ export default class AkariSub extends EventTarget {
2863
2544
  const currentTime = this._clockCurrentTime() ?? 0;
2864
2545
  const isPaused = this._isVideoPausedForWorker();
2865
2546
  const bufferExactFrames = isPaused && this._onDemandRender && !!this._frameTimeline?.length && this.framePrefetch > 0;
2866
- this._frameBufferReadyEvent = bufferExactFrames ? 'ready' : null;
2547
+ this._frameBufferReadyEvent = bufferExactFrames ? { type: 'ready' } : null;
2867
2548
  this.setCurrentTime(isPaused, currentTime + this.timeOffset, this._videoPlaybackRateForWorker());
2868
2549
  if (!this._onDemandRender) {
2869
2550
  this.dispatchEvent(new CustomEvent('ready'));
@@ -2892,20 +2573,39 @@ export default class AkariSub extends EventTarget {
2892
2573
  this.dispatchEvent(new CustomEvent('partial_ready'));
2893
2574
  }
2894
2575
  /** @internal */
2895
- _trackReady() {
2576
+ _trackReady(data = {}) {
2577
+ this._supersedePendingTrackActivation(data.requestId);
2896
2578
  const bufferExactFrames = this._workerReady &&
2897
2579
  this._isVideoPausedForWorker() &&
2898
2580
  this._onDemandRender &&
2899
2581
  !!this._frameTimeline?.length &&
2900
2582
  this.framePrefetch > 0;
2901
- this._frameBufferReadyEvent = bufferExactFrames ? 'trackReady' : null;
2583
+ this._frameBufferReadyEvent = bufferExactFrames
2584
+ ? { type: 'trackReady', requestId: data.requestId }
2585
+ : null;
2902
2586
  this._syncVideoClock();
2903
2587
  if (bufferExactFrames) {
2904
2588
  this._primePreparedFrames(this._currentExactFrameMediaTime());
2905
2589
  this._dispatchNextPreparation();
2906
2590
  return;
2907
2591
  }
2908
- this.dispatchEvent(new CustomEvent('trackReady'));
2592
+ this.dispatchEvent(new CustomEvent('trackReady', data.requestId == null ? undefined : { detail: { requestId: data.requestId } }));
2593
+ }
2594
+ /** @internal */
2595
+ _supersedePendingTrackActivation(nextRequestId) {
2596
+ const supersededRequestIds = new Set();
2597
+ for (const [requestId, supersede] of [...(this._pendingTrackReadyWaiters ?? [])]) {
2598
+ if (requestId === nextRequestId)
2599
+ continue;
2600
+ supersededRequestIds.add(requestId);
2601
+ supersede(new Error('Track activation was superseded by a newer track change'));
2602
+ }
2603
+ const pending = this._frameBufferReadyEvent;
2604
+ if (pending?.type === 'trackReady' &&
2605
+ pending.requestId != null &&
2606
+ supersededRequestIds.has(pending.requestId)) {
2607
+ this._frameBufferReadyEvent = null;
2608
+ }
2909
2609
  }
2910
2610
  /** Send a raw message to the worker. Prefer the typed methods above. */
2911
2611
  async sendMessage(target, data = {}, transferable) {
@@ -2944,6 +2644,15 @@ export default class AkariSub extends EventTarget {
2944
2644
  reject(new Error('Error: Timeout while trying to fetch ' + target));
2945
2645
  }, timeoutMs);
2946
2646
  const handleMessage = (event) => {
2647
+ if (event.data.target === 'error') {
2648
+ const workerError = event.data.error;
2649
+ handleError(workerError instanceof Error
2650
+ ? workerError
2651
+ : new Error(typeof workerError === 'string'
2652
+ ? workerError
2653
+ : workerError?.message || 'Unknown worker error'));
2654
+ return;
2655
+ }
2947
2656
  if (event.data.target === target &&
2948
2657
  (workerMessage.requestId === undefined || event.data.requestId === workerMessage.requestId)) {
2949
2658
  cleanup();
@@ -2982,6 +2691,45 @@ export default class AkariSub extends EventTarget {
2982
2691
  });
2983
2692
  }
2984
2693
  /** @internal */
2694
+ _waitForTrackReady(requestId) {
2695
+ let cancel = () => { };
2696
+ const promise = new Promise((resolve) => {
2697
+ let settled = false;
2698
+ const cleanup = () => {
2699
+ this.removeEventListener('trackReady', handleTrackReady);
2700
+ this.removeEventListener('error', handleError);
2701
+ this._pendingWorkerRejectors.delete(handleDestroyed);
2702
+ if (this._pendingTrackReadyWaiters.get(requestId) === handleSuperseded) {
2703
+ this._pendingTrackReadyWaiters.delete(requestId);
2704
+ }
2705
+ };
2706
+ const settle = (error) => {
2707
+ if (settled)
2708
+ return;
2709
+ settled = true;
2710
+ cleanup();
2711
+ resolve(error);
2712
+ };
2713
+ const handleTrackReady = (event) => {
2714
+ if (event.detail?.requestId !== requestId)
2715
+ return;
2716
+ settle(null);
2717
+ };
2718
+ const handleError = (event) => {
2719
+ const errorEvent = event;
2720
+ settle(errorEvent.error instanceof Error ? errorEvent.error : new Error(errorEvent.message || 'Renderer error'));
2721
+ };
2722
+ const handleDestroyed = (error) => settle(error);
2723
+ const handleSuperseded = (error) => settle(error);
2724
+ cancel = () => settle(null);
2725
+ this.addEventListener('trackReady', handleTrackReady);
2726
+ this.addEventListener('error', handleError);
2727
+ this._pendingWorkerRejectors.add(handleDestroyed);
2728
+ this._pendingTrackReadyWaiters.set(requestId, handleSuperseded);
2729
+ });
2730
+ return { promise, cancel: () => cancel() };
2731
+ }
2732
+ /** @internal */
2985
2733
  _cues(data) {
2986
2734
  for (const cue of data.exited) {
2987
2735
  this._onCueExit?.(cue);
@@ -3101,13 +2849,12 @@ export default class AkariSub extends EventTarget {
3101
2849
  cancelAnimationFrame(this._refreshRafHandle);
3102
2850
  this._refreshRafHandle = null;
3103
2851
  }
3104
- this._clearGPURecoveryTimer();
3105
- this._gpuRecoveryGeneration++;
3106
- this._gpuRecovering = null;
2852
+ this._gpuRecovery.destroy();
3107
2853
  this._recoveryCanvas?.remove();
3108
2854
  this._recoveryCanvas = null;
3109
2855
  this._recoveryCtx = null;
3110
2856
  this._clearPreparedFrames(false);
2857
+ this._presentation.destroy();
3111
2858
  if (this._video && this._canvasParent) {
3112
2859
  this._video.parentNode?.removeChild(this._canvasParent);
3113
2860
  }
@@ -3116,7 +2863,6 @@ export default class AkariSub extends EventTarget {
3116
2863
  this._gpuRenderer = null;
3117
2864
  this._rendererType = 'canvas2d';
3118
2865
  }
3119
- this._lastGPUFrame = null;
3120
2866
  this._gpuSnapshotPool.length = 0;
3121
2867
  this._destroyed = true;
3122
2868
  this._resolveDestroyed?.();