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.
- package/CHANGELOG.md +73 -0
- package/dist/{chunk-6UUT4BEA.cjs → chunk-2IJ66NTD.cjs} +13 -20
- package/dist/chunk-2IJ66NTD.cjs.map +1 -0
- package/dist/{chunk-XKPSTC34.cjs → chunk-2XW2O3YI.cjs} +5 -20
- package/dist/chunk-2XW2O3YI.cjs.map +1 -0
- package/dist/chunk-5KVLE6YI.js +167 -0
- package/dist/chunk-5KVLE6YI.js.map +1 -0
- package/dist/{chunk-2PGRFCWB.js → chunk-CPJLFFCC.js} +8 -18
- package/dist/chunk-CPJLFFCC.js.map +1 -0
- package/dist/chunk-CPZ7PXAM.cjs +240 -0
- package/dist/chunk-CPZ7PXAM.cjs.map +1 -0
- package/dist/{chunk-QQXBPW72.js → chunk-E76AMWI4.js} +4 -18
- package/dist/chunk-E76AMWI4.js.map +1 -0
- package/dist/{chunk-NV7ILLWH.js → chunk-KY2GPCT7.js} +347 -665
- package/dist/chunk-KY2GPCT7.js.map +1 -0
- package/dist/chunk-LUFA47FP.js +19 -0
- package/dist/chunk-LUFA47FP.js.map +1 -0
- package/dist/chunk-Q2VUO52Z.cjs +374 -0
- package/dist/chunk-Q2VUO52Z.cjs.map +1 -0
- package/dist/chunk-QDJLQR53.cjs +22 -0
- package/dist/chunk-QDJLQR53.cjs.map +1 -0
- package/dist/chunk-S4WAZC2T.cjs +173 -0
- package/dist/chunk-S4WAZC2T.cjs.map +1 -0
- package/dist/chunk-SMH6IOP2.js +368 -0
- package/dist/chunk-SMH6IOP2.js.map +1 -0
- package/dist/chunk-SR3MPV4D.js +237 -0
- package/dist/chunk-SR3MPV4D.js.map +1 -0
- package/dist/{chunk-7RGG6ME7.cjs → chunk-TBW26OPP.cjs} +365 -688
- package/dist/chunk-TBW26OPP.cjs.map +1 -0
- package/dist/chunk-X2K3GIWE.js +235 -0
- package/dist/chunk-X2K3GIWE.js.map +1 -0
- package/dist/chunk-ZCUXHW55.cjs +242 -0
- package/dist/chunk-ZCUXHW55.cjs.map +1 -0
- package/dist/element-browser.js +799 -493
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +58 -4
- package/dist/element.cjs.map +1 -1
- package/dist/element.d.cts +38 -0
- package/dist/element.d.ts +38 -0
- package/dist/element.js +57 -3
- package/dist/element.js.map +1 -1
- package/dist/index.cjs +523 -393
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +494 -366
- package/dist/index.js.map +1 -1
- package/dist/libav-demux-H2GS46GH.cjs +27 -0
- package/dist/libav-demux-H2GS46GH.cjs.map +1 -0
- package/dist/libav-demux-OWZ4T2YW.js +6 -0
- package/dist/libav-demux-OWZ4T2YW.js.map +1 -0
- package/dist/{libav-import-GST2AMPL.cjs → libav-import-2ZVKV2E7.cjs} +2 -2
- package/dist/{libav-import-GST2AMPL.cjs.map → libav-import-2ZVKV2E7.cjs.map} +1 -1
- package/dist/{libav-import-2JURFHEW.js → libav-import-6MGLCXVQ.js} +2 -2
- package/dist/{libav-import-2JURFHEW.js.map → libav-import-6MGLCXVQ.js.map} +1 -1
- package/dist/player.cjs +601 -470
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +50 -0
- package/dist/player.d.ts +50 -0
- package/dist/player.js +580 -449
- package/dist/player.js.map +1 -1
- package/dist/remux-OBSMIENG.cjs +35 -0
- package/dist/remux-OBSMIENG.cjs.map +1 -0
- package/dist/remux-WBYIZBBX.js +10 -0
- package/dist/remux-WBYIZBBX.js.map +1 -0
- package/dist/source-4TZ6KMNV.js +4 -0
- package/dist/{source-F656KYYV.js.map → source-4TZ6KMNV.js.map} +1 -1
- package/dist/source-7YLO6E7X.cjs +29 -0
- package/dist/{source-73CAH6HW.cjs.map → source-7YLO6E7X.cjs.map} +1 -1
- package/dist/source-MTX5ELUZ.js +4 -0
- package/dist/{source-QJR3OHTW.js.map → source-MTX5ELUZ.js.map} +1 -1
- package/dist/source-VFLXLOCN.cjs +29 -0
- package/dist/{source-VB74JQ7Z.cjs.map → source-VFLXLOCN.cjs.map} +1 -1
- package/dist/subtitles-4T74JRGT.js +4 -0
- package/dist/subtitles-4T74JRGT.js.map +1 -0
- package/dist/subtitles-QUH4LPI4.cjs +29 -0
- package/dist/subtitles-QUH4LPI4.cjs.map +1 -0
- package/package.json +1 -1
- package/src/convert/remux.ts +1 -35
- package/src/convert/transcode-libav.ts +691 -0
- package/src/convert/transcode.ts +12 -4
- package/src/element/avbridge-player.ts +16 -0
- package/src/element/avbridge-video.ts +54 -0
- package/src/errors.ts +6 -0
- package/src/player.ts +15 -16
- package/src/strategies/fallback/decoder.ts +96 -173
- package/src/strategies/fallback/index.ts +19 -2
- package/src/strategies/fallback/libav-import.ts +9 -1
- package/src/strategies/fallback/video-renderer.ts +107 -0
- package/src/strategies/hybrid/decoder.ts +88 -180
- package/src/strategies/hybrid/index.ts +17 -2
- package/src/strategies/native.ts +6 -3
- package/src/strategies/remux/index.ts +14 -2
- package/src/strategies/remux/pipeline.ts +72 -12
- package/src/subtitles/render.ts +8 -0
- package/src/util/libav-demux.ts +405 -0
- package/dist/chunk-2PGRFCWB.js.map +0 -1
- package/dist/chunk-6UUT4BEA.cjs.map +0 -1
- package/dist/chunk-7RGG6ME7.cjs.map +0 -1
- package/dist/chunk-NV7ILLWH.js.map +0 -1
- package/dist/chunk-QQXBPW72.js.map +0 -1
- package/dist/chunk-XKPSTC34.cjs.map +0 -1
- package/dist/source-73CAH6HW.cjs +0 -28
- package/dist/source-F656KYYV.js +0 -3
- package/dist/source-QJR3OHTW.js +0 -3
- package/dist/source-VB74JQ7Z.cjs +0 -28
package/src/convert/transcode.ts
CHANGED
|
@@ -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
|
|
59
|
-
|
|
60
|
-
`transcode
|
|
61
|
-
`
|
|
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
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
260
|
-
//
|
|
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
|
-
|
|
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
|
}
|