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.
- package/CHANGELOG.md +153 -1
- package/NOTICE.md +2 -2
- package/README.md +2 -3
- package/THIRD_PARTY_LICENSES.md +2 -2
- package/dist/avi-2JPBSHGA.js +183 -0
- package/dist/avi-2JPBSHGA.js.map +1 -0
- package/dist/avi-F6WZJK5T.cjs +185 -0
- package/dist/avi-F6WZJK5T.cjs.map +1 -0
- package/dist/{avi-GCGM7OJI.js → avi-NJXAXUXK.js} +9 -3
- package/dist/avi-NJXAXUXK.js.map +1 -0
- package/dist/{avi-6SJLWIWW.cjs → avi-W6L3BTWU.cjs} +10 -4
- package/dist/avi-W6L3BTWU.cjs.map +1 -0
- package/dist/chunk-2IJ66NTD.cjs +212 -0
- package/dist/chunk-2IJ66NTD.cjs.map +1 -0
- package/dist/{chunk-ILKDNBSE.js → chunk-2XW2O3YI.cjs} +55 -10
- 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-5YAWWKA3.js +18 -0
- package/dist/chunk-5YAWWKA3.js.map +1 -0
- package/dist/chunk-CPJLFFCC.js +189 -0
- 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-WD2ZNQA7.js → chunk-DCSOQH2N.js} +7 -4
- package/dist/chunk-DCSOQH2N.js.map +1 -0
- package/dist/{chunk-HZLQNKFN.cjs → chunk-E76AMWI4.js} +40 -15
- package/dist/chunk-E76AMWI4.js.map +1 -0
- package/dist/chunk-F3LQJKXK.cjs +20 -0
- package/dist/chunk-F3LQJKXK.cjs.map +1 -0
- package/dist/chunk-IAYKFGFG.js +200 -0
- package/dist/chunk-IAYKFGFG.js.map +1 -0
- package/dist/{chunk-DMWARSEF.js → chunk-KY2GPCT7.js} +788 -697
- 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-NNVOHKXJ.cjs +204 -0
- package/dist/chunk-NNVOHKXJ.cjs.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-UF2N5L63.cjs → chunk-TBW26OPP.cjs} +800 -710
- 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-L4NPOJ36.cjs → chunk-Z33SBWL5.cjs} +7 -4
- package/dist/chunk-Z33SBWL5.cjs.map +1 -0
- package/dist/chunk-ZCUXHW55.cjs +242 -0
- package/dist/chunk-ZCUXHW55.cjs.map +1 -0
- package/dist/element-browser.js +1282 -503
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +59 -5
- package/dist/element.cjs.map +1 -1
- package/dist/element.d.cts +39 -1
- package/dist/element.d.ts +39 -1
- package/dist/element.js +58 -4
- package/dist/element.js.map +1 -1
- package/dist/index.cjs +605 -327
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +48 -4
- package/dist/index.d.ts +48 -4
- package/dist/index.js +528 -319
- package/dist/index.js.map +1 -1
- package/dist/libav-demux-H2GS46GH.cjs +27 -0
- package/dist/{libav-http-reader-NQJVY273.js.map → libav-demux-H2GS46GH.cjs.map} +1 -1
- package/dist/libav-demux-OWZ4T2YW.js +6 -0
- package/dist/{libav-http-reader-FPYDBMYK.cjs.map → libav-demux-OWZ4T2YW.js.map} +1 -1
- package/dist/libav-http-reader-AZLE7YFS.cjs +16 -0
- package/dist/libav-http-reader-AZLE7YFS.cjs.map +1 -0
- package/dist/libav-http-reader-WXG3Z7AI.js +3 -0
- package/dist/libav-http-reader-WXG3Z7AI.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-U2NPmFvA.d.cts → player-B6WB74RD.d.cts} +62 -3
- package/dist/{player-U2NPmFvA.d.ts → player-B6WB74RD.d.ts} +62 -3
- package/dist/player.cjs +5631 -0
- package/dist/player.cjs.map +1 -0
- package/dist/player.d.cts +699 -0
- package/dist/player.d.ts +699 -0
- package/dist/player.js +5629 -0
- package/dist/player.js.map +1 -0
- 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-FFZ7TW2B.js.map → source-4TZ6KMNV.js.map} +1 -1
- package/dist/source-7YLO6E7X.cjs +29 -0
- package/dist/{source-CN43EI7Z.cjs.map → source-7YLO6E7X.cjs.map} +1 -1
- package/dist/source-MTX5ELUZ.js +4 -0
- package/dist/source-MTX5ELUZ.js.map +1 -0
- package/dist/source-VFLXLOCN.cjs +29 -0
- package/dist/source-VFLXLOCN.cjs.map +1 -0
- 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/dist/variant-routing-434STYAB.js +3 -0
- package/dist/{variant-routing-JOBWXYKD.js.map → variant-routing-434STYAB.js.map} +1 -1
- package/dist/variant-routing-HONNAA6R.cjs +12 -0
- package/dist/{variant-routing-GOHB2RZN.cjs.map → variant-routing-HONNAA6R.cjs.map} +1 -1
- package/package.json +9 -1
- package/src/classify/rules.ts +27 -5
- package/src/convert/remux.ts +9 -35
- package/src/convert/transcode-libav.ts +691 -0
- package/src/convert/transcode.ts +53 -12
- package/src/element/avbridge-player.ts +861 -0
- package/src/element/avbridge-video.ts +54 -0
- package/src/element/player-icons.ts +25 -0
- package/src/element/player-styles.ts +472 -0
- package/src/errors.ts +53 -0
- package/src/index.ts +23 -0
- package/src/player-element.ts +18 -0
- package/src/player.ts +118 -27
- package/src/plugins/builtin.ts +2 -2
- package/src/probe/avi.ts +4 -0
- package/src/probe/index.ts +40 -10
- package/src/strategies/fallback/audio-output.ts +31 -0
- package/src/strategies/fallback/decoder.ts +179 -175
- package/src/strategies/fallback/index.ts +48 -6
- package/src/strategies/fallback/libav-import.ts +9 -1
- package/src/strategies/fallback/variant-routing.ts +7 -13
- package/src/strategies/fallback/video-renderer.ts +231 -32
- package/src/strategies/hybrid/decoder.ts +219 -200
- package/src/strategies/hybrid/index.ts +48 -7
- package/src/strategies/native.ts +6 -3
- package/src/strategies/remux/index.ts +14 -2
- package/src/strategies/remux/mse.ts +12 -2
- package/src/strategies/remux/pipeline.ts +72 -12
- package/src/subtitles/index.ts +7 -3
- package/src/subtitles/render.ts +8 -0
- package/src/types.ts +53 -1
- package/src/util/libav-demux.ts +405 -0
- package/src/util/libav-http-reader.ts +5 -1
- package/src/util/source.ts +28 -8
- package/src/util/transport.ts +26 -0
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
- package/dist/avi-6SJLWIWW.cjs.map +0 -1
- package/dist/avi-GCGM7OJI.js.map +0 -1
- package/dist/chunk-DMWARSEF.js.map +0 -1
- package/dist/chunk-HZLQNKFN.cjs.map +0 -1
- package/dist/chunk-ILKDNBSE.js.map +0 -1
- package/dist/chunk-J5MCMN3S.js +0 -27
- package/dist/chunk-J5MCMN3S.js.map +0 -1
- package/dist/chunk-L4NPOJ36.cjs.map +0 -1
- package/dist/chunk-NZU7W256.cjs +0 -29
- package/dist/chunk-NZU7W256.cjs.map +0 -1
- package/dist/chunk-UF2N5L63.cjs.map +0 -1
- package/dist/chunk-WD2ZNQA7.js.map +0 -1
- package/dist/libav-http-reader-FPYDBMYK.cjs +0 -16
- package/dist/libav-http-reader-NQJVY273.js +0 -3
- package/dist/source-CN43EI7Z.cjs +0 -28
- package/dist/source-FFZ7TW2B.js +0 -3
- package/dist/variant-routing-GOHB2RZN.cjs +0 -12
- package/dist/variant-routing-JOBWXYKD.js +0 -3
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* `SourceBuffer` with an append queue that respects `updateend` backpressure.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { AvbridgeError, ERR_MSE_NOT_SUPPORTED, ERR_MSE_CODEC_NOT_SUPPORTED } from "../../errors.js";
|
|
7
|
+
|
|
6
8
|
export interface MseSinkOptions {
|
|
7
9
|
mime: string;
|
|
8
10
|
video: HTMLVideoElement;
|
|
@@ -23,10 +25,18 @@ export class MseSink {
|
|
|
23
25
|
|
|
24
26
|
constructor(private readonly options: MseSinkOptions) {
|
|
25
27
|
if (typeof MediaSource === "undefined") {
|
|
26
|
-
throw new
|
|
28
|
+
throw new AvbridgeError(
|
|
29
|
+
ERR_MSE_NOT_SUPPORTED,
|
|
30
|
+
"MediaSource Extensions (MSE) are not supported in this environment.",
|
|
31
|
+
"MSE is required for the remux strategy. Use a browser that supports MSE, or try the fallback strategy.",
|
|
32
|
+
);
|
|
27
33
|
}
|
|
28
34
|
if (!MediaSource.isTypeSupported(options.mime)) {
|
|
29
|
-
throw new
|
|
35
|
+
throw new AvbridgeError(
|
|
36
|
+
ERR_MSE_CODEC_NOT_SUPPORTED,
|
|
37
|
+
`This browser's MSE does not support "${options.mime}".`,
|
|
38
|
+
"The codec combination can't be played via remux in this browser. The player will try the next strategy automatically.",
|
|
39
|
+
);
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
this.mediaSource = new MediaSource();
|
|
@@ -26,6 +26,11 @@ export interface RemuxPipeline {
|
|
|
26
26
|
seek(time: number, autoPlay?: boolean): Promise<void>;
|
|
27
27
|
/** Update the autoplay intent mid-flight — used when play() arrives after seek() but before the MseSink has been constructed. */
|
|
28
28
|
setAutoPlay(autoPlay: boolean): void;
|
|
29
|
+
/**
|
|
30
|
+
* Switch the active audio track. Tears down the current Output, rebuilds
|
|
31
|
+
* with the new audio source, and resumes pumping at the given time.
|
|
32
|
+
*/
|
|
33
|
+
setAudioTrack(trackId: number, timeSec: number, autoPlay: boolean): Promise<void>;
|
|
29
34
|
destroy(): Promise<void>;
|
|
30
35
|
stats(): Record<string, unknown>;
|
|
31
36
|
}
|
|
@@ -37,7 +42,6 @@ export async function createRemuxPipeline(
|
|
|
37
42
|
const mb = await import("mediabunny");
|
|
38
43
|
|
|
39
44
|
const videoTrackInfo = ctx.videoTracks[0];
|
|
40
|
-
const audioTrackInfo = ctx.audioTracks[0];
|
|
41
45
|
if (!videoTrackInfo) throw new Error("remux: source has no video track");
|
|
42
46
|
|
|
43
47
|
// Map avbridge codec names back to mediabunny's enum strings.
|
|
@@ -45,7 +49,6 @@ export async function createRemuxPipeline(
|
|
|
45
49
|
if (!mbVideoCodec) {
|
|
46
50
|
throw new Error(`remux: video codec "${videoTrackInfo.codec}" is not supported by mediabunny output`);
|
|
47
51
|
}
|
|
48
|
-
const mbAudioCodec = audioTrackInfo ? avbridgeAudioToMediabunny(audioTrackInfo.codec) : null;
|
|
49
52
|
|
|
50
53
|
// Open the input. URL sources go through mediabunny's UrlSource so the
|
|
51
54
|
// muxer streams via Range requests instead of buffering the whole file.
|
|
@@ -55,23 +58,52 @@ export async function createRemuxPipeline(
|
|
|
55
58
|
});
|
|
56
59
|
const allTracks = await input.getTracks();
|
|
57
60
|
const inputVideo = allTracks.find((t) => t.id === videoTrackInfo.id && t.isVideoTrack());
|
|
58
|
-
const inputAudio = audioTrackInfo
|
|
59
|
-
? allTracks.find((t) => t.id === audioTrackInfo.id && t.isAudioTrack())
|
|
60
|
-
: null;
|
|
61
61
|
if (!inputVideo || !inputVideo.isVideoTrack()) {
|
|
62
62
|
throw new Error("remux: video track not found in input");
|
|
63
63
|
}
|
|
64
|
-
if (audioTrackInfo && (!inputAudio || !inputAudio.isAudioTrack())) {
|
|
65
|
-
throw new Error("remux: audio track not found in input");
|
|
66
|
-
}
|
|
67
64
|
|
|
68
|
-
// Pull WebCodecs decoder
|
|
65
|
+
// Pull the video WebCodecs decoder config once — used as `meta` on the
|
|
66
|
+
// first packet after every Output rebuild.
|
|
69
67
|
const videoConfig = await inputVideo.getDecoderConfig();
|
|
70
|
-
const audioConfig = inputAudio && inputAudio.isAudioTrack() ? await inputAudio.getDecoderConfig() : null;
|
|
71
68
|
|
|
72
|
-
// Packet
|
|
69
|
+
// Packet sink for video — reused across seeks.
|
|
73
70
|
const videoSink = new mb.EncodedPacketSink(inputVideo);
|
|
74
|
-
|
|
71
|
+
|
|
72
|
+
// Audio selection is mutable: setAudioTrack() can swap it. The selected
|
|
73
|
+
// audio derived state (input track, codec, sink, config) is rebuilt via
|
|
74
|
+
// rebuildAudio() whenever the id changes.
|
|
75
|
+
type InputAudioTrack = InstanceType<typeof mb.InputAudioTrack>;
|
|
76
|
+
type AudioDecCfg = Awaited<ReturnType<InputAudioTrack["getDecoderConfig"]>>;
|
|
77
|
+
|
|
78
|
+
let selectedAudioTrackId: number | null = ctx.audioTracks[0]?.id ?? null;
|
|
79
|
+
let inputAudio: InputAudioTrack | null = null;
|
|
80
|
+
let mbAudioCodec: ReturnType<typeof avbridgeAudioToMediabunny> | null = null;
|
|
81
|
+
let audioSink: InstanceType<typeof mb.EncodedPacketSink> | null = null;
|
|
82
|
+
let audioConfig: AudioDecCfg | null = null;
|
|
83
|
+
|
|
84
|
+
async function rebuildAudio(): Promise<void> {
|
|
85
|
+
if (selectedAudioTrackId == null) {
|
|
86
|
+
inputAudio = null;
|
|
87
|
+
mbAudioCodec = null;
|
|
88
|
+
audioSink = null;
|
|
89
|
+
audioConfig = null;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const trackInfo = ctx.audioTracks.find((t) => t.id === selectedAudioTrackId);
|
|
93
|
+
if (!trackInfo) {
|
|
94
|
+
throw new Error(`remux: no audio track with id ${selectedAudioTrackId}`);
|
|
95
|
+
}
|
|
96
|
+
const newInput = allTracks.find((t) => t.id === trackInfo.id && t.isAudioTrack());
|
|
97
|
+
if (!newInput || !newInput.isAudioTrack()) {
|
|
98
|
+
throw new Error("remux: audio track not found in input");
|
|
99
|
+
}
|
|
100
|
+
inputAudio = newInput;
|
|
101
|
+
mbAudioCodec = avbridgeAudioToMediabunny(trackInfo.codec);
|
|
102
|
+
audioSink = new mb.EncodedPacketSink(newInput);
|
|
103
|
+
audioConfig = await newInput.getDecoderConfig();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await rebuildAudio();
|
|
75
107
|
|
|
76
108
|
// MSE sink — created lazily on first output write, reused across seeks.
|
|
77
109
|
let sink: MseSink | null = null;
|
|
@@ -254,6 +286,34 @@ export async function createRemuxPipeline(
|
|
|
254
286
|
pendingAutoPlay = autoPlay;
|
|
255
287
|
if (sink) sink.setPlayOnSeek(autoPlay);
|
|
256
288
|
},
|
|
289
|
+
async setAudioTrack(trackId, time, autoPlay) {
|
|
290
|
+
if (selectedAudioTrackId === trackId) return;
|
|
291
|
+
if (!ctx.audioTracks.some((t) => t.id === trackId)) {
|
|
292
|
+
console.warn("[avbridge] remux: setAudioTrack — unknown track id", trackId);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// Stop the current pump. The next pumpLoop() will build a fresh
|
|
296
|
+
// Output that uses the newly-selected audio source.
|
|
297
|
+
pumpToken++;
|
|
298
|
+
selectedAudioTrackId = trackId;
|
|
299
|
+
await rebuildAudio().catch((err) => {
|
|
300
|
+
console.warn("[avbridge] remux: rebuildAudio failed:", (err as Error).message);
|
|
301
|
+
});
|
|
302
|
+
// Tear down the existing MseSink — the audio codec may have changed,
|
|
303
|
+
// and the SourceBuffer's mime is fixed at construction time. The next
|
|
304
|
+
// createOutput will recompute `getMimeType()` and the write handler
|
|
305
|
+
// will lazily build a new sink.
|
|
306
|
+
if (sink) {
|
|
307
|
+
try { sink.destroy(); } catch { /* ignore */ }
|
|
308
|
+
sink = null;
|
|
309
|
+
}
|
|
310
|
+
pendingAutoPlay = autoPlay;
|
|
311
|
+
pendingStartTime = time;
|
|
312
|
+
pumpLoop(++pumpToken, time).catch((err) => {
|
|
313
|
+
// eslint-disable-next-line no-console
|
|
314
|
+
console.error("[avbridge] remux pipeline setAudioTrack pump failed:", err);
|
|
315
|
+
});
|
|
316
|
+
},
|
|
257
317
|
async destroy() {
|
|
258
318
|
destroyed = true;
|
|
259
319
|
pumpToken++;
|
package/src/subtitles/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { SubtitleTrackInfo } from "../types.js";
|
|
1
|
+
import type { SubtitleTrackInfo, TransportConfig } from "../types.js";
|
|
2
|
+
import { fetchWith } from "../util/transport.js";
|
|
2
3
|
import { srtToVtt } from "./srt.js";
|
|
3
4
|
import { isVtt } from "./vtt.js";
|
|
4
5
|
|
|
@@ -98,7 +99,10 @@ export async function attachSubtitleTracks(
|
|
|
98
99
|
tracks: SubtitleTrackInfo[],
|
|
99
100
|
bag?: SubtitleResourceBag,
|
|
100
101
|
onError?: (err: Error, track: SubtitleTrackInfo) => void,
|
|
102
|
+
transport?: TransportConfig,
|
|
101
103
|
): Promise<void> {
|
|
104
|
+
const doFetch = fetchWith(transport);
|
|
105
|
+
|
|
102
106
|
// Clear existing dynamically-attached tracks.
|
|
103
107
|
for (const t of Array.from(video.querySelectorAll("track[data-avbridge]"))) {
|
|
104
108
|
t.remove();
|
|
@@ -109,14 +113,14 @@ export async function attachSubtitleTracks(
|
|
|
109
113
|
try {
|
|
110
114
|
let url = t.sidecarUrl;
|
|
111
115
|
if (t.format === "srt") {
|
|
112
|
-
const res = await
|
|
116
|
+
const res = await doFetch(t.sidecarUrl, transport?.requestInit);
|
|
113
117
|
const text = await res.text();
|
|
114
118
|
const vtt = srtToVtt(text);
|
|
115
119
|
const blob = new Blob([vtt], { type: "text/vtt" });
|
|
116
120
|
url = bag ? bag.createObjectURL(blob) : URL.createObjectURL(blob);
|
|
117
121
|
} else if (t.format === "vtt") {
|
|
118
122
|
// Validate quickly so a malformed file fails loudly here.
|
|
119
|
-
const res = await
|
|
123
|
+
const res = await doFetch(t.sidecarUrl, transport?.requestInit);
|
|
120
124
|
const text = await res.text();
|
|
121
125
|
if (!isVtt(text)) {
|
|
122
126
|
// eslint-disable-next-line no-console
|
package/src/subtitles/render.ts
CHANGED
|
@@ -32,6 +32,14 @@ export class SubtitleOverlay {
|
|
|
32
32
|
this.el.textContent = active?.text ?? "";
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Set the currently-displayed text directly (bypasses loadVtt/update). */
|
|
36
|
+
setText(text: string): void {
|
|
37
|
+
// Only touch the DOM if it actually changed — rAF tick runs 60Hz.
|
|
38
|
+
if (this.el.textContent !== text) {
|
|
39
|
+
this.el.textContent = text;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
destroy(): void {
|
|
36
44
|
this.el.remove();
|
|
37
45
|
this.cues = [];
|
package/src/types.ts
CHANGED
|
@@ -69,6 +69,8 @@ export type AudioCodec =
|
|
|
69
69
|
| "ra_288" // RealAudio 2.0 (28.8 kbps)
|
|
70
70
|
| "sipr" // RealAudio Sipr (voice codec)
|
|
71
71
|
| "atrac3" // Sony ATRAC3 (sometimes seen in .rm)
|
|
72
|
+
| "dts" // DTS (common in Blu-ray MKV rips)
|
|
73
|
+
| "truehd" // Dolby TrueHD (Blu-ray lossless)
|
|
72
74
|
| (string & {});
|
|
73
75
|
|
|
74
76
|
export interface VideoTrackInfo {
|
|
@@ -219,7 +221,7 @@ export interface Plugin {
|
|
|
219
221
|
name: string;
|
|
220
222
|
canHandle(context: MediaContext): boolean;
|
|
221
223
|
/** Returns a session if it claims the context, otherwise throws. */
|
|
222
|
-
execute(context: MediaContext, target: HTMLVideoElement): Promise<PlaybackSession>;
|
|
224
|
+
execute(context: MediaContext, target: HTMLVideoElement, transport?: TransportConfig): Promise<PlaybackSession>;
|
|
223
225
|
}
|
|
224
226
|
|
|
225
227
|
/** Player creation options. */
|
|
@@ -256,6 +258,38 @@ export interface CreatePlayerOptions {
|
|
|
256
258
|
* strategy in the fallback chain on failure or stall.
|
|
257
259
|
*/
|
|
258
260
|
autoEscalate?: boolean;
|
|
261
|
+
/**
|
|
262
|
+
* Behavior when the browser tab becomes hidden.
|
|
263
|
+
* - `"pause"` (default): auto-pause on hide, auto-resume on visible
|
|
264
|
+
* if the user had been playing. Matches YouTube, Netflix, and
|
|
265
|
+
* native media players. Prevents degraded playback from Chrome's
|
|
266
|
+
* background throttling of requestAnimationFrame and setTimeout.
|
|
267
|
+
* - `"continue"`: keep playing. Playback will degrade anyway due to
|
|
268
|
+
* browser throttling, but useful for consumers who want full
|
|
269
|
+
* control of visibility handling themselves.
|
|
270
|
+
*/
|
|
271
|
+
backgroundBehavior?: "pause" | "continue";
|
|
272
|
+
/**
|
|
273
|
+
* Extra {@link RequestInit} merged into every HTTP request the player
|
|
274
|
+
* makes (probe Range requests, subtitle fetches, libav HTTP reader).
|
|
275
|
+
* Headers are merged, not overwritten — so you can add `Authorization`
|
|
276
|
+
* without losing the player's `Range` header.
|
|
277
|
+
*/
|
|
278
|
+
requestInit?: RequestInit;
|
|
279
|
+
/**
|
|
280
|
+
* Custom fetch implementation. Defaults to `globalThis.fetch`. Useful
|
|
281
|
+
* for interceptors, logging, or environments without a global fetch.
|
|
282
|
+
*/
|
|
283
|
+
fetchFn?: FetchFn;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Signature-compatible with `globalThis.fetch`. */
|
|
287
|
+
export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
288
|
+
|
|
289
|
+
/** Internal transport config bundle. Not part of the public API. */
|
|
290
|
+
export interface TransportConfig {
|
|
291
|
+
requestInit?: RequestInit;
|
|
292
|
+
fetchFn?: FetchFn;
|
|
259
293
|
}
|
|
260
294
|
|
|
261
295
|
/** Events emitted by {@link UnifiedPlayer}. Strongly typed. */
|
|
@@ -293,6 +327,24 @@ export interface ConvertOptions {
|
|
|
293
327
|
onProgress?: (info: ProgressInfo) => void;
|
|
294
328
|
/** When true, reject on any uncertain codec/container combo. Default: `false` (best-effort). */
|
|
295
329
|
strict?: boolean;
|
|
330
|
+
/**
|
|
331
|
+
* Write output progressively to a `WritableStream` instead of accumulating
|
|
332
|
+
* in memory. Use with the File System Access API (`showSaveFilePicker()`) to
|
|
333
|
+
* transcode files larger than available memory.
|
|
334
|
+
*
|
|
335
|
+
* When set, the returned `ConvertResult.blob` will be an empty Blob (the
|
|
336
|
+
* real data went to the stream). The caller is responsible for closing the
|
|
337
|
+
* stream after the returned promise resolves.
|
|
338
|
+
*
|
|
339
|
+
* @example
|
|
340
|
+
* ```ts
|
|
341
|
+
* const handle = await showSaveFilePicker({ suggestedName: "output.mp4" });
|
|
342
|
+
* const writable = await handle.createWritable();
|
|
343
|
+
* const result = await transcode(file, { outputStream: writable });
|
|
344
|
+
* await writable.close();
|
|
345
|
+
* ```
|
|
346
|
+
*/
|
|
347
|
+
outputStream?: WritableStream;
|
|
296
348
|
}
|
|
297
349
|
|
|
298
350
|
/** Progress information passed to {@link ConvertOptions.onProgress}. */
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared libav demux session. Opens a libav demuxer over a NormalizedSource
|
|
3
|
+
* and provides a linear, cancellable packet pump.
|
|
4
|
+
*
|
|
5
|
+
* Phase 1 API: deliberately minimal. The first consumer is the AVI/ASF/FLV
|
|
6
|
+
* transcode path (src/convert/transcode-libav.ts), which is strictly linear.
|
|
7
|
+
* No seek, no track swapping — those were added to hybrid/fallback's
|
|
8
|
+
* private pumps for playback reasons. When those paths migrate here, the
|
|
9
|
+
* API will grow to cover their needs.
|
|
10
|
+
*
|
|
11
|
+
* The shared timestamp sanitizers (sanitizePacketTimestamp,
|
|
12
|
+
* sanitizeFrameTimestamp) also live here. They were previously duplicated
|
|
13
|
+
* in convert/remux.ts and strategies/hybrid/decoder.ts. The duplicates
|
|
14
|
+
* stay put in Phase 1 with TODO pointers; migration is a follow-up.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { loadLibav, type LibavVariant } from "../strategies/fallback/libav-loader.js";
|
|
18
|
+
import { pickLibavVariant } from "../strategies/fallback/variant-routing.js";
|
|
19
|
+
import { prepareLibavInput } from "./libav-http-reader.js";
|
|
20
|
+
import type { MediaContext, TransportConfig } from "../types.js";
|
|
21
|
+
import type { NormalizedSource } from "./source.js";
|
|
22
|
+
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
24
|
+
// Structural types (mirror libav.js' shape without dragging in its types)
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface LibavStream {
|
|
28
|
+
index: number;
|
|
29
|
+
codec_type: number;
|
|
30
|
+
codec_id: number;
|
|
31
|
+
codecpar: number;
|
|
32
|
+
time_base_num?: number;
|
|
33
|
+
time_base_den?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LibavPacket {
|
|
37
|
+
data: Uint8Array;
|
|
38
|
+
pts: number;
|
|
39
|
+
ptshi?: number;
|
|
40
|
+
duration?: number;
|
|
41
|
+
durationhi?: number;
|
|
42
|
+
flags: number;
|
|
43
|
+
stream_index: number;
|
|
44
|
+
time_base_num?: number;
|
|
45
|
+
time_base_den?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface LibavFrame {
|
|
49
|
+
data: unknown;
|
|
50
|
+
format: number;
|
|
51
|
+
channels?: number;
|
|
52
|
+
ch_layout_nb_channels?: number;
|
|
53
|
+
sample_rate?: number;
|
|
54
|
+
nb_samples?: number;
|
|
55
|
+
pts?: number;
|
|
56
|
+
ptshi?: number;
|
|
57
|
+
width?: number;
|
|
58
|
+
height?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface LibavRuntime {
|
|
62
|
+
AVMEDIA_TYPE_VIDEO: number;
|
|
63
|
+
AVMEDIA_TYPE_AUDIO: number;
|
|
64
|
+
AVERROR_EOF: number;
|
|
65
|
+
EAGAIN: number;
|
|
66
|
+
|
|
67
|
+
mkreadaheadfile(name: string, blob: Blob): Promise<void>;
|
|
68
|
+
unlinkreadaheadfile(name: string): Promise<void>;
|
|
69
|
+
ff_init_demuxer_file(name: string): Promise<[number, LibavStream[]]>;
|
|
70
|
+
ff_read_frame_multi(
|
|
71
|
+
fmt_ctx: number,
|
|
72
|
+
pkt: number,
|
|
73
|
+
opts?: { limit?: number },
|
|
74
|
+
): Promise<[number, Record<number, LibavPacket[]>]>;
|
|
75
|
+
av_packet_alloc(): Promise<number>;
|
|
76
|
+
av_packet_free?(pkt: number): Promise<void>;
|
|
77
|
+
avformat_close_input_js(ctx: number): Promise<void>;
|
|
78
|
+
f64toi64?(val: number): [number, number];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
82
|
+
// Session
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export interface LibavDemuxSession {
|
|
86
|
+
readonly libav: LibavRuntime;
|
|
87
|
+
readonly fmtCtx: number;
|
|
88
|
+
readonly streams: LibavStream[];
|
|
89
|
+
readonly videoStream: LibavStream | null;
|
|
90
|
+
readonly audioStream: LibavStream | null;
|
|
91
|
+
/** True when the input is being streamed via HTTP Range requests. */
|
|
92
|
+
readonly transport: "http-range" | "blob";
|
|
93
|
+
/**
|
|
94
|
+
* Linear read-to-EOF pump. Invokes the callbacks for each
|
|
95
|
+
* ff_read_frame_multi batch (audio is handed over before video per
|
|
96
|
+
* batch, matching the audio-first ordering that the hybrid/fallback
|
|
97
|
+
* playback pumps use — see POSTMORTEMS.md entry 1).
|
|
98
|
+
*
|
|
99
|
+
* Honors the AbortSignal between batches. Invokes `onEof` once when
|
|
100
|
+
* the demuxer returns EOF. Does NOT handle seek.
|
|
101
|
+
*/
|
|
102
|
+
pump(cb: {
|
|
103
|
+
onVideoPackets?: (pkts: LibavPacket[]) => Promise<void>;
|
|
104
|
+
onAudioPackets?: (pkts: LibavPacket[]) => Promise<void>;
|
|
105
|
+
onEof?: () => Promise<void>;
|
|
106
|
+
signal?: AbortSignal;
|
|
107
|
+
}): Promise<void>;
|
|
108
|
+
destroy(): Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface OpenLibavDemuxOptions {
|
|
112
|
+
source: NormalizedSource;
|
|
113
|
+
filename: string;
|
|
114
|
+
context: MediaContext;
|
|
115
|
+
transport?: TransportConfig;
|
|
116
|
+
/** Override automatic variant picking. Defaults to pickLibavVariant(context). */
|
|
117
|
+
variant?: LibavVariant;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function openLibavDemux(opts: OpenLibavDemuxOptions): Promise<LibavDemuxSession> {
|
|
121
|
+
const variant: LibavVariant = opts.variant ?? pickLibavVariant(opts.context);
|
|
122
|
+
const libav = (await loadLibav(variant)) as unknown as LibavRuntime;
|
|
123
|
+
|
|
124
|
+
const inputHandle = await prepareLibavInput(
|
|
125
|
+
libav as unknown as Parameters<typeof prepareLibavInput>[0],
|
|
126
|
+
opts.filename,
|
|
127
|
+
opts.source,
|
|
128
|
+
opts.transport,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const readPkt = await libav.av_packet_alloc();
|
|
132
|
+
const [fmtCtx, streams] = await libav.ff_init_demuxer_file(opts.filename);
|
|
133
|
+
const videoStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_VIDEO) ?? null;
|
|
134
|
+
const audioStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_AUDIO) ?? null;
|
|
135
|
+
|
|
136
|
+
let destroyed = false;
|
|
137
|
+
|
|
138
|
+
async function pump(cb: Parameters<LibavDemuxSession["pump"]>[0]): Promise<void> {
|
|
139
|
+
while (!destroyed) {
|
|
140
|
+
if (cb.signal?.aborted) return;
|
|
141
|
+
|
|
142
|
+
let readErr: number;
|
|
143
|
+
let packets: Record<number, LibavPacket[]>;
|
|
144
|
+
try {
|
|
145
|
+
[readErr, packets] = await libav.ff_read_frame_multi(fmtCtx, readPkt, {
|
|
146
|
+
// 16 KB batch — chosen so each read produces a handful of
|
|
147
|
+
// packets, keeping downstream queues bounded. Same rationale
|
|
148
|
+
// as the hybrid/fallback pumps (see CLAUDE.md note).
|
|
149
|
+
limit: 16 * 1024,
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
throw new Error(`libav-demux: ff_read_frame_multi failed: ${(err as Error).message}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (destroyed || cb.signal?.aborted) return;
|
|
156
|
+
|
|
157
|
+
const videoPackets = videoStream ? packets[videoStream.index] : undefined;
|
|
158
|
+
const audioPackets = audioStream ? packets[audioStream.index] : undefined;
|
|
159
|
+
|
|
160
|
+
// Audio-first ordering. Audio decode is cheap; video decode can
|
|
161
|
+
// be expensive. Feeding audio first ensures the audio consumer
|
|
162
|
+
// has samples to work with before any long video-decode block.
|
|
163
|
+
if (cb.onAudioPackets && audioPackets && audioPackets.length > 0) {
|
|
164
|
+
await cb.onAudioPackets(audioPackets);
|
|
165
|
+
}
|
|
166
|
+
if (destroyed || cb.signal?.aborted) return;
|
|
167
|
+
if (cb.onVideoPackets && videoPackets && videoPackets.length > 0) {
|
|
168
|
+
await cb.onVideoPackets(videoPackets);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (readErr === libav.AVERROR_EOF) {
|
|
172
|
+
if (cb.onEof) await cb.onEof();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (readErr && readErr !== 0 && readErr !== -libav.EAGAIN) {
|
|
176
|
+
throw new Error(`libav-demux: ff_read_frame_multi returned ${readErr}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function destroy(): Promise<void> {
|
|
182
|
+
destroyed = true;
|
|
183
|
+
try { await libav.av_packet_free?.(readPkt); } catch { /* ignore */ }
|
|
184
|
+
try { await libav.avformat_close_input_js(fmtCtx); } catch { /* ignore */ }
|
|
185
|
+
try { await inputHandle.detach(); } catch { /* ignore */ }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
libav,
|
|
190
|
+
fmtCtx,
|
|
191
|
+
streams,
|
|
192
|
+
videoStream,
|
|
193
|
+
audioStream,
|
|
194
|
+
transport: inputHandle.transport,
|
|
195
|
+
pump,
|
|
196
|
+
destroy,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
201
|
+
// Timestamp sanitizers (extracted from convert/remux.ts + hybrid/decoder.ts)
|
|
202
|
+
//
|
|
203
|
+
// libav can hand us packets/frames with pts = AV_NOPTS_VALUE (encoded as
|
|
204
|
+
// ptshi = -2147483648, pts = 0) for inputs whose demuxer can't determine
|
|
205
|
+
// presentation times. AVI is the canonical example. Downstream consumers
|
|
206
|
+
// that treat pts as int64 overflow and throw.
|
|
207
|
+
//
|
|
208
|
+
// The sanitizer replaces invalid pts with a synthetic microsecond counter,
|
|
209
|
+
// and normalizes valid pts to a 1/1e6 time_base so consumers don't need
|
|
210
|
+
// to track the source time_base per packet.
|
|
211
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Sanitize a libav packet's timestamp. Mutates `pkt` in place.
|
|
215
|
+
* If the packet has AV_NOPTS_VALUE, replaces pts with `nextUs()`.
|
|
216
|
+
* Otherwise normalizes to µs with time_base = 1/1_000_000.
|
|
217
|
+
*/
|
|
218
|
+
export function sanitizePacketTimestamp(
|
|
219
|
+
pkt: LibavPacket,
|
|
220
|
+
nextUs: () => number,
|
|
221
|
+
fallbackTimeBase?: [number, number],
|
|
222
|
+
): void {
|
|
223
|
+
const lo = pkt.pts ?? 0;
|
|
224
|
+
const hi = pkt.ptshi ?? 0;
|
|
225
|
+
const isInvalid = (hi === -2147483648 && lo === 0) || !Number.isFinite(lo);
|
|
226
|
+
if (isInvalid) {
|
|
227
|
+
const us = nextUs();
|
|
228
|
+
pkt.pts = us;
|
|
229
|
+
pkt.ptshi = 0;
|
|
230
|
+
pkt.time_base_num = 1;
|
|
231
|
+
pkt.time_base_den = 1_000_000;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const tb = fallbackTimeBase ?? [1, 1_000_000];
|
|
235
|
+
const pts64 = hi * 0x100000000 + lo;
|
|
236
|
+
const us = Math.round((pts64 * 1_000_000 * tb[0]) / tb[1]);
|
|
237
|
+
if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
|
|
238
|
+
pkt.pts = us;
|
|
239
|
+
pkt.ptshi = us < 0 ? -1 : 0;
|
|
240
|
+
pkt.time_base_num = 1;
|
|
241
|
+
pkt.time_base_den = 1_000_000;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const fallback = nextUs();
|
|
245
|
+
pkt.pts = fallback;
|
|
246
|
+
pkt.ptshi = 0;
|
|
247
|
+
pkt.time_base_num = 1;
|
|
248
|
+
pkt.time_base_den = 1_000_000;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
252
|
+
// Audio frame → interleaved Float32 (extracted from
|
|
253
|
+
// strategies/hybrid/decoder.ts + strategies/fallback/decoder.ts).
|
|
254
|
+
//
|
|
255
|
+
// libav hands us decoded audio frames in whichever sample format the codec
|
|
256
|
+
// uses (FLTP, S16P, etc.). Most downstream consumers (Web Audio, WebCodecs
|
|
257
|
+
// AudioEncoder) want interleaved Float32. This does the conversion without
|
|
258
|
+
// any dependencies.
|
|
259
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
const AV_SAMPLE_FMT_U8 = 0;
|
|
262
|
+
const AV_SAMPLE_FMT_S16 = 1;
|
|
263
|
+
const AV_SAMPLE_FMT_S32 = 2;
|
|
264
|
+
const AV_SAMPLE_FMT_FLT = 3;
|
|
265
|
+
const AV_SAMPLE_FMT_U8P = 5;
|
|
266
|
+
const AV_SAMPLE_FMT_S16P = 6;
|
|
267
|
+
const AV_SAMPLE_FMT_S32P = 7;
|
|
268
|
+
const AV_SAMPLE_FMT_FLTP = 8;
|
|
269
|
+
|
|
270
|
+
export interface InterleavedSamples {
|
|
271
|
+
data: Float32Array;
|
|
272
|
+
channels: number;
|
|
273
|
+
sampleRate: number;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function libavFrameToInterleavedFloat32(frame: LibavFrame): InterleavedSamples | null {
|
|
277
|
+
const channels = frame.channels ?? frame.ch_layout_nb_channels ?? 1;
|
|
278
|
+
const sampleRate = frame.sample_rate ?? 44100;
|
|
279
|
+
const nbSamples = frame.nb_samples ?? 0;
|
|
280
|
+
if (nbSamples === 0) return null;
|
|
281
|
+
|
|
282
|
+
const out = new Float32Array(nbSamples * channels);
|
|
283
|
+
|
|
284
|
+
switch (frame.format) {
|
|
285
|
+
case AV_SAMPLE_FMT_FLTP: {
|
|
286
|
+
const planes = ensurePlanes(frame.data, channels);
|
|
287
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
288
|
+
const plane = asFloat32(planes[ch]);
|
|
289
|
+
for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = plane[i];
|
|
290
|
+
}
|
|
291
|
+
return { data: out, channels, sampleRate };
|
|
292
|
+
}
|
|
293
|
+
case AV_SAMPLE_FMT_FLT: {
|
|
294
|
+
const flat = asFloat32(frame.data);
|
|
295
|
+
for (let i = 0; i < nbSamples * channels; i++) out[i] = flat[i];
|
|
296
|
+
return { data: out, channels, sampleRate };
|
|
297
|
+
}
|
|
298
|
+
case AV_SAMPLE_FMT_S16P: {
|
|
299
|
+
const planes = ensurePlanes(frame.data, channels);
|
|
300
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
301
|
+
const plane = asInt16(planes[ch]);
|
|
302
|
+
for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = plane[i] / 32768;
|
|
303
|
+
}
|
|
304
|
+
return { data: out, channels, sampleRate };
|
|
305
|
+
}
|
|
306
|
+
case AV_SAMPLE_FMT_S16: {
|
|
307
|
+
const flat = asInt16(frame.data);
|
|
308
|
+
for (let i = 0; i < nbSamples * channels; i++) out[i] = flat[i] / 32768;
|
|
309
|
+
return { data: out, channels, sampleRate };
|
|
310
|
+
}
|
|
311
|
+
case AV_SAMPLE_FMT_S32P: {
|
|
312
|
+
const planes = ensurePlanes(frame.data, channels);
|
|
313
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
314
|
+
const plane = asInt32(planes[ch]);
|
|
315
|
+
for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = plane[i] / 2147483648;
|
|
316
|
+
}
|
|
317
|
+
return { data: out, channels, sampleRate };
|
|
318
|
+
}
|
|
319
|
+
case AV_SAMPLE_FMT_S32: {
|
|
320
|
+
const flat = asInt32(frame.data);
|
|
321
|
+
for (let i = 0; i < nbSamples * channels; i++) out[i] = flat[i] / 2147483648;
|
|
322
|
+
return { data: out, channels, sampleRate };
|
|
323
|
+
}
|
|
324
|
+
case AV_SAMPLE_FMT_U8P: {
|
|
325
|
+
const planes = ensurePlanes(frame.data, channels);
|
|
326
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
327
|
+
const plane = asUint8(planes[ch]);
|
|
328
|
+
for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = (plane[i] - 128) / 128;
|
|
329
|
+
}
|
|
330
|
+
return { data: out, channels, sampleRate };
|
|
331
|
+
}
|
|
332
|
+
case AV_SAMPLE_FMT_U8: {
|
|
333
|
+
const flat = asUint8(frame.data);
|
|
334
|
+
for (let i = 0; i < nbSamples * channels; i++) out[i] = (flat[i] - 128) / 128;
|
|
335
|
+
return { data: out, channels, sampleRate };
|
|
336
|
+
}
|
|
337
|
+
default:
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function ensurePlanes(data: unknown, channels: number): unknown[] {
|
|
343
|
+
if (Array.isArray(data)) return data;
|
|
344
|
+
const arr = data as { length: number; subarray?: (a: number, b: number) => unknown };
|
|
345
|
+
const len = arr.length;
|
|
346
|
+
const perChannel = Math.floor(len / channels);
|
|
347
|
+
const planes: unknown[] = [];
|
|
348
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
349
|
+
planes.push(arr.subarray ? arr.subarray(ch * perChannel, (ch + 1) * perChannel) : arr);
|
|
350
|
+
}
|
|
351
|
+
return planes;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function asFloat32(x: unknown): Float32Array {
|
|
355
|
+
if (x instanceof Float32Array) return x;
|
|
356
|
+
const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
|
|
357
|
+
return new Float32Array(ta.buffer, ta.byteOffset, ta.byteLength / 4);
|
|
358
|
+
}
|
|
359
|
+
function asInt16(x: unknown): Int16Array {
|
|
360
|
+
if (x instanceof Int16Array) return x;
|
|
361
|
+
const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
|
|
362
|
+
return new Int16Array(ta.buffer, ta.byteOffset, ta.byteLength / 2);
|
|
363
|
+
}
|
|
364
|
+
function asInt32(x: unknown): Int32Array {
|
|
365
|
+
if (x instanceof Int32Array) return x;
|
|
366
|
+
const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
|
|
367
|
+
return new Int32Array(ta.buffer, ta.byteOffset, ta.byteLength / 4);
|
|
368
|
+
}
|
|
369
|
+
function asUint8(x: unknown): Uint8Array {
|
|
370
|
+
if (x instanceof Uint8Array) return x;
|
|
371
|
+
const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
|
|
372
|
+
return new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Sanitize a decoded frame's timestamp. Mutates `frame` in place.
|
|
377
|
+
* Returns nothing; callers that want derived metadata (e.g. a
|
|
378
|
+
* VideoFrame timestamp in µs) should read `frame.pts` after calling.
|
|
379
|
+
*/
|
|
380
|
+
export function sanitizeFrameTimestamp(
|
|
381
|
+
frame: LibavFrame,
|
|
382
|
+
nextUs: () => number,
|
|
383
|
+
fallbackTimeBase?: [number, number],
|
|
384
|
+
): void {
|
|
385
|
+
const lo = frame.pts ?? 0;
|
|
386
|
+
const hi = frame.ptshi ?? 0;
|
|
387
|
+
const isInvalid = (hi === -2147483648 && lo === 0) || !Number.isFinite(lo);
|
|
388
|
+
if (isInvalid) {
|
|
389
|
+
const us = nextUs();
|
|
390
|
+
frame.pts = us;
|
|
391
|
+
frame.ptshi = 0;
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const tb = fallbackTimeBase ?? [1, 1_000_000];
|
|
395
|
+
const pts64 = hi * 0x100000000 + lo;
|
|
396
|
+
const us = Math.round((pts64 * 1_000_000 * tb[0]) / tb[1]);
|
|
397
|
+
if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
|
|
398
|
+
frame.pts = us;
|
|
399
|
+
frame.ptshi = us < 0 ? -1 : 0;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const fallback = nextUs();
|
|
403
|
+
frame.pts = fallback;
|
|
404
|
+
frame.ptshi = 0;
|
|
405
|
+
}
|