avbridge 2.2.1 → 2.5.0

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 (165) hide show
  1. package/CHANGELOG.md +153 -1
  2. package/NOTICE.md +2 -2
  3. package/README.md +2 -3
  4. package/THIRD_PARTY_LICENSES.md +2 -2
  5. package/dist/avi-2JPBSHGA.js +183 -0
  6. package/dist/avi-2JPBSHGA.js.map +1 -0
  7. package/dist/avi-F6WZJK5T.cjs +185 -0
  8. package/dist/avi-F6WZJK5T.cjs.map +1 -0
  9. package/dist/{avi-GCGM7OJI.js → avi-NJXAXUXK.js} +9 -3
  10. package/dist/avi-NJXAXUXK.js.map +1 -0
  11. package/dist/{avi-6SJLWIWW.cjs → avi-W6L3BTWU.cjs} +10 -4
  12. package/dist/avi-W6L3BTWU.cjs.map +1 -0
  13. package/dist/chunk-2IJ66NTD.cjs +212 -0
  14. package/dist/chunk-2IJ66NTD.cjs.map +1 -0
  15. package/dist/{chunk-ILKDNBSE.js → chunk-2XW2O3YI.cjs} +55 -10
  16. package/dist/chunk-2XW2O3YI.cjs.map +1 -0
  17. package/dist/chunk-5KVLE6YI.js +167 -0
  18. package/dist/chunk-5KVLE6YI.js.map +1 -0
  19. package/dist/chunk-5YAWWKA3.js +18 -0
  20. package/dist/chunk-5YAWWKA3.js.map +1 -0
  21. package/dist/chunk-CPJLFFCC.js +189 -0
  22. package/dist/chunk-CPJLFFCC.js.map +1 -0
  23. package/dist/chunk-CPZ7PXAM.cjs +240 -0
  24. package/dist/chunk-CPZ7PXAM.cjs.map +1 -0
  25. package/dist/{chunk-WD2ZNQA7.js → chunk-DCSOQH2N.js} +7 -4
  26. package/dist/chunk-DCSOQH2N.js.map +1 -0
  27. package/dist/{chunk-HZLQNKFN.cjs → chunk-E76AMWI4.js} +40 -15
  28. package/dist/chunk-E76AMWI4.js.map +1 -0
  29. package/dist/chunk-F3LQJKXK.cjs +20 -0
  30. package/dist/chunk-F3LQJKXK.cjs.map +1 -0
  31. package/dist/chunk-IAYKFGFG.js +200 -0
  32. package/dist/chunk-IAYKFGFG.js.map +1 -0
  33. package/dist/{chunk-DMWARSEF.js → chunk-KY2GPCT7.js} +788 -697
  34. package/dist/chunk-KY2GPCT7.js.map +1 -0
  35. package/dist/chunk-LUFA47FP.js +19 -0
  36. package/dist/chunk-LUFA47FP.js.map +1 -0
  37. package/dist/chunk-NNVOHKXJ.cjs +204 -0
  38. package/dist/chunk-NNVOHKXJ.cjs.map +1 -0
  39. package/dist/chunk-Q2VUO52Z.cjs +374 -0
  40. package/dist/chunk-Q2VUO52Z.cjs.map +1 -0
  41. package/dist/chunk-QDJLQR53.cjs +22 -0
  42. package/dist/chunk-QDJLQR53.cjs.map +1 -0
  43. package/dist/chunk-S4WAZC2T.cjs +173 -0
  44. package/dist/chunk-S4WAZC2T.cjs.map +1 -0
  45. package/dist/chunk-SMH6IOP2.js +368 -0
  46. package/dist/chunk-SMH6IOP2.js.map +1 -0
  47. package/dist/chunk-SR3MPV4D.js +237 -0
  48. package/dist/chunk-SR3MPV4D.js.map +1 -0
  49. package/dist/{chunk-UF2N5L63.cjs → chunk-TBW26OPP.cjs} +800 -710
  50. package/dist/chunk-TBW26OPP.cjs.map +1 -0
  51. package/dist/chunk-X2K3GIWE.js +235 -0
  52. package/dist/chunk-X2K3GIWE.js.map +1 -0
  53. package/dist/{chunk-L4NPOJ36.cjs → chunk-Z33SBWL5.cjs} +7 -4
  54. package/dist/chunk-Z33SBWL5.cjs.map +1 -0
  55. package/dist/chunk-ZCUXHW55.cjs +242 -0
  56. package/dist/chunk-ZCUXHW55.cjs.map +1 -0
  57. package/dist/element-browser.js +1282 -503
  58. package/dist/element-browser.js.map +1 -1
  59. package/dist/element.cjs +59 -5
  60. package/dist/element.cjs.map +1 -1
  61. package/dist/element.d.cts +39 -1
  62. package/dist/element.d.ts +39 -1
  63. package/dist/element.js +58 -4
  64. package/dist/element.js.map +1 -1
  65. package/dist/index.cjs +605 -327
  66. package/dist/index.cjs.map +1 -1
  67. package/dist/index.d.cts +48 -4
  68. package/dist/index.d.ts +48 -4
  69. package/dist/index.js +528 -319
  70. package/dist/index.js.map +1 -1
  71. package/dist/libav-demux-H2GS46GH.cjs +27 -0
  72. package/dist/{libav-http-reader-NQJVY273.js.map → libav-demux-H2GS46GH.cjs.map} +1 -1
  73. package/dist/libav-demux-OWZ4T2YW.js +6 -0
  74. package/dist/{libav-http-reader-FPYDBMYK.cjs.map → libav-demux-OWZ4T2YW.js.map} +1 -1
  75. package/dist/libav-http-reader-AZLE7YFS.cjs +16 -0
  76. package/dist/libav-http-reader-AZLE7YFS.cjs.map +1 -0
  77. package/dist/libav-http-reader-WXG3Z7AI.js +3 -0
  78. package/dist/libav-http-reader-WXG3Z7AI.js.map +1 -0
  79. package/dist/{libav-import-GST2AMPL.cjs → libav-import-2ZVKV2E7.cjs} +2 -2
  80. package/dist/{libav-import-GST2AMPL.cjs.map → libav-import-2ZVKV2E7.cjs.map} +1 -1
  81. package/dist/{libav-import-2JURFHEW.js → libav-import-6MGLCXVQ.js} +2 -2
  82. package/dist/{libav-import-2JURFHEW.js.map → libav-import-6MGLCXVQ.js.map} +1 -1
  83. package/dist/{player-U2NPmFvA.d.cts → player-B6WB74RD.d.cts} +62 -3
  84. package/dist/{player-U2NPmFvA.d.ts → player-B6WB74RD.d.ts} +62 -3
  85. package/dist/player.cjs +5631 -0
  86. package/dist/player.cjs.map +1 -0
  87. package/dist/player.d.cts +699 -0
  88. package/dist/player.d.ts +699 -0
  89. package/dist/player.js +5629 -0
  90. package/dist/player.js.map +1 -0
  91. package/dist/remux-OBSMIENG.cjs +35 -0
  92. package/dist/remux-OBSMIENG.cjs.map +1 -0
  93. package/dist/remux-WBYIZBBX.js +10 -0
  94. package/dist/remux-WBYIZBBX.js.map +1 -0
  95. package/dist/source-4TZ6KMNV.js +4 -0
  96. package/dist/{source-FFZ7TW2B.js.map → source-4TZ6KMNV.js.map} +1 -1
  97. package/dist/source-7YLO6E7X.cjs +29 -0
  98. package/dist/{source-CN43EI7Z.cjs.map → source-7YLO6E7X.cjs.map} +1 -1
  99. package/dist/source-MTX5ELUZ.js +4 -0
  100. package/dist/source-MTX5ELUZ.js.map +1 -0
  101. package/dist/source-VFLXLOCN.cjs +29 -0
  102. package/dist/source-VFLXLOCN.cjs.map +1 -0
  103. package/dist/subtitles-4T74JRGT.js +4 -0
  104. package/dist/subtitles-4T74JRGT.js.map +1 -0
  105. package/dist/subtitles-QUH4LPI4.cjs +29 -0
  106. package/dist/subtitles-QUH4LPI4.cjs.map +1 -0
  107. package/dist/variant-routing-434STYAB.js +3 -0
  108. package/dist/{variant-routing-JOBWXYKD.js.map → variant-routing-434STYAB.js.map} +1 -1
  109. package/dist/variant-routing-HONNAA6R.cjs +12 -0
  110. package/dist/{variant-routing-GOHB2RZN.cjs.map → variant-routing-HONNAA6R.cjs.map} +1 -1
  111. package/package.json +9 -1
  112. package/src/classify/rules.ts +27 -5
  113. package/src/convert/remux.ts +9 -35
  114. package/src/convert/transcode-libav.ts +691 -0
  115. package/src/convert/transcode.ts +53 -12
  116. package/src/element/avbridge-player.ts +861 -0
  117. package/src/element/avbridge-video.ts +54 -0
  118. package/src/element/player-icons.ts +25 -0
  119. package/src/element/player-styles.ts +472 -0
  120. package/src/errors.ts +53 -0
  121. package/src/index.ts +23 -0
  122. package/src/player-element.ts +18 -0
  123. package/src/player.ts +118 -27
  124. package/src/plugins/builtin.ts +2 -2
  125. package/src/probe/avi.ts +4 -0
  126. package/src/probe/index.ts +40 -10
  127. package/src/strategies/fallback/audio-output.ts +31 -0
  128. package/src/strategies/fallback/decoder.ts +179 -175
  129. package/src/strategies/fallback/index.ts +48 -6
  130. package/src/strategies/fallback/libav-import.ts +9 -1
  131. package/src/strategies/fallback/variant-routing.ts +7 -13
  132. package/src/strategies/fallback/video-renderer.ts +231 -32
  133. package/src/strategies/hybrid/decoder.ts +219 -200
  134. package/src/strategies/hybrid/index.ts +48 -7
  135. package/src/strategies/native.ts +6 -3
  136. package/src/strategies/remux/index.ts +14 -2
  137. package/src/strategies/remux/mse.ts +12 -2
  138. package/src/strategies/remux/pipeline.ts +72 -12
  139. package/src/subtitles/index.ts +7 -3
  140. package/src/subtitles/render.ts +8 -0
  141. package/src/types.ts +53 -1
  142. package/src/util/libav-demux.ts +405 -0
  143. package/src/util/libav-http-reader.ts +5 -1
  144. package/src/util/source.ts +28 -8
  145. package/src/util/transport.ts +26 -0
  146. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  147. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  148. package/dist/avi-6SJLWIWW.cjs.map +0 -1
  149. package/dist/avi-GCGM7OJI.js.map +0 -1
  150. package/dist/chunk-DMWARSEF.js.map +0 -1
  151. package/dist/chunk-HZLQNKFN.cjs.map +0 -1
  152. package/dist/chunk-ILKDNBSE.js.map +0 -1
  153. package/dist/chunk-J5MCMN3S.js +0 -27
  154. package/dist/chunk-J5MCMN3S.js.map +0 -1
  155. package/dist/chunk-L4NPOJ36.cjs.map +0 -1
  156. package/dist/chunk-NZU7W256.cjs +0 -29
  157. package/dist/chunk-NZU7W256.cjs.map +0 -1
  158. package/dist/chunk-UF2N5L63.cjs.map +0 -1
  159. package/dist/chunk-WD2ZNQA7.js.map +0 -1
  160. package/dist/libav-http-reader-FPYDBMYK.cjs +0 -16
  161. package/dist/libav-http-reader-NQJVY273.js +0 -3
  162. package/dist/source-CN43EI7Z.cjs +0 -28
  163. package/dist/source-FFZ7TW2B.js +0 -3
  164. package/dist/variant-routing-GOHB2RZN.cjs +0 -12
  165. package/dist/variant-routing-JOBWXYKD.js +0 -3
@@ -1,4 +1,5 @@
1
1
  import type { ClockSource } from "./audio-output.js";
2
+ import { SubtitleOverlay } from "../../subtitles/render.js";
2
3
 
3
4
  /**
4
5
  * Renders decoded `VideoFrame`s into a 2D canvas overlaid on the user's
@@ -20,6 +21,13 @@ import type { ClockSource } from "./audio-output.js";
20
21
  * decoder was still warming up, and every frame was already in the past by
21
22
  * the time it landed in the queue.
22
23
  */
24
+ // Periodic debug log — throttled to once per second so it doesn't
25
+ // flood the console at 60Hz rAF rate.
26
+ function isDebug(): boolean {
27
+ return typeof globalThis !== "undefined" && !!(globalThis as Record<string, unknown>).AVBRIDGE_DEBUG;
28
+ }
29
+ let lastDebugLog = 0;
30
+
23
31
  export class VideoRenderer {
24
32
  private canvas: HTMLCanvasElement;
25
33
  private ctx: CanvasRenderingContext2D;
@@ -35,6 +43,31 @@ export class VideoRenderer {
35
43
  private lastPaintWall = 0;
36
44
  /** Minimum ms between paints — paces video at roughly source fps. */
37
45
  private paintIntervalMs: number;
46
+ /** Cumulative count of frames skipped because all PTS are in the future. */
47
+ private ticksWaiting = 0;
48
+ /** Cumulative count of ticks where PTS mode painted a frame. */
49
+ private ticksPainted = 0;
50
+
51
+ /**
52
+ * Subtitle overlay div attached to the stage wrapper alongside the
53
+ * canvas. Created lazily when subtitle tracks are attached via the
54
+ * target's `<track>` children. Canvas strategies (hybrid, fallback)
55
+ * hide the <video>, so we can't rely on the browser's native cue
56
+ * rendering; we read TextTrack.cues and render into this overlay.
57
+ */
58
+ private subtitleOverlay: SubtitleOverlay | null = null;
59
+ private subtitleTrack: TextTrack | null = null;
60
+
61
+ /**
62
+ * Calibration offset (microseconds) between video PTS and audio clock.
63
+ * Video PTS and AudioContext.currentTime can drift ~0.1% relative to
64
+ * each other (different clock domains). Over 45 minutes that's 2.6s.
65
+ * We measure the offset on the first painted frame and update it
66
+ * periodically so the PTS comparison stays calibrated.
67
+ */
68
+ private ptsCalibrationUs = 0;
69
+ private ptsCalibrated = false;
70
+ private lastCalibrationWall = 0;
38
71
 
39
72
  /** Resolves once the first decoded frame has been enqueued. */
40
73
  readonly firstFrameReady: Promise<void>;
@@ -89,6 +122,15 @@ export class VideoRenderer {
89
122
  }
90
123
  target.style.visibility = "hidden";
91
124
 
125
+ // Create a subtitle overlay on the same parent as the canvas so cues
126
+ // appear over the rendered video. Shows nothing until a TextTrack
127
+ // gets attached via attachSubtitleTracks.
128
+ const overlayParent = parent instanceof HTMLElement ? parent : document.body;
129
+ this.subtitleOverlay = new SubtitleOverlay(overlayParent);
130
+ // Watch for <track> children on the target <video>. When one is
131
+ // added, grab its TextTrack and poll cues from it each tick.
132
+ this.watchTextTracks(target);
133
+
92
134
  const ctx = this.canvas.getContext("2d");
93
135
  if (!ctx) throw new Error("video renderer: failed to acquire 2D context");
94
136
  this.ctx = ctx;
@@ -134,10 +176,95 @@ export class VideoRenderer {
134
176
  }
135
177
  }
136
178
 
179
+ /**
180
+ * Watch the target <video>'s textTracks list. When a track is added,
181
+ * grab it and start polling cues on each render tick. Existing tracks
182
+ * (if any) are picked up immediately.
183
+ */
184
+ private watchTextTracks(target: HTMLVideoElement): void {
185
+ const pick = () => {
186
+ if (this.subtitleTrack) return;
187
+ const tracks = target.textTracks;
188
+ if (isDebug()) {
189
+ // eslint-disable-next-line no-console
190
+ console.log(`[avbridge:subs] watchTextTracks pick() — ${tracks.length} tracks`);
191
+ }
192
+ for (let i = 0; i < tracks.length; i++) {
193
+ const t = tracks[i];
194
+ if (isDebug()) {
195
+ // eslint-disable-next-line no-console
196
+ console.log(`[avbridge:subs] track ${i}: kind=${t.kind} mode=${t.mode} cues=${t.cues?.length ?? 0}`);
197
+ }
198
+ if (t.kind === "subtitles" || t.kind === "captions") {
199
+ this.subtitleTrack = t;
200
+ t.mode = "hidden"; // hidden means "cues available via API, don't render"
201
+ if (isDebug()) {
202
+ // eslint-disable-next-line no-console
203
+ console.log(`[avbridge:subs] picked track, mode=hidden`);
204
+ }
205
+ // Listen for cue load completion
206
+ const trackEl = target.querySelector(`track[srclang="${t.language}"]`) as HTMLTrackElement | null;
207
+ if (trackEl) {
208
+ trackEl.addEventListener("load", () => {
209
+ if (isDebug()) {
210
+ // eslint-disable-next-line no-console
211
+ console.log(`[avbridge:subs] track element loaded, cues=${t.cues?.length ?? 0}`);
212
+ }
213
+ });
214
+ trackEl.addEventListener("error", (ev) => {
215
+ // eslint-disable-next-line no-console
216
+ console.warn(`[avbridge:subs] track element error:`, ev);
217
+ });
218
+ }
219
+ break;
220
+ }
221
+ }
222
+ };
223
+ pick();
224
+ if (typeof target.textTracks.addEventListener === "function") {
225
+ target.textTracks.addEventListener("addtrack", (e) => {
226
+ if (isDebug()) {
227
+ // eslint-disable-next-line no-console
228
+ console.log("[avbridge:subs] addtrack event fired");
229
+ }
230
+ void e;
231
+ pick();
232
+ });
233
+ }
234
+ }
235
+
236
+ private _loggedCues = false;
237
+
238
+ /** Find the active cue (if any) for the given media time. */
239
+ private updateSubtitles(): void {
240
+ if (!this.subtitleOverlay || !this.subtitleTrack) return;
241
+ const cues = this.subtitleTrack.cues;
242
+ if (!cues || cues.length === 0) return;
243
+ if (isDebug() && !this._loggedCues) {
244
+ this._loggedCues = true;
245
+ // eslint-disable-next-line no-console
246
+ console.log(`[avbridge:subs] cues available: ${cues.length}, first start=${cues[0].startTime}, last end=${cues[cues.length-1].endTime}`);
247
+ }
248
+ const t = this.clock.now();
249
+ let activeText = "";
250
+ for (let i = 0; i < cues.length; i++) {
251
+ const c = cues[i];
252
+ if (t >= c.startTime && t <= c.endTime) {
253
+ const vttCue = c as VTTCue & { text?: string };
254
+ activeText = vttCue.text ?? "";
255
+ break;
256
+ }
257
+ }
258
+ // Strip VTT tags for plain rendering (e.g. <c.en> voice tags)
259
+ this.subtitleOverlay.setText(activeText.replace(/<[^>]+>/g, ""));
260
+ }
261
+
137
262
  private tick(): void {
138
263
  if (this.destroyed) return;
139
264
  this.rafHandle = requestAnimationFrame(this.tick);
140
265
 
266
+ this.updateSubtitles();
267
+
141
268
  if (this.queue.length === 0) return;
142
269
 
143
270
  const playing = this.clock.isPlaying();
@@ -154,45 +281,109 @@ export class VideoRenderer {
154
281
  return;
155
282
  }
156
283
 
157
- // Wall-clock-paced painting with coarse A/V drift correction.
158
- //
159
- // Base policy: paint one frame every `paintIntervalMs` of wall time,
160
- // regardless of the frame's synthetic timestamp. This avoids the old
161
- // per-frame audio-gate that caused massive overflow during decode bursts.
284
+ // PTS-based painting: find the latest frame whose presentation time
285
+ // has arrived (timestamp ≤ audio clock), paint it, and discard any
286
+ // older frames. This produces correct cadence at any display refresh
287
+ // rate and any source fps no 3:2 pulldown artifacts.
162
288
  //
163
- // Drift correction (runs every ~1 sec):
164
- // - Video > 150 ms behind audio → drop one frame (catch up)
165
- // - Video > 150 ms ahead of audio → skip one paint (let audio catch up)
166
- //
167
- // This keeps long-run sync robust even for legacy AVI/DivX with messy
168
- // timestamps, packed B-frames, and odd frame durations. The correction
169
- // is deliberately gentle (one frame at a time) so it doesn't cause
170
- // visible stuttering.
171
- const wallNow = performance.now();
172
- if (wallNow - this.lastPaintWall < this.paintIntervalMs - 2) return;
289
+ // Fallback: if frame timestamps are unreliable (all zero, synthetic),
290
+ // fall back to wall-clock pacing as before.
291
+ const rawAudioNowUs = this.clock.now() * 1_000_000;
292
+ const headTs = this.queue[0].timestamp ?? 0;
293
+ const hasPts = headTs > 0 || this.queue.length > 1;
173
294
 
174
- if (this.queue.length === 0) return;
295
+ if (hasPts) {
296
+ // Calibration: video PTS and audio clock (AudioContext.currentTime)
297
+ // live in different clock domains with a fixed offset (different epoch)
298
+ // plus a small rate drift (~7ms/s). We snap the offset on first paint
299
+ // and re-snap every 10 seconds. Between snaps, max drift is ~70ms
300
+ // (under 2 frames at 24fps, below lip-sync perception threshold).
301
+ const wallNow = performance.now();
302
+ if (!this.ptsCalibrated || wallNow - this.lastCalibrationWall > 10_000) {
303
+ this.ptsCalibrationUs = headTs - rawAudioNowUs;
304
+ this.ptsCalibrated = true;
305
+ this.lastCalibrationWall = wallNow;
306
+ }
307
+
308
+ const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
309
+ const frameDurationUs = this.paintIntervalMs * 1000;
310
+ const deadlineUs = audioNowUs + frameDurationUs;
175
311
 
176
- // Coarse drift correction: compare the head frame's timestamp to
177
- // audio.now() every ~1 sec (every 30 frames at 30fps). The frame ts
178
- // and audio.now() are both in seconds of media time. Drift beyond
179
- // 150ms triggers gentle correction — one frame per check, not a burst.
180
- if (this.framesPainted > 0 && this.framesPainted % 30 === 0) {
181
- const audioNowUs = this.clock.now() * 1_000_000;
182
- const headTs = this.queue[0].timestamp ?? 0;
183
- const driftUs = headTs - audioNowUs;
184
-
185
- if (driftUs < -150_000) {
186
- // Video behind audio by > 150ms — drop one frame to catch up.
187
- this.queue.shift()?.close();
188
- this.framesDroppedLate++;
189
- if (this.queue.length === 0) return;
190
- } else if (driftUs > 150_000) {
191
- // Video ahead of audio by > 150ms — skip this paint cycle.
312
+ let bestIdx = -1;
313
+ for (let i = 0; i < this.queue.length; i++) {
314
+ const ts = this.queue[i].timestamp ?? 0;
315
+ if (ts <= deadlineUs) {
316
+ bestIdx = i;
317
+ } else {
318
+ break;
319
+ }
320
+ }
321
+
322
+ if (bestIdx < 0) {
323
+ this.ticksWaiting++;
324
+ if (isDebug()) {
325
+ const now = performance.now();
326
+ if (now - lastDebugLog > 1000) {
327
+ const headPtsMs = (headTs / 1000).toFixed(1);
328
+ const audioMs = (audioNowUs / 1000).toFixed(1);
329
+ const rawDriftMs = ((headTs - rawAudioNowUs) / 1000).toFixed(1);
330
+ const calibMs = (this.ptsCalibrationUs / 1000).toFixed(1);
331
+ // eslint-disable-next-line no-console
332
+ console.log(
333
+ `[avbridge:renderer] WAIT q=${this.queue.length} headPTS=${headPtsMs}ms calibAudio=${audioMs}ms ` +
334
+ `rawDrift=${rawDriftMs}ms calib=${calibMs}ms painted=${this.framesPainted} dropped=${this.framesDroppedLate}`,
335
+ );
336
+ lastDebugLog = now;
337
+ }
338
+ }
192
339
  return;
193
340
  }
341
+
342
+ // Only drop frames that are more than 2 frame-durations behind.
343
+ const dropThresholdUs = audioNowUs - frameDurationUs * 2;
344
+ let dropped = 0;
345
+ while (bestIdx > 0) {
346
+ const ts = this.queue[0].timestamp ?? 0;
347
+ if (ts < dropThresholdUs) {
348
+ this.queue.shift()?.close();
349
+ this.framesDroppedLate++;
350
+ bestIdx--;
351
+ dropped++;
352
+ } else {
353
+ break;
354
+ }
355
+ }
356
+
357
+ this.ticksPainted++;
358
+
359
+ if (isDebug()) {
360
+ const now = performance.now();
361
+ if (now - lastDebugLog > 1000) {
362
+ const paintedTs = (this.queue[0]?.timestamp ?? 0);
363
+ const audioMs = (audioNowUs / 1000).toFixed(1);
364
+ const ptsMs = (paintedTs / 1000).toFixed(1);
365
+ const rawDriftMs = ((paintedTs - rawAudioNowUs) / 1000).toFixed(1);
366
+ const calibMs = (this.ptsCalibrationUs / 1000).toFixed(1);
367
+ // eslint-disable-next-line no-console
368
+ console.log(
369
+ `[avbridge:renderer] PAINT q=${this.queue.length} calibAudio=${audioMs}ms nextPTS=${ptsMs}ms ` +
370
+ `rawDrift=${rawDriftMs}ms calib=${calibMs}ms dropped=${dropped} total_drops=${this.framesDroppedLate} painted=${this.framesPainted}`,
371
+ );
372
+ lastDebugLog = now;
373
+ }
374
+ }
375
+
376
+ const frame = this.queue.shift()!;
377
+ this.paint(frame);
378
+ frame.close();
379
+ this.lastPaintWall = performance.now();
380
+ return;
194
381
  }
195
382
 
383
+ // Wall-clock fallback: used when timestamps are unreliable (all zero).
384
+ const wallNow = performance.now();
385
+ if (wallNow - this.lastPaintWall < this.paintIntervalMs - 2) return;
386
+
196
387
  const frame = this.queue.shift()!;
197
388
  this.paint(frame);
198
389
  frame.close();
@@ -222,8 +413,14 @@ export class VideoRenderer {
222
413
 
223
414
  /** Discard all queued frames. Used by seek to drop stale buffers. */
224
415
  flush(): void {
416
+ const count = this.queue.length;
225
417
  while (this.queue.length > 0) this.queue.shift()?.close();
226
418
  this.prerolled = false;
419
+ this.ptsCalibrated = false; // recalibrate at new seek position
420
+ if (isDebug() && count > 0) {
421
+ // eslint-disable-next-line no-console
422
+ console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
423
+ }
227
424
  }
228
425
 
229
426
  stats(): Record<string, unknown> {
@@ -239,6 +436,8 @@ export class VideoRenderer {
239
436
  this.destroyed = true;
240
437
  if (this.rafHandle != null) cancelAnimationFrame(this.rafHandle);
241
438
  this.flush();
439
+ if (this.subtitleOverlay) { this.subtitleOverlay.destroy(); this.subtitleOverlay = null; }
440
+ this.subtitleTrack = null;
242
441
  this.canvas.remove();
243
442
  this.target.style.visibility = "";
244
443
  }