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,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone remux function: repackage media into a modern container without
|
|
3
|
+
* re-encoding. Input can be any format avbridge can probe; output is a
|
|
4
|
+
* finalized downloadable file (MP4, WebM, or MKV).
|
|
5
|
+
*
|
|
6
|
+
* Two internal paths:
|
|
7
|
+
* - **Path A** (mediabunny-readable containers): wraps mediabunny's Conversion
|
|
8
|
+
* class for MP4/MKV/WebM/OGG/MOV/WAV/MP3/FLAC/ADTS sources.
|
|
9
|
+
* - **Path B** (AVI/ASF/FLV): libav.js demux + mediabunny mux via manual
|
|
10
|
+
* packet pump. Lazy-loads libav.js — zero cost if unused.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { probe } from "../probe/index.js";
|
|
14
|
+
import {
|
|
15
|
+
avbridgeVideoToMediabunny,
|
|
16
|
+
avbridgeAudioToMediabunny,
|
|
17
|
+
buildMediabunnySourceFromInput,
|
|
18
|
+
} from "../probe/mediabunny.js";
|
|
19
|
+
import { normalizeSource } from "../util/source.js";
|
|
20
|
+
import { prepareLibavInput, type LibavInputHandle } from "../util/libav-http-reader.js";
|
|
21
|
+
import type {
|
|
22
|
+
MediaInput,
|
|
23
|
+
MediaContext,
|
|
24
|
+
ConvertOptions,
|
|
25
|
+
ConvertResult,
|
|
26
|
+
OutputFormat,
|
|
27
|
+
} from "../types.js";
|
|
28
|
+
|
|
29
|
+
/** Containers mediabunny can read (and therefore use Conversion for). */
|
|
30
|
+
const MEDIABUNNY_CONTAINERS = new Set([
|
|
31
|
+
"mp4", "mov", "mkv", "webm", "ogg", "wav", "mp3", "flac", "adts",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Remux a media source into a modern container format without re-encoding.
|
|
36
|
+
*
|
|
37
|
+
* @throws When the source codecs cannot be remuxed (e.g. WMV3 — use `transcode()` instead).
|
|
38
|
+
* @throws When an AVI/ASF/FLV source is provided but libav.js is not installed.
|
|
39
|
+
*/
|
|
40
|
+
export async function remux(
|
|
41
|
+
source: MediaInput,
|
|
42
|
+
options: ConvertOptions = {},
|
|
43
|
+
): Promise<ConvertResult> {
|
|
44
|
+
const outputFormat = options.outputFormat ?? "mp4";
|
|
45
|
+
options.signal?.throwIfAborted();
|
|
46
|
+
|
|
47
|
+
// Probe the source
|
|
48
|
+
const ctx = await probe(source);
|
|
49
|
+
options.signal?.throwIfAborted();
|
|
50
|
+
|
|
51
|
+
// Validate remux eligibility: all codecs must map to mediabunny output codecs
|
|
52
|
+
validateRemuxEligibility(ctx, options.strict ?? false);
|
|
53
|
+
|
|
54
|
+
// Route to the appropriate path
|
|
55
|
+
if (MEDIABUNNY_CONTAINERS.has(ctx.container)) {
|
|
56
|
+
return remuxViaMediAbunny(ctx, outputFormat, options);
|
|
57
|
+
}
|
|
58
|
+
return remuxViaLibav(ctx, outputFormat, options);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Eligibility validation ──────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** @internal Exported for testing. */
|
|
64
|
+
export function validateRemuxEligibility(ctx: MediaContext, strict: boolean): void {
|
|
65
|
+
const video = ctx.videoTracks[0];
|
|
66
|
+
const audio = ctx.audioTracks[0];
|
|
67
|
+
|
|
68
|
+
if (video) {
|
|
69
|
+
const mbCodec = avbridgeVideoToMediabunny(video.codec);
|
|
70
|
+
if (!mbCodec) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Cannot remux: video codec "${video.codec}" is not supported for remuxing. ` +
|
|
73
|
+
`Use transcode() to re-encode to a modern codec.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (audio) {
|
|
79
|
+
const mbCodec = avbridgeAudioToMediabunny(audio.codec);
|
|
80
|
+
if (!mbCodec) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Cannot remux: audio codec "${audio.codec}" is not supported for remuxing. ` +
|
|
83
|
+
`Use transcode() to re-encode to a modern codec.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (strict && video?.codec === "h264" && audio?.codec === "mp3") {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Cannot remux in strict mode: H.264 + MP3 is a best-effort combination ` +
|
|
91
|
+
`that may produce playback issues in some browsers. ` +
|
|
92
|
+
`Set strict: false to allow, or use transcode() to re-encode audio to AAC.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!video && !audio) {
|
|
97
|
+
throw new Error("Cannot remux: source has no video or audio tracks.");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Path A: mediabunny Conversion ───────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
async function remuxViaMediAbunny(
|
|
104
|
+
ctx: MediaContext,
|
|
105
|
+
outputFormat: OutputFormat,
|
|
106
|
+
options: ConvertOptions,
|
|
107
|
+
): Promise<ConvertResult> {
|
|
108
|
+
const mb = await import("mediabunny");
|
|
109
|
+
|
|
110
|
+
const input = new mb.Input({
|
|
111
|
+
source: await buildMediabunnySourceFromInput(mb, ctx.source),
|
|
112
|
+
formats: mb.ALL_FORMATS,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const target = new mb.BufferTarget();
|
|
116
|
+
const output = new mb.Output({
|
|
117
|
+
format: createOutputFormat(mb, outputFormat),
|
|
118
|
+
target,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const conversion = await mb.Conversion.init({
|
|
122
|
+
input,
|
|
123
|
+
output,
|
|
124
|
+
showWarnings: false,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!conversion.isValid) {
|
|
128
|
+
const reasons = conversion.discardedTracks
|
|
129
|
+
.map((d) => `${d.track.type} track discarded: ${d.reason}`)
|
|
130
|
+
.join("; ");
|
|
131
|
+
throw new Error(`Cannot remux: mediabunny rejected the conversion. ${reasons}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Wire progress
|
|
135
|
+
if (options.onProgress) {
|
|
136
|
+
const onProgress = options.onProgress;
|
|
137
|
+
conversion.onProgress = (p) => {
|
|
138
|
+
onProgress({ percent: p * 100, bytesWritten: 0 });
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Wire cancellation
|
|
143
|
+
let abortHandler: (() => void) | undefined;
|
|
144
|
+
if (options.signal) {
|
|
145
|
+
options.signal.throwIfAborted();
|
|
146
|
+
abortHandler = () => void conversion.cancel();
|
|
147
|
+
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await conversion.execute();
|
|
152
|
+
} finally {
|
|
153
|
+
if (abortHandler && options.signal) {
|
|
154
|
+
options.signal.removeEventListener("abort", abortHandler);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!target.buffer) {
|
|
159
|
+
throw new Error("Remux failed: mediabunny produced no output buffer.");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const mimeType = mimeForFormat(outputFormat);
|
|
163
|
+
const blob = new Blob([target.buffer], { type: mimeType });
|
|
164
|
+
const filename = generateFilename(ctx.name, outputFormat);
|
|
165
|
+
|
|
166
|
+
options.onProgress?.({ percent: 100, bytesWritten: blob.size });
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
blob,
|
|
170
|
+
mimeType,
|
|
171
|
+
container: outputFormat,
|
|
172
|
+
videoCodec: ctx.videoTracks[0]?.codec,
|
|
173
|
+
audioCodec: ctx.audioTracks[0]?.codec,
|
|
174
|
+
duration: ctx.duration,
|
|
175
|
+
filename,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Path B: libav.js demux + mediabunny mux (AVI/ASF/FLV) ──────────────────
|
|
180
|
+
|
|
181
|
+
async function remuxViaLibav(
|
|
182
|
+
ctx: MediaContext,
|
|
183
|
+
outputFormat: OutputFormat,
|
|
184
|
+
options: ConvertOptions,
|
|
185
|
+
): Promise<ConvertResult> {
|
|
186
|
+
// Lazy-load libav
|
|
187
|
+
let loadLibav: typeof import("../strategies/fallback/libav-loader.js").loadLibav;
|
|
188
|
+
let pickLibavVariant: typeof import("../strategies/fallback/variant-routing.js").pickLibavVariant;
|
|
189
|
+
try {
|
|
190
|
+
const loader = await import("../strategies/fallback/libav-loader.js");
|
|
191
|
+
const routing = await import("../strategies/fallback/variant-routing.js");
|
|
192
|
+
loadLibav = loader.loadLibav;
|
|
193
|
+
pickLibavVariant = routing.pickLibavVariant;
|
|
194
|
+
} catch {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Cannot remux ${ctx.container.toUpperCase()} source: libav.js is not available. ` +
|
|
197
|
+
`Install @libav.js/variant-webcodecs and libavjs-webcodecs-bridge, ` +
|
|
198
|
+
`or build the custom avbridge variant with scripts/build-libav.sh.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const variant = pickLibavVariant(ctx);
|
|
203
|
+
const libav = await loadLibav(variant) as unknown as LibavRuntime;
|
|
204
|
+
|
|
205
|
+
// For Blob/File inputs, libav reads from an in-memory readahead file.
|
|
206
|
+
// For URL inputs, libav demuxes via HTTP Range requests through the
|
|
207
|
+
// block reader — no full download.
|
|
208
|
+
const normalized = await normalizeSource(ctx.source);
|
|
209
|
+
const filename = ctx.name ?? `remux-input-${Date.now()}`;
|
|
210
|
+
const handle: LibavInputHandle = await prepareLibavInput(libav as unknown as Parameters<typeof prepareLibavInput>[0], filename, normalized);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
return await doLibavRemux(libav, filename, ctx, outputFormat, options);
|
|
214
|
+
} finally {
|
|
215
|
+
await handle.detach().catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function doLibavRemux(
|
|
220
|
+
libav: LibavRuntime,
|
|
221
|
+
filename: string,
|
|
222
|
+
ctx: MediaContext,
|
|
223
|
+
outputFormat: OutputFormat,
|
|
224
|
+
options: ConvertOptions,
|
|
225
|
+
): Promise<ConvertResult> {
|
|
226
|
+
const mb = await import("mediabunny");
|
|
227
|
+
|
|
228
|
+
const readPkt = await libav.av_packet_alloc();
|
|
229
|
+
const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(filename);
|
|
230
|
+
const videoStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_VIDEO) ?? null;
|
|
231
|
+
const audioStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_AUDIO) ?? null;
|
|
232
|
+
|
|
233
|
+
// Map codecs to mediabunny output
|
|
234
|
+
const videoTrackInfo = ctx.videoTracks[0];
|
|
235
|
+
const audioTrackInfo = ctx.audioTracks[0];
|
|
236
|
+
const mbVideoCodec = videoTrackInfo ? avbridgeVideoToMediabunny(videoTrackInfo.codec) : null;
|
|
237
|
+
const mbAudioCodec = audioTrackInfo ? avbridgeAudioToMediabunny(audioTrackInfo.codec) : null;
|
|
238
|
+
|
|
239
|
+
// Set up mediabunny output with BufferTarget
|
|
240
|
+
const target = new mb.BufferTarget();
|
|
241
|
+
const output = new mb.Output({
|
|
242
|
+
format: createOutputFormat(mb, outputFormat),
|
|
243
|
+
target,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
let videoSource: InstanceType<typeof mb.EncodedVideoPacketSource> | null = null;
|
|
247
|
+
let audioSource: InstanceType<typeof mb.EncodedAudioPacketSource> | null = null;
|
|
248
|
+
|
|
249
|
+
if (mbVideoCodec && videoStream) {
|
|
250
|
+
videoSource = new mb.EncodedVideoPacketSource(mbVideoCodec);
|
|
251
|
+
output.addVideoTrack(videoSource);
|
|
252
|
+
}
|
|
253
|
+
if (mbAudioCodec && audioStream) {
|
|
254
|
+
type AudioCodecArg = ConstructorParameters<typeof mb.EncodedAudioPacketSource>[0];
|
|
255
|
+
audioSource = new mb.EncodedAudioPacketSource(mbAudioCodec as AudioCodecArg);
|
|
256
|
+
output.addAudioTrack(audioSource);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await output.start();
|
|
260
|
+
|
|
261
|
+
// Timestamp tracking for synthetic timestamps
|
|
262
|
+
const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
|
|
263
|
+
const videoFrameStepUs = Math.max(1, Math.round(1_000_000 / videoFps));
|
|
264
|
+
let syntheticVideoUs = 0;
|
|
265
|
+
let syntheticAudioUs = 0;
|
|
266
|
+
|
|
267
|
+
const videoTimeBase: [number, number] | undefined =
|
|
268
|
+
videoStream?.time_base_num && videoStream?.time_base_den
|
|
269
|
+
? [videoStream.time_base_num, videoStream.time_base_den]
|
|
270
|
+
: undefined;
|
|
271
|
+
const audioTimeBase: [number, number] | undefined =
|
|
272
|
+
audioStream?.time_base_num && audioStream?.time_base_den
|
|
273
|
+
? [audioStream.time_base_num, audioStream.time_base_den]
|
|
274
|
+
: undefined;
|
|
275
|
+
|
|
276
|
+
let totalPackets = 0;
|
|
277
|
+
const durationUs = ctx.duration ? ctx.duration * 1_000_000 : 0;
|
|
278
|
+
let firstVideoMeta = true;
|
|
279
|
+
let firstAudioMeta = true;
|
|
280
|
+
|
|
281
|
+
// Pump loop: read packets from libav, feed to mediabunny output
|
|
282
|
+
while (true) {
|
|
283
|
+
options.signal?.throwIfAborted();
|
|
284
|
+
|
|
285
|
+
let readErr: number;
|
|
286
|
+
let packets: Record<number, LibavPacket[]>;
|
|
287
|
+
try {
|
|
288
|
+
[readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
|
|
289
|
+
limit: 64 * 1024,
|
|
290
|
+
});
|
|
291
|
+
} catch (err) {
|
|
292
|
+
throw new Error(`libav demux failed: ${(err as Error).message}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const videoPackets = videoStream ? packets[videoStream.index] ?? [] : [];
|
|
296
|
+
const audioPackets = audioStream ? packets[audioStream.index] ?? [] : [];
|
|
297
|
+
|
|
298
|
+
// Feed video packets
|
|
299
|
+
if (videoSource) {
|
|
300
|
+
for (const pkt of videoPackets) {
|
|
301
|
+
sanitizePacketTimestamp(pkt, () => {
|
|
302
|
+
const ts = syntheticVideoUs;
|
|
303
|
+
syntheticVideoUs += videoFrameStepUs;
|
|
304
|
+
return ts;
|
|
305
|
+
}, videoTimeBase);
|
|
306
|
+
|
|
307
|
+
const mbPacket = libavPacketToMediAbunny(mb, pkt);
|
|
308
|
+
await videoSource.add(
|
|
309
|
+
mbPacket,
|
|
310
|
+
firstVideoMeta ? { decoderConfig: buildVideoDecoderConfig(videoTrackInfo!) } : undefined,
|
|
311
|
+
);
|
|
312
|
+
firstVideoMeta = false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Feed audio packets
|
|
317
|
+
if (audioSource) {
|
|
318
|
+
for (const pkt of audioPackets) {
|
|
319
|
+
sanitizePacketTimestamp(pkt, () => {
|
|
320
|
+
const ts = syntheticAudioUs;
|
|
321
|
+
const sampleRate = audioTrackInfo?.sampleRate ?? 44100;
|
|
322
|
+
syntheticAudioUs += Math.round(1024 * 1_000_000 / sampleRate);
|
|
323
|
+
return ts;
|
|
324
|
+
}, audioTimeBase);
|
|
325
|
+
|
|
326
|
+
const mbPacket = libavPacketToMediAbunny(mb, pkt);
|
|
327
|
+
await audioSource.add(
|
|
328
|
+
mbPacket,
|
|
329
|
+
firstAudioMeta ? { decoderConfig: buildAudioDecoderConfig(audioTrackInfo!) } : undefined,
|
|
330
|
+
);
|
|
331
|
+
firstAudioMeta = false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
totalPackets += videoPackets.length + audioPackets.length;
|
|
336
|
+
|
|
337
|
+
// Report progress
|
|
338
|
+
if (options.onProgress && durationUs > 0) {
|
|
339
|
+
const lastVideoTs = videoPackets.length > 0 ? videoPackets[videoPackets.length - 1].pts ?? 0 : 0;
|
|
340
|
+
const lastAudioTs = audioPackets.length > 0 ? audioPackets[audioPackets.length - 1].pts ?? 0 : 0;
|
|
341
|
+
const currentUs = Math.max(lastVideoTs, lastAudioTs);
|
|
342
|
+
const percent = Math.min(99, (currentUs / durationUs) * 100);
|
|
343
|
+
options.onProgress({ percent, bytesWritten: 0 });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (readErr === libav.AVERROR_EOF) break;
|
|
347
|
+
if (readErr && readErr !== 0 && readErr !== -libav.EAGAIN) {
|
|
348
|
+
console.warn("[avbridge] remux: ff_read_frame_multi returned", readErr);
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
await output.finalize();
|
|
354
|
+
|
|
355
|
+
// Cleanup libav resources
|
|
356
|
+
try { await libav.av_packet_free?.(readPkt); } catch { /* ignore */ }
|
|
357
|
+
try { await libav.avformat_close_input_js(fmt_ctx); } catch { /* ignore */ }
|
|
358
|
+
|
|
359
|
+
if (!target.buffer) {
|
|
360
|
+
throw new Error("Remux failed: mediabunny produced no output buffer.");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const mimeType = mimeForFormat(outputFormat);
|
|
364
|
+
const blob = new Blob([target.buffer], { type: mimeType });
|
|
365
|
+
const outputFilename = generateFilename(ctx.name, outputFormat);
|
|
366
|
+
|
|
367
|
+
options.onProgress?.({ percent: 100, bytesWritten: blob.size });
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
blob,
|
|
371
|
+
mimeType,
|
|
372
|
+
container: outputFormat,
|
|
373
|
+
videoCodec: videoTrackInfo?.codec,
|
|
374
|
+
audioCodec: audioTrackInfo?.codec,
|
|
375
|
+
duration: ctx.duration,
|
|
376
|
+
filename: outputFilename,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── Packet timestamp sanitizer (from hybrid/decoder.ts) ─────────────────────
|
|
381
|
+
|
|
382
|
+
function sanitizePacketTimestamp(
|
|
383
|
+
pkt: LibavPacket,
|
|
384
|
+
nextUs: () => number,
|
|
385
|
+
fallbackTimeBase?: [number, number],
|
|
386
|
+
): void {
|
|
387
|
+
const lo = pkt.pts ?? 0;
|
|
388
|
+
const hi = pkt.ptshi ?? 0;
|
|
389
|
+
const isInvalid = (hi === -2147483648 && lo === 0) || !Number.isFinite(lo);
|
|
390
|
+
if (isInvalid) {
|
|
391
|
+
const us = nextUs();
|
|
392
|
+
pkt.pts = us;
|
|
393
|
+
pkt.ptshi = 0;
|
|
394
|
+
pkt.time_base_num = 1;
|
|
395
|
+
pkt.time_base_den = 1_000_000;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const tb = fallbackTimeBase ?? [1, 1_000_000];
|
|
399
|
+
const pts64 = hi * 0x100000000 + lo;
|
|
400
|
+
const us = Math.round((pts64 * 1_000_000 * tb[0]) / tb[1]);
|
|
401
|
+
if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
|
|
402
|
+
pkt.pts = us;
|
|
403
|
+
pkt.ptshi = us < 0 ? -1 : 0;
|
|
404
|
+
pkt.time_base_num = 1;
|
|
405
|
+
pkt.time_base_den = 1_000_000;
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const fallback = nextUs();
|
|
409
|
+
pkt.pts = fallback;
|
|
410
|
+
pkt.ptshi = 0;
|
|
411
|
+
pkt.time_base_num = 1;
|
|
412
|
+
pkt.time_base_den = 1_000_000;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
/** @internal Exported for use by transcode(). */
|
|
418
|
+
export function createOutputFormat(
|
|
419
|
+
mb: typeof import("mediabunny"),
|
|
420
|
+
format: OutputFormat,
|
|
421
|
+
) {
|
|
422
|
+
switch (format) {
|
|
423
|
+
case "mp4": return new mb.Mp4OutputFormat({ fastStart: "in-memory" });
|
|
424
|
+
case "webm": return new mb.WebMOutputFormat();
|
|
425
|
+
case "mkv": return new mb.MkvOutputFormat();
|
|
426
|
+
default: return new mb.Mp4OutputFormat({ fastStart: "in-memory" });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** @internal Exported for testing. */
|
|
431
|
+
export function mimeForFormat(format: OutputFormat): string {
|
|
432
|
+
switch (format) {
|
|
433
|
+
case "mp4": return "video/mp4";
|
|
434
|
+
case "webm": return "video/webm";
|
|
435
|
+
case "mkv": return "video/x-matroska";
|
|
436
|
+
default: return "application/octet-stream";
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** @internal Exported for testing. */
|
|
441
|
+
export function generateFilename(originalName: string | undefined, format: OutputFormat): string {
|
|
442
|
+
const ext = format === "mkv" ? "mkv" : format;
|
|
443
|
+
if (!originalName) return `output.${ext}`;
|
|
444
|
+
const base = originalName.replace(/\.[^.]+$/, "");
|
|
445
|
+
return `${base}.${ext}`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Sequence counter for decode-order numbering in mediabunny packets. */
|
|
449
|
+
let _seqCounter = 0;
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Convert a libav packet to a mediabunny EncodedPacket.
|
|
453
|
+
* Timestamps from libav are in microseconds (after sanitization); mediabunny wants seconds.
|
|
454
|
+
*/
|
|
455
|
+
function libavPacketToMediAbunny(
|
|
456
|
+
mb: typeof import("mediabunny"),
|
|
457
|
+
pkt: LibavPacket,
|
|
458
|
+
): InstanceType<typeof mb.EncodedPacket> {
|
|
459
|
+
const KEY_FRAME_FLAG = 0x0001;
|
|
460
|
+
const timestampSec = (pkt.pts ?? 0) / 1_000_000;
|
|
461
|
+
const durationSec = (pkt.duration ?? 0) / 1_000_000;
|
|
462
|
+
const type = (pkt.flags & KEY_FRAME_FLAG) ? "key" as const : "delta" as const;
|
|
463
|
+
return new mb.EncodedPacket(pkt.data, type, timestampSec, durationSec, _seqCounter++);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function buildVideoDecoderConfig(track: { codec: string; width: number; height: number; codecString?: string }) {
|
|
467
|
+
return {
|
|
468
|
+
codec: track.codecString ?? track.codec,
|
|
469
|
+
codedWidth: track.width,
|
|
470
|
+
codedHeight: track.height,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function buildAudioDecoderConfig(track: { codec: string; channels: number; sampleRate: number; codecString?: string }) {
|
|
475
|
+
return {
|
|
476
|
+
codec: track.codecString ?? track.codec,
|
|
477
|
+
numberOfChannels: track.channels,
|
|
478
|
+
sampleRate: track.sampleRate,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── Structural types ────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
interface LibavPacket {
|
|
485
|
+
data: Uint8Array;
|
|
486
|
+
pts: number;
|
|
487
|
+
ptshi?: number;
|
|
488
|
+
duration?: number;
|
|
489
|
+
durationhi?: number;
|
|
490
|
+
flags: number;
|
|
491
|
+
stream_index: number;
|
|
492
|
+
time_base_num?: number;
|
|
493
|
+
time_base_den?: number;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
interface LibavStream {
|
|
497
|
+
index: number;
|
|
498
|
+
codec_type: number;
|
|
499
|
+
codec_id: number;
|
|
500
|
+
codecpar: number;
|
|
501
|
+
time_base_num?: number;
|
|
502
|
+
time_base_den?: number;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
interface LibavRuntime {
|
|
506
|
+
AVMEDIA_TYPE_VIDEO: number;
|
|
507
|
+
AVMEDIA_TYPE_AUDIO: number;
|
|
508
|
+
AVERROR_EOF: number;
|
|
509
|
+
EAGAIN: number;
|
|
510
|
+
|
|
511
|
+
mkreadaheadfile(name: string, blob: Blob): Promise<void>;
|
|
512
|
+
unlinkreadaheadfile(name: string): Promise<void>;
|
|
513
|
+
ff_init_demuxer_file(name: string): Promise<[number, LibavStream[]]>;
|
|
514
|
+
ff_read_frame_multi(
|
|
515
|
+
fmt_ctx: number,
|
|
516
|
+
pkt: number,
|
|
517
|
+
opts?: { limit?: number },
|
|
518
|
+
): Promise<[number, Record<number, LibavPacket[]>]>;
|
|
519
|
+
av_packet_alloc(): Promise<number>;
|
|
520
|
+
av_packet_free?(pkt: number): Promise<void>;
|
|
521
|
+
avformat_close_input_js(ctx: number): Promise<void>;
|
|
522
|
+
}
|