avbridge 1.0.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 +120 -0
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/avi-M5B4SHRM.cjs +164 -0
- package/dist/avi-M5B4SHRM.cjs.map +1 -0
- package/dist/avi-POCGZ4JX.js +162 -0
- package/dist/avi-POCGZ4JX.js.map +1 -0
- package/dist/chunk-5ISVAODK.js +80 -0
- package/dist/chunk-5ISVAODK.js.map +1 -0
- package/dist/chunk-F7YS2XOA.cjs +2966 -0
- package/dist/chunk-F7YS2XOA.cjs.map +1 -0
- package/dist/chunk-FKM7QBZU.js +2957 -0
- package/dist/chunk-FKM7QBZU.js.map +1 -0
- package/dist/chunk-J5MCMN3S.js +27 -0
- package/dist/chunk-J5MCMN3S.js.map +1 -0
- package/dist/chunk-L4NPOJ36.cjs +180 -0
- package/dist/chunk-L4NPOJ36.cjs.map +1 -0
- package/dist/chunk-NZU7W256.cjs +29 -0
- package/dist/chunk-NZU7W256.cjs.map +1 -0
- package/dist/chunk-PQTZS7OA.js +147 -0
- package/dist/chunk-PQTZS7OA.js.map +1 -0
- package/dist/chunk-WD2ZNQA7.js +177 -0
- package/dist/chunk-WD2ZNQA7.js.map +1 -0
- package/dist/chunk-Y5FYF5KG.cjs +153 -0
- package/dist/chunk-Y5FYF5KG.cjs.map +1 -0
- package/dist/chunk-Z2FJ5TJC.cjs +82 -0
- package/dist/chunk-Z2FJ5TJC.cjs.map +1 -0
- package/dist/element.cjs +433 -0
- package/dist/element.cjs.map +1 -0
- package/dist/element.d.cts +158 -0
- package/dist/element.d.ts +158 -0
- package/dist/element.js +431 -0
- package/dist/element.js.map +1 -0
- package/dist/index.cjs +576 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +80 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +554 -0
- package/dist/index.js.map +1 -0
- package/dist/libav-http-reader-FPYDBMYK.cjs +16 -0
- package/dist/libav-http-reader-FPYDBMYK.cjs.map +1 -0
- package/dist/libav-http-reader-NQJVY273.js +3 -0
- package/dist/libav-http-reader-NQJVY273.js.map +1 -0
- package/dist/libav-import-2JURFHEW.js +8 -0
- package/dist/libav-import-2JURFHEW.js.map +1 -0
- package/dist/libav-import-GST2AMPL.cjs +30 -0
- package/dist/libav-import-GST2AMPL.cjs.map +1 -0
- package/dist/libav-loader-KA2MAWLM.js +3 -0
- package/dist/libav-loader-KA2MAWLM.js.map +1 -0
- package/dist/libav-loader-ZHOERPHW.cjs +12 -0
- package/dist/libav-loader-ZHOERPHW.cjs.map +1 -0
- package/dist/player-BBwbCkdL.d.cts +365 -0
- package/dist/player-BBwbCkdL.d.ts +365 -0
- package/dist/source-SC6ZEQYR.cjs +28 -0
- package/dist/source-SC6ZEQYR.cjs.map +1 -0
- package/dist/source-ZFS4H7J3.js +3 -0
- package/dist/source-ZFS4H7J3.js.map +1 -0
- package/dist/variant-routing-GOHB2RZN.cjs +12 -0
- package/dist/variant-routing-GOHB2RZN.cjs.map +1 -0
- package/dist/variant-routing-JOBWXYKD.js +3 -0
- package/dist/variant-routing-JOBWXYKD.js.map +1 -0
- package/package.json +95 -0
- package/src/classify/index.ts +1 -0
- package/src/classify/rules.ts +214 -0
- package/src/convert/index.ts +2 -0
- package/src/convert/remux.ts +522 -0
- package/src/convert/transcode.ts +329 -0
- package/src/diagnostics.ts +99 -0
- package/src/element/avbridge-player.ts +576 -0
- package/src/element.ts +19 -0
- package/src/events.ts +71 -0
- package/src/index.ts +42 -0
- package/src/libav-stubs.d.ts +24 -0
- package/src/player.ts +455 -0
- package/src/plugins/builtin.ts +37 -0
- package/src/plugins/registry.ts +32 -0
- package/src/probe/avi.ts +242 -0
- package/src/probe/index.ts +59 -0
- package/src/probe/mediabunny.ts +194 -0
- package/src/strategies/fallback/audio-output.ts +293 -0
- package/src/strategies/fallback/clock.ts +7 -0
- package/src/strategies/fallback/decoder.ts +660 -0
- package/src/strategies/fallback/index.ts +170 -0
- package/src/strategies/fallback/libav-import.ts +27 -0
- package/src/strategies/fallback/libav-loader.ts +190 -0
- package/src/strategies/fallback/variant-routing.ts +43 -0
- package/src/strategies/fallback/video-renderer.ts +216 -0
- package/src/strategies/hybrid/decoder.ts +641 -0
- package/src/strategies/hybrid/index.ts +139 -0
- package/src/strategies/native.ts +107 -0
- package/src/strategies/remux/annexb.ts +112 -0
- package/src/strategies/remux/index.ts +79 -0
- package/src/strategies/remux/mse.ts +234 -0
- package/src/strategies/remux/pipeline.ts +254 -0
- package/src/subtitles/index.ts +91 -0
- package/src/subtitles/render.ts +62 -0
- package/src/subtitles/srt.ts +62 -0
- package/src/subtitles/vtt.ts +5 -0
- package/src/types-shim.d.ts +3 -0
- package/src/types.ts +360 -0
- package/src/util/codec-strings.ts +86 -0
- package/src/util/libav-http-reader.ts +315 -0
- package/src/util/source.ts +274 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* libav.js demux + decode loop for the fallback strategy.
|
|
3
|
+
*
|
|
4
|
+
* Design:
|
|
5
|
+
*
|
|
6
|
+
* - **Always software decode.** The fallback strategy is only entered when
|
|
7
|
+
* classification has decided no browser decoder will handle the codec set,
|
|
8
|
+
* so the WebCodecs hardware path is dead weight here. Going through libav
|
|
9
|
+
* uniformly also avoids brittleness around `EncodedAudioChunk` framing for
|
|
10
|
+
* codecs like MP3-in-AVI where the browser's AudioDecoder rejects libav's
|
|
11
|
+
* raw demuxed packets.
|
|
12
|
+
*
|
|
13
|
+
* - **Cancellable pump loop.** Each pump iteration is gated on a token that
|
|
14
|
+
* `seek()` increments. When the token changes mid-batch, the loop exits
|
|
15
|
+
* and a fresh one starts at the new position. This is how seek interrupts
|
|
16
|
+
* the decoder cleanly without having to await an arbitrarily long
|
|
17
|
+
* `ff_decode_multi` call.
|
|
18
|
+
*
|
|
19
|
+
* - **Synthetic timestamps.** AVI demuxers report `AV_NOPTS_VALUE` for most
|
|
20
|
+
* packets — they're frame-indexed, not time-indexed. We replace any
|
|
21
|
+
* invalid pts with a per-stream synthetic counter (frame index × 1e6/fps
|
|
22
|
+
* for video; sample-accurate for audio) so the bridge's chunk constructor
|
|
23
|
+
* doesn't overflow int64.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { loadLibav, type LibavVariant } from "./libav-loader.js";
|
|
27
|
+
import { VideoRenderer } from "./video-renderer.js";
|
|
28
|
+
import { AudioOutput } from "./audio-output.js";
|
|
29
|
+
import type { MediaContext } from "../../types.js";
|
|
30
|
+
import { pickLibavVariant } from "./variant-routing.js";
|
|
31
|
+
|
|
32
|
+
export interface DecoderHandles {
|
|
33
|
+
destroy(): Promise<void>;
|
|
34
|
+
/** Seek to the given time in seconds. Returns once the new pump has been kicked off. */
|
|
35
|
+
seek(timeSec: number): Promise<void>;
|
|
36
|
+
stats(): Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StartDecoderOptions {
|
|
40
|
+
/** Normalized source — either a Blob in memory or a URL we'll stream via Range requests. */
|
|
41
|
+
source: import("../../util/source.js").NormalizedSource;
|
|
42
|
+
filename: string;
|
|
43
|
+
context: MediaContext;
|
|
44
|
+
renderer: VideoRenderer;
|
|
45
|
+
audio: AudioOutput;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHandles> {
|
|
49
|
+
const variant: LibavVariant = pickLibavVariant(opts.context);
|
|
50
|
+
const libav = (await loadLibav(variant)) as unknown as LibavRuntime;
|
|
51
|
+
const bridge = await loadBridge();
|
|
52
|
+
|
|
53
|
+
// For URL sources, prepareLibavInput attaches an HTTP block reader so
|
|
54
|
+
// libav demuxes via Range requests. For Blob sources, it falls back to
|
|
55
|
+
// mkreadaheadfile (in-memory). The returned handle owns cleanup.
|
|
56
|
+
const { prepareLibavInput } = await import("../../util/libav-http-reader.js");
|
|
57
|
+
const inputHandle = await prepareLibavInput(libav as unknown as Parameters<typeof prepareLibavInput>[0], opts.filename, opts.source);
|
|
58
|
+
|
|
59
|
+
// Pre-allocate one AVPacket for ff_read_frame_multi to reuse.
|
|
60
|
+
const readPkt = await libav.av_packet_alloc();
|
|
61
|
+
|
|
62
|
+
const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
|
|
63
|
+
const videoStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_VIDEO) ?? null;
|
|
64
|
+
const audioStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_AUDIO) ?? null;
|
|
65
|
+
|
|
66
|
+
if (!videoStream && !audioStream) {
|
|
67
|
+
throw new Error("fallback decoder: file has no decodable streams");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Set up software decoders ─────────────────────────────────────────
|
|
71
|
+
let videoDec: SoftDecoder | null = null;
|
|
72
|
+
let audioDec: SoftDecoder | null = null;
|
|
73
|
+
let videoTimeBase: [number, number] | undefined;
|
|
74
|
+
let audioTimeBase: [number, number] | undefined;
|
|
75
|
+
|
|
76
|
+
if (videoStream) {
|
|
77
|
+
try {
|
|
78
|
+
const [, c, pkt, frame] = await libav.ff_init_decoder(videoStream.codec_id, {
|
|
79
|
+
codecpar: videoStream.codecpar,
|
|
80
|
+
});
|
|
81
|
+
videoDec = { c, pkt, frame };
|
|
82
|
+
if (videoStream.time_base_num && videoStream.time_base_den) {
|
|
83
|
+
videoTimeBase = [videoStream.time_base_num, videoStream.time_base_den];
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error("[avbridge] failed to init video decoder:", err);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (audioStream) {
|
|
91
|
+
try {
|
|
92
|
+
const [, c, pkt, frame] = await libav.ff_init_decoder(audioStream.codec_id, {
|
|
93
|
+
codecpar: audioStream.codecpar,
|
|
94
|
+
});
|
|
95
|
+
audioDec = { c, pkt, frame };
|
|
96
|
+
if (audioStream.time_base_num && audioStream.time_base_den) {
|
|
97
|
+
audioTimeBase = [audioStream.time_base_num, audioStream.time_base_den];
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.warn(
|
|
101
|
+
"[avbridge] fallback: audio decoder unavailable — playing video with wall-clock timing:",
|
|
102
|
+
(err as Error).message,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// No audio decoder? Switch audio output into wall-clock mode so video can
|
|
108
|
+
// play even when the audio codec isn't supported by the loaded libav variant.
|
|
109
|
+
if (!audioDec) {
|
|
110
|
+
opts.audio.setNoAudio();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!videoDec && !audioDec) {
|
|
114
|
+
await inputHandle.detach().catch(() => {});
|
|
115
|
+
const codecs = [
|
|
116
|
+
videoStream ? `video: ${opts.context.videoTracks[0]?.codec ?? "unknown"}` : null,
|
|
117
|
+
audioStream ? `audio: ${opts.context.audioTracks[0]?.codec ?? "unknown"}` : null,
|
|
118
|
+
].filter(Boolean).join(", ");
|
|
119
|
+
const hint = variant === "webcodecs"
|
|
120
|
+
? ` The "${variant}" libav variant does not include software decoders for these codecs. ` +
|
|
121
|
+
`Try the custom "avbridge" variant (scripts/build-libav.sh) for broader codec support, ` +
|
|
122
|
+
`or use a lighter strategy (native, remux, hybrid) instead.`
|
|
123
|
+
: "";
|
|
124
|
+
throw new Error(
|
|
125
|
+
`fallback decoder: could not initialize any libav decoders (${codecs}).${hint}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Mutable state shared across pump loops ───────────────────────────
|
|
130
|
+
let destroyed = false;
|
|
131
|
+
let pumpToken = 0; // bumped on seek; pump loops bail when token changes
|
|
132
|
+
let pumpRunning: Promise<void> | null = null;
|
|
133
|
+
|
|
134
|
+
let packetsRead = 0;
|
|
135
|
+
let videoFramesDecoded = 0;
|
|
136
|
+
let audioFramesDecoded = 0;
|
|
137
|
+
|
|
138
|
+
// Synthetic timestamp counters. Reset on seek.
|
|
139
|
+
let syntheticVideoUs = 0;
|
|
140
|
+
let syntheticAudioUs = 0;
|
|
141
|
+
|
|
142
|
+
const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
|
|
143
|
+
const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
|
|
144
|
+
const videoFrameStepUs = Math.max(1, Math.round(1_000_000 / videoFps));
|
|
145
|
+
|
|
146
|
+
// ── Pump loop ─────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
async function pumpLoop(myToken: number): Promise<void> {
|
|
149
|
+
while (!destroyed && myToken === pumpToken) {
|
|
150
|
+
let readErr: number;
|
|
151
|
+
let packets: Record<number, LibavPacket[]>;
|
|
152
|
+
try {
|
|
153
|
+
// Smaller batch = fewer frames per decode round = less queue burst.
|
|
154
|
+
// 16 KB ≈ 4 video packets + ~12 audio packets at typical DivX
|
|
155
|
+
// bitrates. The renderer drains ~1 frame per 33ms rAF tick, so
|
|
156
|
+
// keeping bursts ≤ 4-6 frames prevents queue overflow.
|
|
157
|
+
[readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
|
|
158
|
+
limit: 16 * 1024,
|
|
159
|
+
});
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error("[avbridge] ff_read_frame_multi failed:", err);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (myToken !== pumpToken || destroyed) return;
|
|
166
|
+
|
|
167
|
+
const videoPackets = videoStream ? packets[videoStream.index] : undefined;
|
|
168
|
+
const audioPackets = audioStream ? packets[audioStream.index] : undefined;
|
|
169
|
+
|
|
170
|
+
if (videoDec && videoPackets && videoPackets.length > 0) {
|
|
171
|
+
await decodeVideoBatch(videoPackets, myToken);
|
|
172
|
+
}
|
|
173
|
+
if (myToken !== pumpToken || destroyed) return;
|
|
174
|
+
if (audioDec && audioPackets && audioPackets.length > 0) {
|
|
175
|
+
await decodeAudioBatch(audioPackets, myToken);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
packetsRead += (videoPackets?.length ?? 0) + (audioPackets?.length ?? 0);
|
|
179
|
+
|
|
180
|
+
// Throttle: don't run too far ahead of playback. Two backpressure
|
|
181
|
+
// signals:
|
|
182
|
+
// - Audio buffer (mediaTimeOfNext - now()) > 2 sec — we have
|
|
183
|
+
// plenty of audio scheduled.
|
|
184
|
+
// - Renderer queue depth >= queueHighWater — the canvas can't
|
|
185
|
+
// drain fast enough. Without this, fast software decode of
|
|
186
|
+
// small frames piles up in the renderer and overflows.
|
|
187
|
+
while (
|
|
188
|
+
!destroyed &&
|
|
189
|
+
myToken === pumpToken &&
|
|
190
|
+
(opts.audio.bufferAhead() > 2.0 ||
|
|
191
|
+
opts.renderer.queueDepth() >= opts.renderer.queueHighWater)
|
|
192
|
+
) {
|
|
193
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (readErr === libav.AVERROR_EOF) {
|
|
197
|
+
if (videoDec) await decodeVideoBatch([], myToken, /*flush*/ true);
|
|
198
|
+
if (audioDec) await decodeAudioBatch([], myToken, /*flush*/ true);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (readErr && readErr !== 0 && readErr !== -libav.EAGAIN) {
|
|
202
|
+
console.warn("[avbridge] ff_read_frame_multi returned", readErr);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function decodeVideoBatch(pkts: LibavPacket[], myToken: number, flush = false) {
|
|
209
|
+
if (!videoDec || destroyed || myToken !== pumpToken) return;
|
|
210
|
+
let frames: LibavFrame[];
|
|
211
|
+
try {
|
|
212
|
+
frames = await libav.ff_decode_multi(
|
|
213
|
+
videoDec.c,
|
|
214
|
+
videoDec.pkt,
|
|
215
|
+
videoDec.frame,
|
|
216
|
+
pkts,
|
|
217
|
+
flush ? { fin: true, ignoreErrors: true } : { ignoreErrors: true },
|
|
218
|
+
);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.error("[avbridge] video decode batch failed:", err);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (myToken !== pumpToken || destroyed) return;
|
|
224
|
+
|
|
225
|
+
for (const f of frames) {
|
|
226
|
+
if (myToken !== pumpToken || destroyed) return;
|
|
227
|
+
const bridgeOpts = sanitizeFrameTimestamp(
|
|
228
|
+
f,
|
|
229
|
+
() => {
|
|
230
|
+
const ts = syntheticVideoUs;
|
|
231
|
+
syntheticVideoUs += videoFrameStepUs;
|
|
232
|
+
return ts;
|
|
233
|
+
},
|
|
234
|
+
videoTimeBase,
|
|
235
|
+
);
|
|
236
|
+
try {
|
|
237
|
+
const vf = bridge.laFrameToVideoFrame(f, bridgeOpts);
|
|
238
|
+
opts.renderer.enqueue(vf);
|
|
239
|
+
videoFramesDecoded++;
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (videoFramesDecoded === 0) {
|
|
242
|
+
console.warn("[avbridge] laFrameToVideoFrame failed:", err);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function decodeAudioBatch(pkts: LibavPacket[], myToken: number, flush = false) {
|
|
249
|
+
if (!audioDec || destroyed || myToken !== pumpToken) return;
|
|
250
|
+
let frames: LibavFrame[];
|
|
251
|
+
try {
|
|
252
|
+
frames = await libav.ff_decode_multi(
|
|
253
|
+
audioDec.c,
|
|
254
|
+
audioDec.pkt,
|
|
255
|
+
audioDec.frame,
|
|
256
|
+
pkts,
|
|
257
|
+
flush ? { fin: true, ignoreErrors: true } : { ignoreErrors: true },
|
|
258
|
+
);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error("[avbridge] audio decode batch failed:", err);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (myToken !== pumpToken || destroyed) return;
|
|
264
|
+
|
|
265
|
+
for (const f of frames) {
|
|
266
|
+
if (myToken !== pumpToken || destroyed) return;
|
|
267
|
+
sanitizeFrameTimestamp(
|
|
268
|
+
f,
|
|
269
|
+
() => {
|
|
270
|
+
const ts = syntheticAudioUs;
|
|
271
|
+
const samples = f.nb_samples ?? 1024;
|
|
272
|
+
const sampleRate = f.sample_rate ?? 44100;
|
|
273
|
+
syntheticAudioUs += Math.round((samples * 1_000_000) / sampleRate);
|
|
274
|
+
return ts;
|
|
275
|
+
},
|
|
276
|
+
audioTimeBase,
|
|
277
|
+
);
|
|
278
|
+
const samples = libavFrameToInterleavedFloat32(f);
|
|
279
|
+
if (samples) {
|
|
280
|
+
opts.audio.schedule(samples.data, samples.channels, samples.sampleRate);
|
|
281
|
+
audioFramesDecoded++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Kick off the initial pump.
|
|
287
|
+
pumpToken = 1;
|
|
288
|
+
pumpRunning = pumpLoop(pumpToken).catch((err) =>
|
|
289
|
+
console.error("[avbridge] decoder pump failed:", err),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
async destroy() {
|
|
294
|
+
destroyed = true;
|
|
295
|
+
pumpToken++;
|
|
296
|
+
try { await pumpRunning; } catch { /* ignore */ }
|
|
297
|
+
try { if (videoDec) await libav.ff_free_decoder?.(videoDec.c, videoDec.pkt, videoDec.frame); } catch { /* ignore */ }
|
|
298
|
+
try { if (audioDec) await libav.ff_free_decoder?.(audioDec.c, audioDec.pkt, audioDec.frame); } catch { /* ignore */ }
|
|
299
|
+
try { await libav.av_packet_free?.(readPkt); } catch { /* ignore */ }
|
|
300
|
+
try { await libav.avformat_close_input_js(fmt_ctx); } catch { /* ignore */ }
|
|
301
|
+
try { await inputHandle.detach(); } catch { /* ignore */ }
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
async seek(timeSec) {
|
|
305
|
+
// Cancel the current pump and wait for it to actually exit before
|
|
306
|
+
// we start moving file pointers around — concurrent ff_decode_multi
|
|
307
|
+
// and av_seek_frame on the same context would be a recipe for memory
|
|
308
|
+
// corruption inside libav.
|
|
309
|
+
const newToken = ++pumpToken;
|
|
310
|
+
if (pumpRunning) {
|
|
311
|
+
try { await pumpRunning; } catch { /* ignore */ }
|
|
312
|
+
}
|
|
313
|
+
if (destroyed) return;
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
// libav.js's `av_seek_frame` takes the timestamp as a *split*
|
|
317
|
+
// (lo, hi) int64 pair, NOT a single number. The function signature
|
|
318
|
+
// is: av_seek_frame(s, stream_index, tsLo, tsHi, flags). Passing a
|
|
319
|
+
// single number put AVSEEK_FLAG_BACKWARD (1) into tsHi, which
|
|
320
|
+
// produced a bogus int64 = 4.29e9 + tsLo ≈ 73 min for any small
|
|
321
|
+
// seek target — seeking past EOF and stalling the pump.
|
|
322
|
+
const tsUs = Math.floor(timeSec * 1_000_000);
|
|
323
|
+
const [tsLo, tsHi] = libav.f64toi64
|
|
324
|
+
? libav.f64toi64(tsUs)
|
|
325
|
+
: [tsUs | 0, Math.floor(tsUs / 0x100000000)];
|
|
326
|
+
await libav.av_seek_frame(
|
|
327
|
+
fmt_ctx,
|
|
328
|
+
-1,
|
|
329
|
+
tsLo,
|
|
330
|
+
tsHi,
|
|
331
|
+
libav.AVSEEK_FLAG_BACKWARD ?? 0,
|
|
332
|
+
);
|
|
333
|
+
} catch (err) {
|
|
334
|
+
console.warn("[avbridge] av_seek_frame failed:", err);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Reset the decoder state. After the previous pump exited via the
|
|
338
|
+
// EOF path it called ff_decode_multi with `fin: true`, which sends a
|
|
339
|
+
// NULL packet to the decoder and puts it in drain mode — meaning all
|
|
340
|
+
// subsequent decode calls return EOF. `avcodec_flush_buffers` clears
|
|
341
|
+
// that state so a fresh stream of post-seek packets is accepted.
|
|
342
|
+
// Also clears any internal frame reordering buffer, which is what we
|
|
343
|
+
// want anyway since we just changed positions.
|
|
344
|
+
try {
|
|
345
|
+
if (videoDec) await libav.avcodec_flush_buffers?.(videoDec.c);
|
|
346
|
+
} catch { /* ignore */ }
|
|
347
|
+
try {
|
|
348
|
+
if (audioDec) await libav.avcodec_flush_buffers?.(audioDec.c);
|
|
349
|
+
} catch { /* ignore */ }
|
|
350
|
+
|
|
351
|
+
// Reset synthetic timestamp counters to the seek target so newly
|
|
352
|
+
// decoded frames start at the right media time.
|
|
353
|
+
syntheticVideoUs = Math.round(timeSec * 1_000_000);
|
|
354
|
+
syntheticAudioUs = Math.round(timeSec * 1_000_000);
|
|
355
|
+
|
|
356
|
+
// The renderer & audio output are reset by the fallback session
|
|
357
|
+
// wrapper that called us — see strategies/fallback/index.ts.
|
|
358
|
+
|
|
359
|
+
// Start a fresh pump for the new token.
|
|
360
|
+
pumpRunning = pumpLoop(newToken).catch((err) =>
|
|
361
|
+
console.error("[avbridge] decoder pump failed (post-seek):", err),
|
|
362
|
+
);
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
stats() {
|
|
366
|
+
return {
|
|
367
|
+
decoderType: "libav-wasm",
|
|
368
|
+
packetsRead,
|
|
369
|
+
videoFramesDecoded,
|
|
370
|
+
audioFramesDecoded,
|
|
371
|
+
...opts.renderer.stats(),
|
|
372
|
+
...opts.audio.stats(),
|
|
373
|
+
};
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
379
|
+
// Frame timestamp sanitizer.
|
|
380
|
+
//
|
|
381
|
+
// libav can hand back decoded frames with `pts = AV_NOPTS_VALUE` (encoded as
|
|
382
|
+
// ptshi = -2147483648, pts = 0) for inputs whose demuxer can't determine
|
|
383
|
+
// presentation times. AVI is the canonical example. The bridge's
|
|
384
|
+
// `laFrameToVideoFrame` then multiplies pts × 1e6 × tbNum / tbDen and
|
|
385
|
+
// overflows int64, throwing "Value is outside the 'long long' value range".
|
|
386
|
+
//
|
|
387
|
+
// Fix: replace any invalid pts with a synthetic microsecond counter, force
|
|
388
|
+
// the frame's pts/ptshi to that value, and tell the bridge to use a 1/1e6
|
|
389
|
+
// timebase so it does an identity conversion.
|
|
390
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
interface BridgeOpts {
|
|
393
|
+
timeBase?: [number, number];
|
|
394
|
+
transfer?: boolean;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function sanitizeFrameTimestamp(
|
|
398
|
+
frame: LibavFrame,
|
|
399
|
+
nextUs: () => number,
|
|
400
|
+
fallbackTimeBase?: [number, number],
|
|
401
|
+
): BridgeOpts {
|
|
402
|
+
const lo = frame.pts ?? 0;
|
|
403
|
+
const hi = frame.ptshi ?? 0;
|
|
404
|
+
const isInvalid = (hi === -2147483648 && lo === 0) || !Number.isFinite(lo);
|
|
405
|
+
if (isInvalid) {
|
|
406
|
+
const us = nextUs();
|
|
407
|
+
frame.pts = us;
|
|
408
|
+
frame.ptshi = 0;
|
|
409
|
+
return { timeBase: [1, 1_000_000] };
|
|
410
|
+
}
|
|
411
|
+
const tb = fallbackTimeBase ?? [1, 1_000_000];
|
|
412
|
+
const pts64 = hi * 0x100000000 + lo;
|
|
413
|
+
const us = Math.round((pts64 * 1_000_000 * tb[0]) / tb[1]);
|
|
414
|
+
if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
|
|
415
|
+
frame.pts = us;
|
|
416
|
+
frame.ptshi = us < 0 ? -1 : 0;
|
|
417
|
+
return { timeBase: [1, 1_000_000] };
|
|
418
|
+
}
|
|
419
|
+
const fallback = nextUs();
|
|
420
|
+
frame.pts = fallback;
|
|
421
|
+
frame.ptshi = 0;
|
|
422
|
+
return { timeBase: [1, 1_000_000] };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
426
|
+
// libav decoded `Frame` → interleaved Float32Array (the format AudioOutput
|
|
427
|
+
// schedules).
|
|
428
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
const AV_SAMPLE_FMT_U8 = 0;
|
|
431
|
+
const AV_SAMPLE_FMT_S16 = 1;
|
|
432
|
+
const AV_SAMPLE_FMT_S32 = 2;
|
|
433
|
+
const AV_SAMPLE_FMT_FLT = 3;
|
|
434
|
+
const AV_SAMPLE_FMT_U8P = 5;
|
|
435
|
+
const AV_SAMPLE_FMT_S16P = 6;
|
|
436
|
+
const AV_SAMPLE_FMT_S32P = 7;
|
|
437
|
+
const AV_SAMPLE_FMT_FLTP = 8;
|
|
438
|
+
|
|
439
|
+
interface InterleavedSamples {
|
|
440
|
+
data: Float32Array;
|
|
441
|
+
channels: number;
|
|
442
|
+
sampleRate: number;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function libavFrameToInterleavedFloat32(frame: LibavFrame): InterleavedSamples | null {
|
|
446
|
+
const channels = frame.channels ?? frame.ch_layout_nb_channels ?? 1;
|
|
447
|
+
const sampleRate = frame.sample_rate ?? 44100;
|
|
448
|
+
const nbSamples = frame.nb_samples ?? 0;
|
|
449
|
+
if (nbSamples === 0) return null;
|
|
450
|
+
|
|
451
|
+
const out = new Float32Array(nbSamples * channels);
|
|
452
|
+
|
|
453
|
+
switch (frame.format) {
|
|
454
|
+
case AV_SAMPLE_FMT_FLTP: {
|
|
455
|
+
const planes = ensurePlanes(frame.data, channels);
|
|
456
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
457
|
+
const plane = asFloat32(planes[ch]);
|
|
458
|
+
for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = plane[i];
|
|
459
|
+
}
|
|
460
|
+
return { data: out, channels, sampleRate };
|
|
461
|
+
}
|
|
462
|
+
case AV_SAMPLE_FMT_FLT: {
|
|
463
|
+
const flat = asFloat32(frame.data);
|
|
464
|
+
for (let i = 0; i < nbSamples * channels; i++) out[i] = flat[i];
|
|
465
|
+
return { data: out, channels, sampleRate };
|
|
466
|
+
}
|
|
467
|
+
case AV_SAMPLE_FMT_S16P: {
|
|
468
|
+
const planes = ensurePlanes(frame.data, channels);
|
|
469
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
470
|
+
const plane = asInt16(planes[ch]);
|
|
471
|
+
for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = plane[i] / 32768;
|
|
472
|
+
}
|
|
473
|
+
return { data: out, channels, sampleRate };
|
|
474
|
+
}
|
|
475
|
+
case AV_SAMPLE_FMT_S16: {
|
|
476
|
+
const flat = asInt16(frame.data);
|
|
477
|
+
for (let i = 0; i < nbSamples * channels; i++) out[i] = flat[i] / 32768;
|
|
478
|
+
return { data: out, channels, sampleRate };
|
|
479
|
+
}
|
|
480
|
+
case AV_SAMPLE_FMT_S32P: {
|
|
481
|
+
const planes = ensurePlanes(frame.data, channels);
|
|
482
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
483
|
+
const plane = asInt32(planes[ch]);
|
|
484
|
+
for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = plane[i] / 2147483648;
|
|
485
|
+
}
|
|
486
|
+
return { data: out, channels, sampleRate };
|
|
487
|
+
}
|
|
488
|
+
case AV_SAMPLE_FMT_S32: {
|
|
489
|
+
const flat = asInt32(frame.data);
|
|
490
|
+
for (let i = 0; i < nbSamples * channels; i++) out[i] = flat[i] / 2147483648;
|
|
491
|
+
return { data: out, channels, sampleRate };
|
|
492
|
+
}
|
|
493
|
+
case AV_SAMPLE_FMT_U8P: {
|
|
494
|
+
const planes = ensurePlanes(frame.data, channels);
|
|
495
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
496
|
+
const plane = asUint8(planes[ch]);
|
|
497
|
+
for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = (plane[i] - 128) / 128;
|
|
498
|
+
}
|
|
499
|
+
return { data: out, channels, sampleRate };
|
|
500
|
+
}
|
|
501
|
+
case AV_SAMPLE_FMT_U8: {
|
|
502
|
+
const flat = asUint8(frame.data);
|
|
503
|
+
for (let i = 0; i < nbSamples * channels; i++) out[i] = (flat[i] - 128) / 128;
|
|
504
|
+
return { data: out, channels, sampleRate };
|
|
505
|
+
}
|
|
506
|
+
default:
|
|
507
|
+
if (!(globalThis as { __avbridgeLoggedSampleFmt?: number }).__avbridgeLoggedSampleFmt) {
|
|
508
|
+
(globalThis as { __avbridgeLoggedSampleFmt?: number }).__avbridgeLoggedSampleFmt = frame.format;
|
|
509
|
+
console.warn(`[avbridge] unsupported audio sample format from libav: ${frame.format}`);
|
|
510
|
+
}
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function ensurePlanes(data: unknown, channels: number): unknown[] {
|
|
516
|
+
if (Array.isArray(data)) return data;
|
|
517
|
+
const arr = data as { length: number; subarray?: (a: number, b: number) => unknown };
|
|
518
|
+
const len = arr.length;
|
|
519
|
+
const perChannel = Math.floor(len / channels);
|
|
520
|
+
const planes: unknown[] = [];
|
|
521
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
522
|
+
planes.push(arr.subarray ? arr.subarray(ch * perChannel, (ch + 1) * perChannel) : arr);
|
|
523
|
+
}
|
|
524
|
+
return planes;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function asFloat32(x: unknown): Float32Array {
|
|
528
|
+
if (x instanceof Float32Array) return x;
|
|
529
|
+
const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
|
|
530
|
+
return new Float32Array(ta.buffer, ta.byteOffset, ta.byteLength / 4);
|
|
531
|
+
}
|
|
532
|
+
function asInt16(x: unknown): Int16Array {
|
|
533
|
+
if (x instanceof Int16Array) return x;
|
|
534
|
+
const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
|
|
535
|
+
return new Int16Array(ta.buffer, ta.byteOffset, ta.byteLength / 2);
|
|
536
|
+
}
|
|
537
|
+
function asInt32(x: unknown): Int32Array {
|
|
538
|
+
if (x instanceof Int32Array) return x;
|
|
539
|
+
const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
|
|
540
|
+
return new Int32Array(ta.buffer, ta.byteOffset, ta.byteLength / 4);
|
|
541
|
+
}
|
|
542
|
+
function asUint8(x: unknown): Uint8Array {
|
|
543
|
+
if (x instanceof Uint8Array) return x;
|
|
544
|
+
const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
|
|
545
|
+
return new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
549
|
+
// Bridge loader (lazy via the static-import wrapper).
|
|
550
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
async function loadBridge(): Promise<BridgeModule> {
|
|
553
|
+
try {
|
|
554
|
+
const wrapper = await import("./libav-import.js");
|
|
555
|
+
return wrapper.libavBridge as unknown as BridgeModule;
|
|
556
|
+
} catch (err) {
|
|
557
|
+
throw new Error(
|
|
558
|
+
`failed to load libavjs-webcodecs-bridge — install the optional peer deps with: ` +
|
|
559
|
+
`npm i libavjs-webcodecs-bridge @libav.js/variant-webcodecs. ` +
|
|
560
|
+
`(${(err as Error).message})`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
566
|
+
// Structural types.
|
|
567
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
568
|
+
|
|
569
|
+
interface SoftDecoder {
|
|
570
|
+
c: number;
|
|
571
|
+
pkt: number;
|
|
572
|
+
frame: number;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
interface LibavStream {
|
|
576
|
+
index: number;
|
|
577
|
+
codec_type: number;
|
|
578
|
+
codec_id: number;
|
|
579
|
+
codecpar: number;
|
|
580
|
+
time_base_num?: number;
|
|
581
|
+
time_base_den?: number;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
interface LibavPacket {
|
|
585
|
+
data: Uint8Array;
|
|
586
|
+
pts: number;
|
|
587
|
+
ptshi?: number;
|
|
588
|
+
duration?: number;
|
|
589
|
+
durationhi?: number;
|
|
590
|
+
flags: number;
|
|
591
|
+
stream_index: number;
|
|
592
|
+
time_base_num?: number;
|
|
593
|
+
time_base_den?: number;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
interface LibavFrame {
|
|
597
|
+
data: unknown;
|
|
598
|
+
format: number;
|
|
599
|
+
channels?: number;
|
|
600
|
+
ch_layout_nb_channels?: number;
|
|
601
|
+
sample_rate?: number;
|
|
602
|
+
nb_samples?: number;
|
|
603
|
+
pts?: number;
|
|
604
|
+
ptshi?: number;
|
|
605
|
+
width?: number;
|
|
606
|
+
height?: number;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
interface LibavRuntime {
|
|
610
|
+
AVMEDIA_TYPE_VIDEO: number;
|
|
611
|
+
AVMEDIA_TYPE_AUDIO: number;
|
|
612
|
+
AVERROR_EOF: number;
|
|
613
|
+
EAGAIN: number;
|
|
614
|
+
AVSEEK_FLAG_BACKWARD?: number;
|
|
615
|
+
|
|
616
|
+
mkreadaheadfile(name: string, blob: Blob): Promise<void>;
|
|
617
|
+
unlinkreadaheadfile(name: string): Promise<void>;
|
|
618
|
+
ff_init_demuxer_file(name: string): Promise<[number, LibavStream[]]>;
|
|
619
|
+
ff_read_frame_multi(
|
|
620
|
+
fmt_ctx: number,
|
|
621
|
+
pkt: number,
|
|
622
|
+
opts?: { limit?: number },
|
|
623
|
+
): Promise<[number, Record<number, LibavPacket[]>]>;
|
|
624
|
+
ff_init_decoder(
|
|
625
|
+
codec: number | string,
|
|
626
|
+
config?: { codecpar?: number; time_base?: [number, number] },
|
|
627
|
+
): Promise<[number, number, number, number]>;
|
|
628
|
+
ff_decode_multi(
|
|
629
|
+
c: number,
|
|
630
|
+
pkt: number,
|
|
631
|
+
frame: number,
|
|
632
|
+
packets: LibavPacket[],
|
|
633
|
+
opts?: { fin?: boolean; ignoreErrors?: boolean },
|
|
634
|
+
): Promise<LibavFrame[]>;
|
|
635
|
+
ff_free_decoder?(c: number, pkt: number, frame: number): Promise<void>;
|
|
636
|
+
av_packet_alloc(): Promise<number>;
|
|
637
|
+
av_packet_free?(pkt: number): Promise<void>;
|
|
638
|
+
av_seek_frame(
|
|
639
|
+
fmt_ctx: number,
|
|
640
|
+
stream: number,
|
|
641
|
+
tsLo: number,
|
|
642
|
+
tsHi: number,
|
|
643
|
+
flags: number,
|
|
644
|
+
): Promise<number>;
|
|
645
|
+
avcodec_flush_buffers?(c: number): Promise<void>;
|
|
646
|
+
avformat_close_input_js(ctx: number): Promise<void>;
|
|
647
|
+
/** Sync helper exposed by libav.js: split a JS number into (lo, hi) int64. */
|
|
648
|
+
f64toi64?(val: number): [number, number];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
interface BridgeModule {
|
|
652
|
+
laFrameToVideoFrame(
|
|
653
|
+
frame: LibavFrame,
|
|
654
|
+
opts?: { VideoFrame?: unknown; timeBase?: [number, number]; transfer?: boolean },
|
|
655
|
+
): VideoFrame;
|
|
656
|
+
laFrameToAudioData(
|
|
657
|
+
frame: LibavFrame,
|
|
658
|
+
opts?: { AudioData?: unknown; timeBase?: [number, number] },
|
|
659
|
+
): AudioData;
|
|
660
|
+
}
|