avbridge 2.3.0 → 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 (104) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/{chunk-6UUT4BEA.cjs → chunk-2IJ66NTD.cjs} +13 -20
  3. package/dist/chunk-2IJ66NTD.cjs.map +1 -0
  4. package/dist/{chunk-XKPSTC34.cjs → chunk-2XW2O3YI.cjs} +5 -20
  5. package/dist/chunk-2XW2O3YI.cjs.map +1 -0
  6. package/dist/chunk-5KVLE6YI.js +167 -0
  7. package/dist/chunk-5KVLE6YI.js.map +1 -0
  8. package/dist/{chunk-2PGRFCWB.js → chunk-CPJLFFCC.js} +8 -18
  9. package/dist/chunk-CPJLFFCC.js.map +1 -0
  10. package/dist/chunk-CPZ7PXAM.cjs +240 -0
  11. package/dist/chunk-CPZ7PXAM.cjs.map +1 -0
  12. package/dist/{chunk-QQXBPW72.js → chunk-E76AMWI4.js} +4 -18
  13. package/dist/chunk-E76AMWI4.js.map +1 -0
  14. package/dist/{chunk-NV7ILLWH.js → chunk-KY2GPCT7.js} +347 -665
  15. package/dist/chunk-KY2GPCT7.js.map +1 -0
  16. package/dist/chunk-LUFA47FP.js +19 -0
  17. package/dist/chunk-LUFA47FP.js.map +1 -0
  18. package/dist/chunk-Q2VUO52Z.cjs +374 -0
  19. package/dist/chunk-Q2VUO52Z.cjs.map +1 -0
  20. package/dist/chunk-QDJLQR53.cjs +22 -0
  21. package/dist/chunk-QDJLQR53.cjs.map +1 -0
  22. package/dist/chunk-S4WAZC2T.cjs +173 -0
  23. package/dist/chunk-S4WAZC2T.cjs.map +1 -0
  24. package/dist/chunk-SMH6IOP2.js +368 -0
  25. package/dist/chunk-SMH6IOP2.js.map +1 -0
  26. package/dist/chunk-SR3MPV4D.js +237 -0
  27. package/dist/chunk-SR3MPV4D.js.map +1 -0
  28. package/dist/{chunk-7RGG6ME7.cjs → chunk-TBW26OPP.cjs} +365 -688
  29. package/dist/chunk-TBW26OPP.cjs.map +1 -0
  30. package/dist/chunk-X2K3GIWE.js +235 -0
  31. package/dist/chunk-X2K3GIWE.js.map +1 -0
  32. package/dist/chunk-ZCUXHW55.cjs +242 -0
  33. package/dist/chunk-ZCUXHW55.cjs.map +1 -0
  34. package/dist/element-browser.js +799 -493
  35. package/dist/element-browser.js.map +1 -1
  36. package/dist/element.cjs +58 -4
  37. package/dist/element.cjs.map +1 -1
  38. package/dist/element.d.cts +38 -0
  39. package/dist/element.d.ts +38 -0
  40. package/dist/element.js +57 -3
  41. package/dist/element.js.map +1 -1
  42. package/dist/index.cjs +523 -393
  43. package/dist/index.cjs.map +1 -1
  44. package/dist/index.js +494 -366
  45. package/dist/index.js.map +1 -1
  46. package/dist/libav-demux-H2GS46GH.cjs +27 -0
  47. package/dist/libav-demux-H2GS46GH.cjs.map +1 -0
  48. package/dist/libav-demux-OWZ4T2YW.js +6 -0
  49. package/dist/libav-demux-OWZ4T2YW.js.map +1 -0
  50. package/dist/{libav-import-GST2AMPL.cjs → libav-import-2ZVKV2E7.cjs} +2 -2
  51. package/dist/{libav-import-GST2AMPL.cjs.map → libav-import-2ZVKV2E7.cjs.map} +1 -1
  52. package/dist/{libav-import-2JURFHEW.js → libav-import-6MGLCXVQ.js} +2 -2
  53. package/dist/{libav-import-2JURFHEW.js.map → libav-import-6MGLCXVQ.js.map} +1 -1
  54. package/dist/player.cjs +601 -470
  55. package/dist/player.cjs.map +1 -1
  56. package/dist/player.d.cts +50 -0
  57. package/dist/player.d.ts +50 -0
  58. package/dist/player.js +580 -449
  59. package/dist/player.js.map +1 -1
  60. package/dist/remux-OBSMIENG.cjs +35 -0
  61. package/dist/remux-OBSMIENG.cjs.map +1 -0
  62. package/dist/remux-WBYIZBBX.js +10 -0
  63. package/dist/remux-WBYIZBBX.js.map +1 -0
  64. package/dist/source-4TZ6KMNV.js +4 -0
  65. package/dist/{source-F656KYYV.js.map → source-4TZ6KMNV.js.map} +1 -1
  66. package/dist/source-7YLO6E7X.cjs +29 -0
  67. package/dist/{source-73CAH6HW.cjs.map → source-7YLO6E7X.cjs.map} +1 -1
  68. package/dist/source-MTX5ELUZ.js +4 -0
  69. package/dist/{source-QJR3OHTW.js.map → source-MTX5ELUZ.js.map} +1 -1
  70. package/dist/source-VFLXLOCN.cjs +29 -0
  71. package/dist/{source-VB74JQ7Z.cjs.map → source-VFLXLOCN.cjs.map} +1 -1
  72. package/dist/subtitles-4T74JRGT.js +4 -0
  73. package/dist/subtitles-4T74JRGT.js.map +1 -0
  74. package/dist/subtitles-QUH4LPI4.cjs +29 -0
  75. package/dist/subtitles-QUH4LPI4.cjs.map +1 -0
  76. package/package.json +1 -1
  77. package/src/convert/remux.ts +1 -35
  78. package/src/convert/transcode-libav.ts +691 -0
  79. package/src/convert/transcode.ts +12 -4
  80. package/src/element/avbridge-player.ts +16 -0
  81. package/src/element/avbridge-video.ts +54 -0
  82. package/src/errors.ts +6 -0
  83. package/src/player.ts +15 -16
  84. package/src/strategies/fallback/decoder.ts +96 -173
  85. package/src/strategies/fallback/index.ts +19 -2
  86. package/src/strategies/fallback/libav-import.ts +9 -1
  87. package/src/strategies/fallback/video-renderer.ts +107 -0
  88. package/src/strategies/hybrid/decoder.ts +88 -180
  89. package/src/strategies/hybrid/index.ts +17 -2
  90. package/src/strategies/native.ts +6 -3
  91. package/src/strategies/remux/index.ts +14 -2
  92. package/src/strategies/remux/pipeline.ts +72 -12
  93. package/src/subtitles/render.ts +8 -0
  94. package/src/util/libav-demux.ts +405 -0
  95. package/dist/chunk-2PGRFCWB.js.map +0 -1
  96. package/dist/chunk-6UUT4BEA.cjs.map +0 -1
  97. package/dist/chunk-7RGG6ME7.cjs.map +0 -1
  98. package/dist/chunk-NV7ILLWH.js.map +0 -1
  99. package/dist/chunk-QQXBPW72.js.map +0 -1
  100. package/dist/chunk-XKPSTC34.cjs.map +0 -1
  101. package/dist/source-73CAH6HW.cjs +0 -28
  102. package/dist/source-F656KYYV.js +0 -3
  103. package/dist/source-QJR3OHTW.js +0 -3
  104. package/dist/source-VB74JQ7Z.cjs +0 -28
@@ -16,6 +16,8 @@
16
16
  import { probe } from "../probe/index.js";
17
17
  import { buildMediabunnySourceFromInput } from "../probe/mediabunny.js";
18
18
  import { createOutputFormat, mimeForFormat, generateFilename } from "./remux.js";
19
+ import { isLibavTranscodeContainer, transcodeViaLibav } from "./transcode-libav.js";
20
+ import { AvbridgeError, ERR_CONTAINER_NOT_SUPPORTED } from "../errors.js";
19
21
  import type {
20
22
  MediaInput,
21
23
  MediaContext,
@@ -54,11 +56,17 @@ export async function transcode(
54
56
  const ctx = await probe(source);
55
57
  options.signal?.throwIfAborted();
56
58
 
59
+ // AVI/ASF/FLV → the libav-demux-backed pipeline (Phase 1: MP4 output only).
60
+ if (isLibavTranscodeContainer(ctx.container)) {
61
+ return transcodeViaLibav(ctx, options);
62
+ }
63
+
57
64
  if (!MEDIABUNNY_CONTAINERS.has(ctx.container)) {
58
- throw new Error(
59
- `Cannot transcode "${ctx.container}" sources in v1. ` +
60
- `transcode() only supports inputs that mediabunny can read (MP4, MKV, WebM, OGG, MP3, FLAC, WAV, MOV). ` +
61
- `For AVI/ASF/FLV sources, use the player's playback strategies instead.`,
65
+ throw new AvbridgeError(
66
+ ERR_CONTAINER_NOT_SUPPORTED,
67
+ `Cannot transcode "${ctx.container}" sources. ` +
68
+ `transcode() supports mediabunny-readable containers (MP4, MKV, WebM, OGG, MP3, FLAC, WAV, MOV) and legacy containers via the libav path (AVI, ASF, FLV).`,
69
+ `If this is a legacy container we don't yet support, use createPlayer() to play it. Transcode support for more containers is on the roadmap.`,
62
70
  );
63
71
  }
64
72
 
@@ -824,6 +824,22 @@ export class AvbridgePlayerElement extends HTMLElement {
824
824
  get strategyClass(): string | undefined { return this._video.strategyClass ?? undefined; }
825
825
  get audioTracks(): unknown[] { return this._video.audioTracks ?? []; }
826
826
  get subtitleTracks(): unknown[] { return this._video.subtitleTracks ?? []; }
827
+
828
+ /**
829
+ * External subtitle files to attach when the source loads. Forwarded
830
+ * to the inner <avbridge-video>. Takes effect on next bootstrap.
831
+ */
832
+ get subtitles(): unknown {
833
+ return (this._video as unknown as { subtitles: unknown }).subtitles;
834
+ }
835
+ set subtitles(value: unknown) {
836
+ (this._video as unknown as { subtitles: unknown }).subtitles = value;
837
+ }
838
+
839
+ /** Attach a subtitle track to the current playback without a reload. */
840
+ async addSubtitle(subtitle: { url: string; language?: string; format?: "vtt" | "srt" }): Promise<void> {
841
+ return (this._video as unknown as { addSubtitle: (s: unknown) => Promise<void> }).addSubtitle(subtitle);
842
+ }
827
843
  get player(): unknown { return this._video.player; }
828
844
  get videoElement(): HTMLVideoElement { return this._video.videoElement; }
829
845
 
@@ -147,6 +147,14 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
147
147
  private _audioTracks: AudioTrackInfo[] = [];
148
148
  private _subtitleTracks: SubtitleTrackInfo[] = [];
149
149
 
150
+ /**
151
+ * External subtitle list forwarded to `createPlayer()` on the next
152
+ * bootstrap. Setting this after bootstrap queues it for the next
153
+ * source change; consumers that need to swap subtitles mid-playback
154
+ * should set `source` to reload.
155
+ */
156
+ private _subtitles: Array<{ url: string; language?: string; format?: "vtt" | "srt" }> | null = null;
157
+
150
158
  /**
151
159
  * Initial strategy preference. `"auto"` means "let the classifier decide";
152
160
  * any other value is passed to `createPlayer({ initialStrategy })` and
@@ -358,6 +366,7 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
358
366
  ...(this._preferredStrategy !== "auto"
359
367
  ? { initialStrategy: this._preferredStrategy }
360
368
  : {}),
369
+ ...(this._subtitles ? { subtitles: this._subtitles } : {}),
361
370
  });
362
371
  } catch (err) {
363
372
  // Stale or destroyed — silently abandon.
@@ -709,6 +718,51 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
709
718
  return this._subtitleTracks;
710
719
  }
711
720
 
721
+ /**
722
+ * External subtitle files to attach when the source loads. Takes effect
723
+ * on the next bootstrap — set before assigning `source`, or reload via
724
+ * `load()` after changing. For dynamic post-bootstrap addition, use
725
+ * `addSubtitle()` instead.
726
+ *
727
+ * @example
728
+ * el.subtitles = [{ url: "/en.srt", format: "srt", language: "en" }];
729
+ * el.src = "/movie.mp4";
730
+ */
731
+ get subtitles(): Array<{ url: string; language?: string; format?: "vtt" | "srt" }> | null {
732
+ return this._subtitles;
733
+ }
734
+
735
+ set subtitles(value: Array<{ url: string; language?: string; format?: "vtt" | "srt" }> | null) {
736
+ this._subtitles = value;
737
+ }
738
+
739
+ /**
740
+ * Attach a subtitle track to the current playback without rebuilding
741
+ * the player. Works while the element is playing — converts SRT to
742
+ * VTT if needed, adds a `<track>` to the inner `<video>`. Canvas
743
+ * strategies pick up the new track via their textTracks watcher.
744
+ */
745
+ async addSubtitle(subtitle: { url: string; language?: string; format?: "vtt" | "srt" }): Promise<void> {
746
+ const { attachSubtitleTracks } = await import("../subtitles/index.js");
747
+ const format = subtitle.format ?? (subtitle.url.endsWith(".srt") ? "srt" : "vtt");
748
+ const track = {
749
+ id: this._subtitleTracks.length,
750
+ format,
751
+ language: subtitle.language,
752
+ sidecarUrl: subtitle.url,
753
+ };
754
+ this._subtitleTracks.push(track);
755
+ await attachSubtitleTracks(
756
+ this._videoEl,
757
+ this._subtitleTracks,
758
+ undefined,
759
+ (err, t) => {
760
+ // eslint-disable-next-line no-console
761
+ console.warn(`[avbridge] subtitle ${t.id} failed: ${err.message}`);
762
+ },
763
+ );
764
+ }
765
+
712
766
  // ── Public methods ─────────────────────────────────────────────────────
713
767
 
714
768
  /** Force a (re-)bootstrap if a source is currently set. */
package/src/errors.ts CHANGED
@@ -45,3 +45,9 @@ export const ERR_LIBAV_NOT_REACHABLE = "ERR_AVBRIDGE_LIBAV_NOT_REACHABLE";
45
45
  // MSE
46
46
  export const ERR_MSE_NOT_SUPPORTED = "ERR_AVBRIDGE_MSE_NOT_SUPPORTED";
47
47
  export const ERR_MSE_CODEC_NOT_SUPPORTED = "ERR_AVBRIDGE_MSE_CODEC_NOT_SUPPORTED";
48
+
49
+ // Transcode
50
+ export const ERR_TRANSCODE_ABORTED = "ERR_AVBRIDGE_TRANSCODE_ABORTED";
51
+ export const ERR_TRANSCODE_UNSUPPORTED_COMBO = "ERR_AVBRIDGE_TRANSCODE_UNSUPPORTED_COMBO";
52
+ export const ERR_TRANSCODE_DECODE = "ERR_AVBRIDGE_TRANSCODE_DECODE";
53
+ export const ERR_CONTAINER_NOT_SUPPORTED = "ERR_AVBRIDGE_CONTAINER_NOT_SUPPORTED";
package/src/player.ts CHANGED
@@ -147,22 +147,21 @@ export class UnifiedPlayer {
147
147
  // Try the primary strategy, falling through the chain on failure
148
148
  await this.startSession(decision.strategy, decision.reason);
149
149
 
150
- // Apply subtitles for non-canvas strategies. Per-track failures are
151
- // caught inside attachSubtitleTracks and logged via console.warn
152
- // subtitles are not load-bearing, so a bad sidecar must not break
153
- // bootstrap.
154
- if (this.session!.strategy !== "fallback" && this.session!.strategy !== "hybrid") {
155
- await attachSubtitleTracks(
156
- this.options.target,
157
- ctx.subtitleTracks,
158
- this.subtitleResources,
159
- (err, track) => {
160
- // eslint-disable-next-line no-console
161
- console.warn(`[avbridge] subtitle ${track.id} failed: ${err.message}`);
162
- },
163
- this.transport,
164
- );
165
- }
150
+ // Apply subtitles for all strategies. Native/remux render them via
151
+ // the inner <video>'s native text-track engine. Hybrid/fallback
152
+ // hide the <video> and render cues into the canvas overlay — see
153
+ // each session's SubtitleOverlay wiring. The <track> elements are
154
+ // attached in both cases so cues are parsed by the browser.
155
+ await attachSubtitleTracks(
156
+ this.options.target,
157
+ ctx.subtitleTracks,
158
+ this.subtitleResources,
159
+ (err, track) => {
160
+ // eslint-disable-next-line no-console
161
+ console.warn(`[avbridge] subtitle ${track.id} failed: ${err.message}`);
162
+ },
163
+ this.transport,
164
+ );
166
165
 
167
166
  this.emitter.emitSticky("tracks", {
168
167
  video: ctx.videoTracks,
@@ -29,11 +29,22 @@ import { AudioOutput } from "./audio-output.js";
29
29
  import type { MediaContext } from "../../types.js";
30
30
  import { pickLibavVariant } from "./variant-routing.js";
31
31
  import { dbg } from "../../util/debug.js";
32
+ import {
33
+ sanitizeFrameTimestamp,
34
+ libavFrameToInterleavedFloat32,
35
+ } from "../../util/libav-demux.js";
32
36
 
33
37
  export interface DecoderHandles {
34
38
  destroy(): Promise<void>;
35
39
  /** Seek to the given time in seconds. Returns once the new pump has been kicked off. */
36
40
  seek(timeSec: number): Promise<void>;
41
+ /**
42
+ * Switch the active audio track. The decoder tears down the current audio
43
+ * decoder, initializes one for the stream whose container id matches
44
+ * `trackId` (== libav `stream.index`), seeks the demuxer to `timeSec`, and
45
+ * restarts the pump. No-op if the track is already active.
46
+ */
47
+ setAudioTrack(trackId: number, timeSec: number): Promise<void>;
37
48
  stats(): Record<string, unknown>;
38
49
  }
39
50
 
@@ -63,7 +74,15 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
63
74
 
64
75
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
65
76
  const videoStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_VIDEO) ?? null;
66
- const audioStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_AUDIO) ?? null;
77
+ // Audio stream is mutable so setAudioTrack() can swap it. Default to the
78
+ // track the context picked first (matches probe ordering). We resolve by
79
+ // container id so the selection survives stream reordering.
80
+ const firstAudioTrackId = opts.context.audioTracks[0]?.id;
81
+ let audioStream: LibavStream | null =
82
+ (firstAudioTrackId != null
83
+ ? streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_AUDIO && s.index === firstAudioTrackId)
84
+ : undefined) ??
85
+ streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_AUDIO) ?? null;
67
86
 
68
87
  if (!videoStream && !audioStream) {
69
88
  throw new Error("fallback decoder: file has no decodable streams");
@@ -376,7 +395,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
376
395
 
377
396
  for (const f of frames) {
378
397
  if (myToken !== pumpToken || destroyed) return;
379
- const bridgeOpts = sanitizeFrameTimestamp(
398
+ sanitizeFrameTimestamp(
380
399
  f,
381
400
  () => {
382
401
  const ts = syntheticVideoUs;
@@ -385,8 +404,10 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
385
404
  },
386
405
  videoTimeBase,
387
406
  );
407
+ // sanitizeFrameTimestamp normalizes pts to µs, so the bridge can
408
+ // always use the 1/1e6 timebase.
388
409
  try {
389
- const vf = bridge.laFrameToVideoFrame(f, bridgeOpts);
410
+ const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1_000_000] });
390
411
  opts.renderer.enqueue(vf);
391
412
  videoFramesDecoded++;
392
413
  } catch (err) {
@@ -455,6 +476,78 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
455
476
  try { await inputHandle.detach(); } catch { /* ignore */ }
456
477
  },
457
478
 
479
+ async setAudioTrack(trackId, timeSec) {
480
+ if (audioStream && audioStream.index === trackId) return;
481
+ const newStream = streams.find(
482
+ (s) => s.codec_type === libav.AVMEDIA_TYPE_AUDIO && s.index === trackId,
483
+ );
484
+ if (!newStream) {
485
+ console.warn("[avbridge] fallback: setAudioTrack — no stream with id", trackId);
486
+ return;
487
+ }
488
+
489
+ // Stop the pump before touching libav state. Same discipline as seek().
490
+ const newToken = ++pumpToken;
491
+ if (pumpRunning) {
492
+ try { await pumpRunning; } catch { /* ignore */ }
493
+ }
494
+ if (destroyed) return;
495
+
496
+ // Tear down the old audio decoder and init a fresh one for the new stream.
497
+ if (audioDec) {
498
+ try { await libav.ff_free_decoder?.(audioDec.c, audioDec.pkt, audioDec.frame); } catch { /* ignore */ }
499
+ audioDec = null;
500
+ }
501
+ try {
502
+ const [, c, pkt, frame] = await libav.ff_init_decoder(newStream.codec_id, {
503
+ codecpar: newStream.codecpar,
504
+ });
505
+ audioDec = { c, pkt, frame };
506
+ audioTimeBase = newStream.time_base_num && newStream.time_base_den
507
+ ? [newStream.time_base_num, newStream.time_base_den]
508
+ : undefined;
509
+ } catch (err) {
510
+ console.warn(
511
+ "[avbridge] fallback: setAudioTrack init failed — falling back to no-audio mode:",
512
+ (err as Error).message,
513
+ );
514
+ audioDec = null;
515
+ opts.audio.setNoAudio();
516
+ }
517
+
518
+ audioStream = newStream;
519
+
520
+ // Re-seek so packets resume from the user's current position for the
521
+ // new track (and the same video position).
522
+ try {
523
+ const tsUs = Math.floor(timeSec * 1_000_000);
524
+ const [tsLo, tsHi] = libav.f64toi64
525
+ ? libav.f64toi64(tsUs)
526
+ : [tsUs | 0, Math.floor(tsUs / 0x100000000)];
527
+ await libav.av_seek_frame(
528
+ fmt_ctx,
529
+ -1,
530
+ tsLo,
531
+ tsHi,
532
+ libav.AVSEEK_FLAG_BACKWARD ?? 0,
533
+ );
534
+ } catch (err) {
535
+ console.warn("[avbridge] fallback: setAudioTrack seek failed:", err);
536
+ }
537
+
538
+ // Flush the video decoder too — we just moved the demuxer back to a
539
+ // keyframe boundary.
540
+ try { if (videoDec) await libav.avcodec_flush_buffers?.(videoDec.c); } catch { /* ignore */ }
541
+ await flushBSF();
542
+
543
+ syntheticVideoUs = Math.round(timeSec * 1_000_000);
544
+ syntheticAudioUs = Math.round(timeSec * 1_000_000);
545
+
546
+ pumpRunning = pumpLoop(newToken).catch((err) =>
547
+ console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err),
548
+ );
549
+ },
550
+
458
551
  async seek(timeSec) {
459
552
  // Cancel the current pump and wait for it to actually exit before
460
553
  // we start moving file pointers around — concurrent ff_decode_multi
@@ -537,176 +630,6 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
537
630
  };
538
631
  }
539
632
 
540
- // ─────────────────────────────────────────────────────────────────────────────
541
- // Frame timestamp sanitizer.
542
- //
543
- // libav can hand back decoded frames with `pts = AV_NOPTS_VALUE` (encoded as
544
- // ptshi = -2147483648, pts = 0) for inputs whose demuxer can't determine
545
- // presentation times. AVI is the canonical example. The bridge's
546
- // `laFrameToVideoFrame` then multiplies pts × 1e6 × tbNum / tbDen and
547
- // overflows int64, throwing "Value is outside the 'long long' value range".
548
- //
549
- // Fix: replace any invalid pts with a synthetic microsecond counter, force
550
- // the frame's pts/ptshi to that value, and tell the bridge to use a 1/1e6
551
- // timebase so it does an identity conversion.
552
- // ─────────────────────────────────────────────────────────────────────────────
553
-
554
- interface BridgeOpts {
555
- timeBase?: [number, number];
556
- transfer?: boolean;
557
- }
558
-
559
- function sanitizeFrameTimestamp(
560
- frame: LibavFrame,
561
- nextUs: () => number,
562
- fallbackTimeBase?: [number, number],
563
- ): BridgeOpts {
564
- const lo = frame.pts ?? 0;
565
- const hi = frame.ptshi ?? 0;
566
- const isInvalid = (hi === -2147483648 && lo === 0) || !Number.isFinite(lo);
567
- if (isInvalid) {
568
- const us = nextUs();
569
- frame.pts = us;
570
- frame.ptshi = 0;
571
- return { timeBase: [1, 1_000_000] };
572
- }
573
- const tb = fallbackTimeBase ?? [1, 1_000_000];
574
- const pts64 = hi * 0x100000000 + lo;
575
- const us = Math.round((pts64 * 1_000_000 * tb[0]) / tb[1]);
576
- if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
577
- frame.pts = us;
578
- frame.ptshi = us < 0 ? -1 : 0;
579
- return { timeBase: [1, 1_000_000] };
580
- }
581
- const fallback = nextUs();
582
- frame.pts = fallback;
583
- frame.ptshi = 0;
584
- return { timeBase: [1, 1_000_000] };
585
- }
586
-
587
- // ─────────────────────────────────────────────────────────────────────────────
588
- // libav decoded `Frame` → interleaved Float32Array (the format AudioOutput
589
- // schedules).
590
- // ─────────────────────────────────────────────────────────────────────────────
591
-
592
- const AV_SAMPLE_FMT_U8 = 0;
593
- const AV_SAMPLE_FMT_S16 = 1;
594
- const AV_SAMPLE_FMT_S32 = 2;
595
- const AV_SAMPLE_FMT_FLT = 3;
596
- const AV_SAMPLE_FMT_U8P = 5;
597
- const AV_SAMPLE_FMT_S16P = 6;
598
- const AV_SAMPLE_FMT_S32P = 7;
599
- const AV_SAMPLE_FMT_FLTP = 8;
600
-
601
- interface InterleavedSamples {
602
- data: Float32Array;
603
- channels: number;
604
- sampleRate: number;
605
- }
606
-
607
- function libavFrameToInterleavedFloat32(frame: LibavFrame): InterleavedSamples | null {
608
- const channels = frame.channels ?? frame.ch_layout_nb_channels ?? 1;
609
- const sampleRate = frame.sample_rate ?? 44100;
610
- const nbSamples = frame.nb_samples ?? 0;
611
- if (nbSamples === 0) return null;
612
-
613
- const out = new Float32Array(nbSamples * channels);
614
-
615
- switch (frame.format) {
616
- case AV_SAMPLE_FMT_FLTP: {
617
- const planes = ensurePlanes(frame.data, channels);
618
- for (let ch = 0; ch < channels; ch++) {
619
- const plane = asFloat32(planes[ch]);
620
- for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = plane[i];
621
- }
622
- return { data: out, channels, sampleRate };
623
- }
624
- case AV_SAMPLE_FMT_FLT: {
625
- const flat = asFloat32(frame.data);
626
- for (let i = 0; i < nbSamples * channels; i++) out[i] = flat[i];
627
- return { data: out, channels, sampleRate };
628
- }
629
- case AV_SAMPLE_FMT_S16P: {
630
- const planes = ensurePlanes(frame.data, channels);
631
- for (let ch = 0; ch < channels; ch++) {
632
- const plane = asInt16(planes[ch]);
633
- for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = plane[i] / 32768;
634
- }
635
- return { data: out, channels, sampleRate };
636
- }
637
- case AV_SAMPLE_FMT_S16: {
638
- const flat = asInt16(frame.data);
639
- for (let i = 0; i < nbSamples * channels; i++) out[i] = flat[i] / 32768;
640
- return { data: out, channels, sampleRate };
641
- }
642
- case AV_SAMPLE_FMT_S32P: {
643
- const planes = ensurePlanes(frame.data, channels);
644
- for (let ch = 0; ch < channels; ch++) {
645
- const plane = asInt32(planes[ch]);
646
- for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = plane[i] / 2147483648;
647
- }
648
- return { data: out, channels, sampleRate };
649
- }
650
- case AV_SAMPLE_FMT_S32: {
651
- const flat = asInt32(frame.data);
652
- for (let i = 0; i < nbSamples * channels; i++) out[i] = flat[i] / 2147483648;
653
- return { data: out, channels, sampleRate };
654
- }
655
- case AV_SAMPLE_FMT_U8P: {
656
- const planes = ensurePlanes(frame.data, channels);
657
- for (let ch = 0; ch < channels; ch++) {
658
- const plane = asUint8(planes[ch]);
659
- for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = (plane[i] - 128) / 128;
660
- }
661
- return { data: out, channels, sampleRate };
662
- }
663
- case AV_SAMPLE_FMT_U8: {
664
- const flat = asUint8(frame.data);
665
- for (let i = 0; i < nbSamples * channels; i++) out[i] = (flat[i] - 128) / 128;
666
- return { data: out, channels, sampleRate };
667
- }
668
- default:
669
- if (!(globalThis as { __avbridgeLoggedSampleFmt?: number }).__avbridgeLoggedSampleFmt) {
670
- (globalThis as { __avbridgeLoggedSampleFmt?: number }).__avbridgeLoggedSampleFmt = frame.format;
671
- console.warn(`[avbridge] unsupported audio sample format from libav: ${frame.format}`);
672
- }
673
- return null;
674
- }
675
- }
676
-
677
- function ensurePlanes(data: unknown, channels: number): unknown[] {
678
- if (Array.isArray(data)) return data;
679
- const arr = data as { length: number; subarray?: (a: number, b: number) => unknown };
680
- const len = arr.length;
681
- const perChannel = Math.floor(len / channels);
682
- const planes: unknown[] = [];
683
- for (let ch = 0; ch < channels; ch++) {
684
- planes.push(arr.subarray ? arr.subarray(ch * perChannel, (ch + 1) * perChannel) : arr);
685
- }
686
- return planes;
687
- }
688
-
689
- function asFloat32(x: unknown): Float32Array {
690
- if (x instanceof Float32Array) return x;
691
- const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
692
- return new Float32Array(ta.buffer, ta.byteOffset, ta.byteLength / 4);
693
- }
694
- function asInt16(x: unknown): Int16Array {
695
- if (x instanceof Int16Array) return x;
696
- const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
697
- return new Int16Array(ta.buffer, ta.byteOffset, ta.byteLength / 2);
698
- }
699
- function asInt32(x: unknown): Int32Array {
700
- if (x instanceof Int32Array) return x;
701
- const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
702
- return new Int32Array(ta.buffer, ta.byteOffset, ta.byteLength / 4);
703
- }
704
- function asUint8(x: unknown): Uint8Array {
705
- if (x instanceof Uint8Array) return x;
706
- const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
707
- return new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
708
- }
709
-
710
633
  // ─────────────────────────────────────────────────────────────────────────────
711
634
  // Bridge loader (lazy via the static-import wrapper).
712
635
  // ─────────────────────────────────────────────────────────────────────────────
@@ -256,8 +256,25 @@ export async function createFallbackSession(
256
256
  await doSeek(time);
257
257
  },
258
258
 
259
- async setAudioTrack(_id) {
260
- // Multi-track audio is post-MVP for the fallback strategy.
259
+ async setAudioTrack(id) {
260
+ // Verify the id refers to a real track.
261
+ if (!ctx.audioTracks.some((t) => t.id === id)) {
262
+ console.warn("[avbridge] fallback: setAudioTrack — unknown track id", id);
263
+ return;
264
+ }
265
+ const wasPlaying = audio.isPlaying();
266
+ const currentTime = audio.now();
267
+ // Suspend audio, rebuild the decoder + seek, reset audio output, re-gate.
268
+ await audio.pause().catch(() => {});
269
+ await handles.setAudioTrack(id, currentTime).catch((err) =>
270
+ console.warn("[avbridge] fallback: handles.setAudioTrack failed:", err),
271
+ );
272
+ await audio.reset(currentTime);
273
+ renderer.flush();
274
+ if (wasPlaying) {
275
+ await waitForBuffer();
276
+ await audio.start();
277
+ }
261
278
  },
262
279
 
263
280
  async setSubtitleTrack(_id) {
@@ -23,5 +23,13 @@ export interface BridgeModule {
23
23
  audioStreamToConfig(libav: unknown, stream: unknown): Promise<AudioDecoderConfig | null>;
24
24
  packetToEncodedVideoChunk(pkt: unknown, stream: unknown): EncodedVideoChunk;
25
25
  packetToEncodedAudioChunk(pkt: unknown, stream: unknown): EncodedAudioChunk;
26
- libavFrameToVideoFrame?(frame: unknown, stream: unknown): VideoFrame | null;
26
+ /**
27
+ * Convert a libav-decoded frame (software OR hardware decode) into a
28
+ * WebCodecs VideoFrame. `opts.timeBase` overrides the frame's per-packet
29
+ * timebase; useful when callers have already normalized pts to µs.
30
+ */
31
+ laFrameToVideoFrame(
32
+ frame: unknown,
33
+ opts?: { timeBase?: [number, number]; transfer?: boolean },
34
+ ): VideoFrame;
27
35
  }