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
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy-container transcode pipeline.
|
|
3
|
+
*
|
|
4
|
+
* One-pass: libav demux → WebCodecs VideoDecoder (or libav software video
|
|
5
|
+
* decode for rv40/etc.) + libav software audio decode → VideoSample /
|
|
6
|
+
* AudioSample → mediabunny Output → MP4 / WebM / MKV Blob.
|
|
7
|
+
*
|
|
8
|
+
* Reached from src/convert/transcode.ts when the input container is one of
|
|
9
|
+
* {avi, asf, flv, rm/rmvb} (see isLibavTranscodeContainer). MP4/MKV/WebM/etc.
|
|
10
|
+
* sources still go through the mediabunny Conversion path in transcode.ts.
|
|
11
|
+
*
|
|
12
|
+
* Scope limits (see docs/dev/ROADMAP.md):
|
|
13
|
+
* - Single video + single audio track. Extra tracks are silently dropped.
|
|
14
|
+
* - 8-bit video only; 10-bit throws with a clear error.
|
|
15
|
+
* - No seek. Linear read-to-EOF.
|
|
16
|
+
* - `outputStream` (streaming output to a WritableStream) is not yet
|
|
17
|
+
* wired for this path — throws with a clear error if requested.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Lazy imports: libav-demux + the libav-loader chain it pulls in are heavy.
|
|
21
|
+
// To keep the `transcode` bundle scenario below its gzip budget, we only
|
|
22
|
+
// load them when the libav path is actually taken. See the top of
|
|
23
|
+
// transcodeViaLibav() for the dynamic imports.
|
|
24
|
+
import type { LibavPacket } from "../util/libav-demux.js";
|
|
25
|
+
import {
|
|
26
|
+
AvbridgeError,
|
|
27
|
+
ERR_TRANSCODE_ABORTED,
|
|
28
|
+
ERR_TRANSCODE_UNSUPPORTED_COMBO,
|
|
29
|
+
ERR_TRANSCODE_DECODE,
|
|
30
|
+
ERR_CODEC_NOT_SUPPORTED,
|
|
31
|
+
} from "../errors.js";
|
|
32
|
+
import type {
|
|
33
|
+
MediaContext,
|
|
34
|
+
TranscodeOptions,
|
|
35
|
+
ConvertResult,
|
|
36
|
+
OutputVideoCodec,
|
|
37
|
+
OutputAudioCodec,
|
|
38
|
+
TranscodeQuality,
|
|
39
|
+
} from "../types.js";
|
|
40
|
+
|
|
41
|
+
/** @internal */
|
|
42
|
+
export function isLibavTranscodeContainer(container: string): boolean {
|
|
43
|
+
return (
|
|
44
|
+
container === "avi" ||
|
|
45
|
+
container === "asf" ||
|
|
46
|
+
container === "flv" ||
|
|
47
|
+
container === "rm" // RealMedia (.rm / .rmvb) — rv40/cook via libav software decode
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function transcodeViaLibav(
|
|
52
|
+
ctx: MediaContext,
|
|
53
|
+
options: TranscodeOptions,
|
|
54
|
+
): Promise<ConvertResult> {
|
|
55
|
+
const outputFormat = options.outputFormat ?? "mp4";
|
|
56
|
+
// Output format gate: mp4, webm, and mkv are supported. The shared
|
|
57
|
+
// createOutputFormat helper in remux.ts handles the muxer side.
|
|
58
|
+
if (outputFormat !== "mp4" && outputFormat !== "webm" && outputFormat !== "mkv") {
|
|
59
|
+
throw new AvbridgeError(
|
|
60
|
+
ERR_TRANSCODE_UNSUPPORTED_COMBO,
|
|
61
|
+
`legacy-container transcode supports MP4, WebM, and MKV output (got "${outputFormat}").`,
|
|
62
|
+
`Use outputFormat: "mp4", "webm", or "mkv".`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
// Codec defaults depend on output format. `transcode()` already calls
|
|
66
|
+
// validateCodecCompatibility with the user-supplied codecs, but when
|
|
67
|
+
// we're reached directly or with just outputFormat set, pick sensible
|
|
68
|
+
// defaults per container.
|
|
69
|
+
const videoCodec: OutputVideoCodec =
|
|
70
|
+
options.videoCodec ?? (outputFormat === "webm" ? "vp9" : "h264");
|
|
71
|
+
const audioCodec: OutputAudioCodec =
|
|
72
|
+
options.audioCodec ?? (outputFormat === "webm" ? "opus" : "aac");
|
|
73
|
+
const quality: TranscodeQuality = options.quality ?? "medium";
|
|
74
|
+
options.signal?.throwIfAborted();
|
|
75
|
+
|
|
76
|
+
// Everything from here is lazily loaded so the `transcode` scenario
|
|
77
|
+
// doesn't pay for libav/mediabunny weight just because this file is
|
|
78
|
+
// on the import graph.
|
|
79
|
+
const [
|
|
80
|
+
mb,
|
|
81
|
+
{ openLibavDemux, sanitizePacketTimestamp, sanitizeFrameTimestamp, libavFrameToInterleavedFloat32 },
|
|
82
|
+
{ normalizeSource },
|
|
83
|
+
{ createOutputFormat, mimeForFormat, generateFilename },
|
|
84
|
+
] = await Promise.all([
|
|
85
|
+
import("mediabunny"),
|
|
86
|
+
import("../util/libav-demux.js"),
|
|
87
|
+
import("../util/source.js"),
|
|
88
|
+
import("./remux.js"),
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
// ── Open the demux session ──────────────────────────────────────────
|
|
92
|
+
const normalized = await normalizeSource(ctx.source);
|
|
93
|
+
const demux = await openLibavDemux({
|
|
94
|
+
source: normalized,
|
|
95
|
+
filename: ctx.name ?? "input.bin",
|
|
96
|
+
context: ctx,
|
|
97
|
+
// transport config is not yet threaded through ConvertOptions; add
|
|
98
|
+
// later if URL-source transcode with signed URLs becomes a need.
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
options.signal?.throwIfAborted();
|
|
103
|
+
|
|
104
|
+
if (!demux.videoStream && !demux.audioStream) {
|
|
105
|
+
throw new Error("transcode: source has no decodable tracks");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Set up mediabunny Output (BufferTarget — in-memory) ──────────
|
|
109
|
+
// outputStream is not yet supported for the libav path — the
|
|
110
|
+
// sample-source + encoder chain doesn't expose the same StreamTarget
|
|
111
|
+
// hook that mediabunny's Conversion uses internally. Flag loudly.
|
|
112
|
+
if (options.outputStream) {
|
|
113
|
+
throw new AvbridgeError(
|
|
114
|
+
ERR_TRANSCODE_UNSUPPORTED_COMBO,
|
|
115
|
+
"outputStream is not yet supported for the libav-backed transcode path.",
|
|
116
|
+
"Remove the outputStream option to receive the transcoded blob in memory. Streaming output for this path is on the roadmap.",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const bufferTarget = new mb.BufferTarget();
|
|
120
|
+
const output = new mb.Output({
|
|
121
|
+
format: createOutputFormat(mb, outputFormat),
|
|
122
|
+
target: bufferTarget,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── Bridge (libavjs-webcodecs-bridge) for packet → EncodedVideoChunk
|
|
126
|
+
const bridge = await loadBridge();
|
|
127
|
+
|
|
128
|
+
// ── Video decoder (WebCodecs) + sample source (mediabunny) ───────
|
|
129
|
+
// Video decode has two possible paths:
|
|
130
|
+
// - WebCodecs (hardware or browser's software decoder) — used when
|
|
131
|
+
// `VideoDecoder.isConfigSupported(config)` returns true. Fast.
|
|
132
|
+
// - libav software decode — used when WebCodecs can't handle the codec.
|
|
133
|
+
// Required for RealMedia (rv40/etc.), where the source codec isn't in
|
|
134
|
+
// any browser. The output is still a VideoFrame (via the bridge's
|
|
135
|
+
// laFrameToVideoFrame), so the downstream queue+drain is unchanged.
|
|
136
|
+
let videoDecoder: VideoDecoder | null = null;
|
|
137
|
+
let videoSoftDec: { c: number; pkt: number; frame: number } | null = null;
|
|
138
|
+
let videoSource: InstanceType<typeof mb.VideoSampleSource> | null = null;
|
|
139
|
+
let videoBsfCtx: number | null = null;
|
|
140
|
+
let videoBsfPkt: number | null = null;
|
|
141
|
+
let videoWidth = 0;
|
|
142
|
+
let videoHeight = 0;
|
|
143
|
+
let videoTimeBase: [number, number] | undefined;
|
|
144
|
+
|
|
145
|
+
// Explicit queue + drain for VideoDecoder.output → videoSource.add.
|
|
146
|
+
// VideoDecoder.output is fire-and-forget, videoSource.add is async
|
|
147
|
+
// and backpressures. Without a queue + single-flight drain, we'd
|
|
148
|
+
// either lose backpressure (memory blow-up) or let in-flight `add`
|
|
149
|
+
// calls interleave out of order. Phase 1 correctness depends on this.
|
|
150
|
+
const frameQueue: VideoFrame[] = [];
|
|
151
|
+
const MAX_QUEUE = 16;
|
|
152
|
+
let draining = false;
|
|
153
|
+
let drainError: Error | null = null;
|
|
154
|
+
// Promise of the currently-running drain (for explicit await at EOF).
|
|
155
|
+
let activeDrain: Promise<void> | null = null;
|
|
156
|
+
|
|
157
|
+
const drain = (): Promise<void> => {
|
|
158
|
+
if (draining) return activeDrain ?? Promise.resolve();
|
|
159
|
+
draining = true;
|
|
160
|
+
const run = (async () => {
|
|
161
|
+
try {
|
|
162
|
+
while (frameQueue.length > 0 && !drainError) {
|
|
163
|
+
const frame = frameQueue.shift()!;
|
|
164
|
+
try {
|
|
165
|
+
const sample = new mb.VideoSample(frame, {
|
|
166
|
+
timestamp: (frame.timestamp ?? 0) / 1_000_000, // µs → s
|
|
167
|
+
});
|
|
168
|
+
await videoSource!.add(sample);
|
|
169
|
+
} finally {
|
|
170
|
+
frame.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
drainError = err as Error;
|
|
175
|
+
// Release any queued frames so we don't leak VideoFrames.
|
|
176
|
+
while (frameQueue.length > 0) {
|
|
177
|
+
try { frameQueue.shift()!.close(); } catch { /* ignore */ }
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
draining = false;
|
|
181
|
+
activeDrain = null;
|
|
182
|
+
}
|
|
183
|
+
})();
|
|
184
|
+
activeDrain = run;
|
|
185
|
+
return run;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (demux.videoStream && !options.dropVideo) {
|
|
189
|
+
try {
|
|
190
|
+
// Phase 1: refuse 10-bit. Neither decode path produces pixel
|
|
191
|
+
// formats that mediabunny's encoders reliably consume.
|
|
192
|
+
const bitDepth = ctx.videoTracks[0]?.bitDepth ?? 8;
|
|
193
|
+
if (bitDepth > 8) {
|
|
194
|
+
throw new AvbridgeError(
|
|
195
|
+
ERR_TRANSCODE_UNSUPPORTED_COMBO,
|
|
196
|
+
`transcode: 10-bit video is not supported in this release (source bit depth: ${bitDepth}).`,
|
|
197
|
+
`Phase 1 transcode handles 8-bit video only. 10-bit support is on the roadmap.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (demux.videoStream.time_base_num && demux.videoStream.time_base_den) {
|
|
202
|
+
videoTimeBase = [demux.videoStream.time_base_num, demux.videoStream.time_base_den];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Try WebCodecs first. If the bridge can build a config AND the
|
|
206
|
+
// browser's VideoDecoder supports it, use hardware/native decode.
|
|
207
|
+
// Otherwise fall back to libav software decode (rv40, etc.).
|
|
208
|
+
let config: VideoDecoderConfig | null = null;
|
|
209
|
+
try {
|
|
210
|
+
config = await bridge.videoStreamToConfig(demux.libav, demux.videoStream);
|
|
211
|
+
} catch {
|
|
212
|
+
config = null;
|
|
213
|
+
}
|
|
214
|
+
const supported = config
|
|
215
|
+
? await VideoDecoder.isConfigSupported(config).catch(() => ({ supported: false }))
|
|
216
|
+
: { supported: false };
|
|
217
|
+
|
|
218
|
+
videoWidth = (config?.codedWidth ?? ctx.videoTracks[0]?.width) ?? 0;
|
|
219
|
+
videoHeight = (config?.codedHeight ?? ctx.videoTracks[0]?.height) ?? 0;
|
|
220
|
+
|
|
221
|
+
if (config && supported.supported) {
|
|
222
|
+
// ── WebCodecs path ──
|
|
223
|
+
videoDecoder = new VideoDecoder({
|
|
224
|
+
output: (frame) => {
|
|
225
|
+
if (frameQueue.length >= MAX_QUEUE) {
|
|
226
|
+
frame.close();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
frameQueue.push(frame);
|
|
230
|
+
void drain();
|
|
231
|
+
},
|
|
232
|
+
error: (err) => {
|
|
233
|
+
drainError = err as unknown as Error;
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
videoDecoder.configure(config);
|
|
237
|
+
} else {
|
|
238
|
+
// ── libav software decode path ──
|
|
239
|
+
// RealMedia (rv10/20/30/40) and any other codec WebCodecs doesn't
|
|
240
|
+
// support lands here. The libav variant picker already routes
|
|
241
|
+
// rm/rv* to the "avbridge" variant via codec-set inspection.
|
|
242
|
+
const libavSoft = demux.libav as unknown as LibavSoftVideo;
|
|
243
|
+
const [, c, pkt, frame] = await libavSoft.ff_init_decoder(
|
|
244
|
+
demux.videoStream.codec_id,
|
|
245
|
+
{ codecpar: demux.videoStream.codecpar },
|
|
246
|
+
);
|
|
247
|
+
videoSoftDec = { c, pkt, frame };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
videoSource = new mb.VideoSampleSource({
|
|
251
|
+
codec: avbridgeVideoToMediabunny(videoCodec),
|
|
252
|
+
bitrate: qualityToMediabunny(mb, quality, options.videoBitrate),
|
|
253
|
+
...(options.frameRate !== undefined ? { frameRate: options.frameRate } : {}),
|
|
254
|
+
...(options.hardwareAcceleration !== undefined
|
|
255
|
+
? { hardwareAcceleration: options.hardwareAcceleration }
|
|
256
|
+
: {}),
|
|
257
|
+
// Progress reporting: media-time-based via each encoded packet.
|
|
258
|
+
onEncodedPacket: options.onProgress
|
|
259
|
+
? (packet) => {
|
|
260
|
+
const t = packet.timestamp;
|
|
261
|
+
if (Number.isFinite(t) && ctx.duration && ctx.duration > 0) {
|
|
262
|
+
const pct = Math.min(100, (t / ctx.duration) * 100);
|
|
263
|
+
options.onProgress!({ percent: pct, bytesWritten: 0 });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
: undefined,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const videoMeta: { width?: number; height?: number; frameRate?: number } = {};
|
|
270
|
+
if (options.width !== undefined) videoMeta.width = options.width;
|
|
271
|
+
else if (videoWidth > 0) videoMeta.width = videoWidth;
|
|
272
|
+
if (options.height !== undefined) videoMeta.height = options.height;
|
|
273
|
+
else if (videoHeight > 0) videoMeta.height = videoHeight;
|
|
274
|
+
if (options.frameRate !== undefined) videoMeta.frameRate = options.frameRate;
|
|
275
|
+
output.addVideoTrack(videoSource, videoMeta);
|
|
276
|
+
|
|
277
|
+
// mpeg4 packed-bframes BSF — same as hybrid/fallback.
|
|
278
|
+
if (ctx.videoTracks[0]?.codec === "mpeg4") {
|
|
279
|
+
const runtime = demux.libav as unknown as LibavBsf;
|
|
280
|
+
try {
|
|
281
|
+
videoBsfCtx = await runtime.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
|
|
282
|
+
if (videoBsfCtx != null && videoBsfCtx >= 0) {
|
|
283
|
+
const parIn = await runtime.AVBSFContext_par_in(videoBsfCtx);
|
|
284
|
+
await runtime.avcodec_parameters_copy(parIn, demux.videoStream.codecpar);
|
|
285
|
+
await runtime.av_bsf_init(videoBsfCtx);
|
|
286
|
+
videoBsfPkt = await (demux.libav as unknown as { av_packet_alloc(): Promise<number> })
|
|
287
|
+
.av_packet_alloc();
|
|
288
|
+
} else {
|
|
289
|
+
videoBsfCtx = null;
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
videoBsfCtx = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (err instanceof AvbridgeError) throw err;
|
|
297
|
+
throw new AvbridgeError(
|
|
298
|
+
ERR_CODEC_NOT_SUPPORTED,
|
|
299
|
+
`transcode: video decoder init failed: ${(err as Error).message}`,
|
|
300
|
+
`The source's video codec may not be supported by this browser's WebCodecs implementation.`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Audio decoder (libav software) + sample source ───────────────
|
|
306
|
+
interface SoftDecoder { c: number; pkt: number; frame: number; }
|
|
307
|
+
let audioDec: SoftDecoder | null = null;
|
|
308
|
+
let audioSource: InstanceType<typeof mb.AudioSampleSource> | null = null;
|
|
309
|
+
let audioTimeBase: [number, number] | undefined;
|
|
310
|
+
|
|
311
|
+
const includeAudio = demux.audioStream && !options.dropAudio;
|
|
312
|
+
if (includeAudio) {
|
|
313
|
+
try {
|
|
314
|
+
const libav = demux.libav as unknown as LibavAudio;
|
|
315
|
+
const [, c, pkt, frame] = await libav.ff_init_decoder(
|
|
316
|
+
demux.audioStream!.codec_id,
|
|
317
|
+
{ codecpar: demux.audioStream!.codecpar },
|
|
318
|
+
);
|
|
319
|
+
audioDec = { c, pkt, frame };
|
|
320
|
+
if (demux.audioStream!.time_base_num && demux.audioStream!.time_base_den) {
|
|
321
|
+
audioTimeBase = [
|
|
322
|
+
demux.audioStream!.time_base_num,
|
|
323
|
+
demux.audioStream!.time_base_den,
|
|
324
|
+
];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
audioSource = new mb.AudioSampleSource({
|
|
328
|
+
codec: avbridgeAudioToMediabunny(audioCodec),
|
|
329
|
+
bitrate: qualityToMediabunny(mb, quality, options.audioBitrate),
|
|
330
|
+
});
|
|
331
|
+
output.addAudioTrack(audioSource);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
const codecName = ctx.audioTracks[0]?.codec ?? "unknown";
|
|
334
|
+
throw new AvbridgeError(
|
|
335
|
+
ERR_CODEC_NOT_SUPPORTED,
|
|
336
|
+
`transcode: no decoder available for audio codec "${codecName}" in this libav variant (${(err as Error).message}).`,
|
|
337
|
+
`The file may still play via createPlayer() (fallback strategy). Pass { dropAudio: true } to transcode video-only.`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
} else if (options.dropAudio) {
|
|
341
|
+
// Caller asked for video-only — don't add an audio track.
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!videoSource && !audioSource) {
|
|
345
|
+
throw new AvbridgeError(
|
|
346
|
+
ERR_TRANSCODE_UNSUPPORTED_COMBO,
|
|
347
|
+
"transcode: no video or audio track to encode (did you set both dropVideo and dropAudio?).",
|
|
348
|
+
"Remove dropVideo or dropAudio to include at least one track.",
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
await output.start();
|
|
353
|
+
|
|
354
|
+
// ── Synthetic timestamp counters for packets without valid PTS ──
|
|
355
|
+
const videoFps = ctx.videoTracks[0]?.fps && ctx.videoTracks[0]!.fps > 0
|
|
356
|
+
? ctx.videoTracks[0]!.fps
|
|
357
|
+
: 30;
|
|
358
|
+
const videoFrameStepUs = Math.max(1, Math.round(1_000_000 / videoFps));
|
|
359
|
+
let syntheticVideoUs = 0;
|
|
360
|
+
let syntheticAudioUs = 0;
|
|
361
|
+
|
|
362
|
+
// BSF helpers (only if bsfCtx initialized)
|
|
363
|
+
const libavFull = demux.libav as unknown as LibavBsf & { ff_copyin_packet(a: number, b: LibavPacket): Promise<void>; ff_copyout_packet(a: number): Promise<LibavPacket>; };
|
|
364
|
+
async function applyBSF(packets: LibavPacket[]): Promise<LibavPacket[]> {
|
|
365
|
+
if (!videoBsfCtx || !videoBsfPkt) return packets;
|
|
366
|
+
const out: LibavPacket[] = [];
|
|
367
|
+
for (const pkt of packets) {
|
|
368
|
+
await libavFull.ff_copyin_packet(videoBsfPkt, pkt);
|
|
369
|
+
const sendErr = await libavFull.av_bsf_send_packet(videoBsfCtx, videoBsfPkt);
|
|
370
|
+
if (sendErr < 0) { out.push(pkt); continue; }
|
|
371
|
+
while (true) {
|
|
372
|
+
const recvErr = await libavFull.av_bsf_receive_packet(videoBsfCtx, videoBsfPkt);
|
|
373
|
+
if (recvErr < 0) break;
|
|
374
|
+
out.push(await libavFull.ff_copyout_packet(videoBsfPkt));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Guarded access: signal cancellation is honored between batches
|
|
381
|
+
// and at queue-wait checkpoints.
|
|
382
|
+
const ac = options.signal;
|
|
383
|
+
function throwIfAborted(): void {
|
|
384
|
+
if (ac?.aborted) {
|
|
385
|
+
throw new AvbridgeError(
|
|
386
|
+
ERR_TRANSCODE_ABORTED,
|
|
387
|
+
"transcode: aborted by caller.",
|
|
388
|
+
undefined,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function throwIfDrainError(): void {
|
|
394
|
+
if (drainError) {
|
|
395
|
+
const msg = drainError.message;
|
|
396
|
+
throw new AvbridgeError(
|
|
397
|
+
ERR_TRANSCODE_DECODE,
|
|
398
|
+
`transcode: video decoder error: ${msg}`,
|
|
399
|
+
"This usually indicates the WebCodecs decoder rejected a malformed packet.",
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Pump ────────────────────────────────────────────────────────
|
|
405
|
+
const onVideoPacketsWebCodecs = videoDecoder
|
|
406
|
+
? async (pkts: LibavPacket[]) => {
|
|
407
|
+
throwIfAborted();
|
|
408
|
+
throwIfDrainError();
|
|
409
|
+
while (
|
|
410
|
+
!ac?.aborted &&
|
|
411
|
+
(videoDecoder!.decodeQueueSize > 16 || frameQueue.length >= MAX_QUEUE - 2)
|
|
412
|
+
) {
|
|
413
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
414
|
+
}
|
|
415
|
+
throwIfAborted();
|
|
416
|
+
|
|
417
|
+
const processed = await applyBSF(pkts);
|
|
418
|
+
const bridgeAny = bridge as unknown as {
|
|
419
|
+
packetToEncodedVideoChunk(pkt: unknown, stream: unknown): EncodedVideoChunk;
|
|
420
|
+
};
|
|
421
|
+
for (const pkt of processed) {
|
|
422
|
+
sanitizePacketTimestamp(pkt, () => {
|
|
423
|
+
const ts = syntheticVideoUs;
|
|
424
|
+
syntheticVideoUs += videoFrameStepUs;
|
|
425
|
+
return ts;
|
|
426
|
+
}, videoTimeBase);
|
|
427
|
+
try {
|
|
428
|
+
const chunk = bridgeAny.packetToEncodedVideoChunk(pkt, demux.videoStream);
|
|
429
|
+
videoDecoder!.decode(chunk);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
throw new AvbridgeError(
|
|
432
|
+
ERR_TRANSCODE_DECODE,
|
|
433
|
+
`transcode: packet → EncodedVideoChunk failed: ${(err as Error).message}`,
|
|
434
|
+
undefined,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
: undefined;
|
|
440
|
+
|
|
441
|
+
const onVideoPacketsSoftware = videoSoftDec
|
|
442
|
+
? async (pkts: LibavPacket[]) => {
|
|
443
|
+
throwIfAborted();
|
|
444
|
+
throwIfDrainError();
|
|
445
|
+
// Only frameQueue backpressure — no WebCodecs queue.
|
|
446
|
+
while (!ac?.aborted && frameQueue.length >= MAX_QUEUE - 2) {
|
|
447
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
448
|
+
}
|
|
449
|
+
throwIfAborted();
|
|
450
|
+
|
|
451
|
+
const libavSoft = demux.libav as unknown as LibavSoftVideo;
|
|
452
|
+
let frames;
|
|
453
|
+
try {
|
|
454
|
+
frames = await libavSoft.ff_decode_multi(
|
|
455
|
+
videoSoftDec!.c, videoSoftDec!.pkt, videoSoftDec!.frame, pkts,
|
|
456
|
+
{ ignoreErrors: true },
|
|
457
|
+
);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
throw new AvbridgeError(
|
|
460
|
+
ERR_TRANSCODE_DECODE,
|
|
461
|
+
`transcode: software video decode failed: ${(err as Error).message}`,
|
|
462
|
+
undefined,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
for (const f of frames) {
|
|
466
|
+
sanitizeFrameTimestamp(f, () => {
|
|
467
|
+
const ts = syntheticVideoUs;
|
|
468
|
+
syntheticVideoUs += videoFrameStepUs;
|
|
469
|
+
return ts;
|
|
470
|
+
}, videoTimeBase);
|
|
471
|
+
try {
|
|
472
|
+
// Bridge consumes any libav-decoded frame (software or
|
|
473
|
+
// hardware) and returns a WebCodecs VideoFrame.
|
|
474
|
+
const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1_000_000] });
|
|
475
|
+
if (frameQueue.length >= MAX_QUEUE) {
|
|
476
|
+
vf.close();
|
|
477
|
+
} else {
|
|
478
|
+
frameQueue.push(vf);
|
|
479
|
+
void drain();
|
|
480
|
+
}
|
|
481
|
+
} catch (err) {
|
|
482
|
+
throw new AvbridgeError(
|
|
483
|
+
ERR_TRANSCODE_DECODE,
|
|
484
|
+
`transcode: laFrameToVideoFrame failed: ${(err as Error).message}`,
|
|
485
|
+
undefined,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
: undefined;
|
|
491
|
+
|
|
492
|
+
await demux.pump({
|
|
493
|
+
signal: ac,
|
|
494
|
+
onVideoPackets: onVideoPacketsWebCodecs ?? onVideoPacketsSoftware,
|
|
495
|
+
onAudioPackets: audioDec
|
|
496
|
+
? async (pkts) => {
|
|
497
|
+
throwIfAborted();
|
|
498
|
+
await decodeAudioBatch(pkts, false);
|
|
499
|
+
}
|
|
500
|
+
: undefined,
|
|
501
|
+
onEof: async () => {
|
|
502
|
+
// Drain video: flush decoder, then wait for our queue to empty.
|
|
503
|
+
if (videoDecoder && videoDecoder.state === "configured") {
|
|
504
|
+
try { await videoDecoder.flush(); } catch { /* ignore */ }
|
|
505
|
+
}
|
|
506
|
+
if (videoSoftDec) {
|
|
507
|
+
// Flush the software decoder with fin: true so any internally
|
|
508
|
+
// buffered frames come out.
|
|
509
|
+
const libavSoft = demux.libav as unknown as LibavSoftVideo;
|
|
510
|
+
try {
|
|
511
|
+
const tail = await libavSoft.ff_decode_multi(
|
|
512
|
+
videoSoftDec.c, videoSoftDec.pkt, videoSoftDec.frame, [],
|
|
513
|
+
{ fin: true, ignoreErrors: true },
|
|
514
|
+
);
|
|
515
|
+
for (const f of tail) {
|
|
516
|
+
sanitizeFrameTimestamp(f, () => {
|
|
517
|
+
const ts = syntheticVideoUs;
|
|
518
|
+
syntheticVideoUs += videoFrameStepUs;
|
|
519
|
+
return ts;
|
|
520
|
+
}, videoTimeBase);
|
|
521
|
+
try {
|
|
522
|
+
const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1_000_000] });
|
|
523
|
+
frameQueue.push(vf);
|
|
524
|
+
void drain();
|
|
525
|
+
} catch { /* ignore per-frame failures during flush */ }
|
|
526
|
+
}
|
|
527
|
+
} catch { /* ignore */ }
|
|
528
|
+
}
|
|
529
|
+
// A final drain() kick to consume any post-flush frames.
|
|
530
|
+
await drain();
|
|
531
|
+
// Drain audio: flush the libav decoder.
|
|
532
|
+
if (audioDec) {
|
|
533
|
+
await decodeAudioBatch([], true);
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
throwIfAborted();
|
|
539
|
+
throwIfDrainError();
|
|
540
|
+
|
|
541
|
+
// Close the sample sources so mediabunny finalizes track metadata.
|
|
542
|
+
videoSource?.close();
|
|
543
|
+
audioSource?.close();
|
|
544
|
+
|
|
545
|
+
await output.finalize();
|
|
546
|
+
|
|
547
|
+
if (!bufferTarget.buffer) {
|
|
548
|
+
throw new Error("transcode: mediabunny produced no output buffer");
|
|
549
|
+
}
|
|
550
|
+
const mimeType = mimeForFormat(outputFormat);
|
|
551
|
+
const blob = new Blob([bufferTarget.buffer], { type: mimeType });
|
|
552
|
+
options.onProgress?.({ percent: 100, bytesWritten: blob.size });
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
blob,
|
|
556
|
+
mimeType,
|
|
557
|
+
container: outputFormat,
|
|
558
|
+
videoCodec: videoSource ? videoCodec : undefined,
|
|
559
|
+
audioCodec: audioSource ? audioCodec : undefined,
|
|
560
|
+
duration: ctx.duration,
|
|
561
|
+
filename: generateFilename(ctx.name, outputFormat),
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
// ── Helpers closing over the above state ────────────────────────
|
|
565
|
+
async function decodeAudioBatch(pkts: LibavPacket[], flush: boolean): Promise<void> {
|
|
566
|
+
if (!audioDec || !audioSource) return;
|
|
567
|
+
const libav = demux.libav as unknown as LibavAudio;
|
|
568
|
+
let frames;
|
|
569
|
+
try {
|
|
570
|
+
frames = await libav.ff_decode_multi(
|
|
571
|
+
audioDec.c,
|
|
572
|
+
audioDec.pkt,
|
|
573
|
+
audioDec.frame,
|
|
574
|
+
pkts,
|
|
575
|
+
flush ? { fin: true, ignoreErrors: true } : { ignoreErrors: true },
|
|
576
|
+
);
|
|
577
|
+
} catch (err) {
|
|
578
|
+
throw new AvbridgeError(
|
|
579
|
+
ERR_TRANSCODE_DECODE,
|
|
580
|
+
`transcode: audio decode failed: ${(err as Error).message}`,
|
|
581
|
+
undefined,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
for (const f of frames) {
|
|
585
|
+
sanitizeFrameTimestamp(f, () => {
|
|
586
|
+
const ts = syntheticAudioUs;
|
|
587
|
+
const samples = f.nb_samples ?? 1024;
|
|
588
|
+
const sampleRate = f.sample_rate ?? 44100;
|
|
589
|
+
syntheticAudioUs += Math.round((samples * 1_000_000) / sampleRate);
|
|
590
|
+
return ts;
|
|
591
|
+
}, audioTimeBase);
|
|
592
|
+
const pcm = libavFrameToInterleavedFloat32(f);
|
|
593
|
+
if (!pcm) continue;
|
|
594
|
+
// AudioSample wants a typed AudioSampleInit. f32 interleaved.
|
|
595
|
+
const sample = new mb.AudioSample({
|
|
596
|
+
data: pcm.data,
|
|
597
|
+
format: "f32",
|
|
598
|
+
numberOfChannels: pcm.channels,
|
|
599
|
+
sampleRate: pcm.sampleRate,
|
|
600
|
+
timestamp: (f.pts ?? 0) / 1_000_000,
|
|
601
|
+
});
|
|
602
|
+
await audioSource.add(sample);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
} finally {
|
|
606
|
+
// Teardown: close decoders, free BSF, close demuxer.
|
|
607
|
+
try { await demux.destroy(); } catch { /* ignore */ }
|
|
608
|
+
// Note: videoDecoder / audioDec cleanup happens implicitly; the demuxer
|
|
609
|
+
// destroy releases the fmt_ctx, and our sample sources + Output
|
|
610
|
+
// finalization release the encoder side. Explicit frees here would
|
|
611
|
+
// race with in-flight decode calls on error paths; we accept the
|
|
612
|
+
// short-lived leak.
|
|
613
|
+
}
|
|
614
|
+
// Reference types used in this file. Declared locally to avoid leaking
|
|
615
|
+
// into the shared helper module.
|
|
616
|
+
interface LibavAudio {
|
|
617
|
+
ff_init_decoder(
|
|
618
|
+
codec: number | string,
|
|
619
|
+
config?: { codecpar?: number; time_base?: [number, number] },
|
|
620
|
+
): Promise<[number, number, number, number]>;
|
|
621
|
+
ff_decode_multi(
|
|
622
|
+
c: number,
|
|
623
|
+
pkt: number,
|
|
624
|
+
frame: number,
|
|
625
|
+
packets: LibavPacket[],
|
|
626
|
+
opts?: { fin?: boolean; ignoreErrors?: boolean },
|
|
627
|
+
): Promise<import("../util/libav-demux.js").LibavFrame[]>;
|
|
628
|
+
}
|
|
629
|
+
// Software video decode uses the same surface as audio. Aliased for
|
|
630
|
+
// readability at callsites.
|
|
631
|
+
type LibavSoftVideo = LibavAudio;
|
|
632
|
+
interface LibavBsf {
|
|
633
|
+
av_bsf_list_parse_str_js(str: string): Promise<number>;
|
|
634
|
+
AVBSFContext_par_in(ctx: number): Promise<number>;
|
|
635
|
+
avcodec_parameters_copy(dst: number, src: number): Promise<number>;
|
|
636
|
+
av_bsf_init(ctx: number): Promise<number>;
|
|
637
|
+
av_bsf_send_packet(ctx: number, pkt: number): Promise<number>;
|
|
638
|
+
av_bsf_receive_packet(ctx: number, pkt: number): Promise<number>;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
643
|
+
|
|
644
|
+
async function loadBridge(): Promise<BridgeModule> {
|
|
645
|
+
try {
|
|
646
|
+
const wrapper = await import("../strategies/fallback/libav-import.js");
|
|
647
|
+
return wrapper.libavBridge as unknown as BridgeModule;
|
|
648
|
+
} catch (err) {
|
|
649
|
+
throw new Error(`failed to load libavjs-webcodecs-bridge: ${(err as Error).message}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
interface BridgeModule {
|
|
654
|
+
videoStreamToConfig(libav: unknown, stream: unknown): Promise<VideoDecoderConfig | null>;
|
|
655
|
+
packetToEncodedVideoChunk(pkt: unknown, stream: unknown): EncodedVideoChunk;
|
|
656
|
+
laFrameToVideoFrame(
|
|
657
|
+
frame: unknown,
|
|
658
|
+
opts?: { timeBase?: [number, number]; transfer?: boolean },
|
|
659
|
+
): VideoFrame;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function avbridgeVideoToMediabunny(c: OutputVideoCodec): "avc" | "hevc" | "vp9" | "av1" {
|
|
663
|
+
switch (c) {
|
|
664
|
+
case "h264": return "avc";
|
|
665
|
+
case "h265": return "hevc";
|
|
666
|
+
case "vp9": return "vp9";
|
|
667
|
+
case "av1": return "av1";
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function avbridgeAudioToMediabunny(c: OutputAudioCodec): "aac" | "opus" | "flac" {
|
|
672
|
+
switch (c) {
|
|
673
|
+
case "aac": return "aac";
|
|
674
|
+
case "opus": return "opus";
|
|
675
|
+
case "flac": return "flac";
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function qualityToMediabunny(
|
|
680
|
+
mb: typeof import("mediabunny"),
|
|
681
|
+
quality: TranscodeQuality,
|
|
682
|
+
override: number | undefined,
|
|
683
|
+
): number | InstanceType<typeof mb.Quality> {
|
|
684
|
+
if (override !== undefined) return override;
|
|
685
|
+
switch (quality) {
|
|
686
|
+
case "low": return mb.QUALITY_LOW;
|
|
687
|
+
case "medium": return mb.QUALITY_MEDIUM;
|
|
688
|
+
case "high": return mb.QUALITY_HIGH;
|
|
689
|
+
case "very-high": return mb.QUALITY_VERY_HIGH;
|
|
690
|
+
}
|
|
691
|
+
}
|