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.
Files changed (103) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/LICENSE +21 -0
  3. package/README.md +415 -0
  4. package/dist/avi-M5B4SHRM.cjs +164 -0
  5. package/dist/avi-M5B4SHRM.cjs.map +1 -0
  6. package/dist/avi-POCGZ4JX.js +162 -0
  7. package/dist/avi-POCGZ4JX.js.map +1 -0
  8. package/dist/chunk-5ISVAODK.js +80 -0
  9. package/dist/chunk-5ISVAODK.js.map +1 -0
  10. package/dist/chunk-F7YS2XOA.cjs +2966 -0
  11. package/dist/chunk-F7YS2XOA.cjs.map +1 -0
  12. package/dist/chunk-FKM7QBZU.js +2957 -0
  13. package/dist/chunk-FKM7QBZU.js.map +1 -0
  14. package/dist/chunk-J5MCMN3S.js +27 -0
  15. package/dist/chunk-J5MCMN3S.js.map +1 -0
  16. package/dist/chunk-L4NPOJ36.cjs +180 -0
  17. package/dist/chunk-L4NPOJ36.cjs.map +1 -0
  18. package/dist/chunk-NZU7W256.cjs +29 -0
  19. package/dist/chunk-NZU7W256.cjs.map +1 -0
  20. package/dist/chunk-PQTZS7OA.js +147 -0
  21. package/dist/chunk-PQTZS7OA.js.map +1 -0
  22. package/dist/chunk-WD2ZNQA7.js +177 -0
  23. package/dist/chunk-WD2ZNQA7.js.map +1 -0
  24. package/dist/chunk-Y5FYF5KG.cjs +153 -0
  25. package/dist/chunk-Y5FYF5KG.cjs.map +1 -0
  26. package/dist/chunk-Z2FJ5TJC.cjs +82 -0
  27. package/dist/chunk-Z2FJ5TJC.cjs.map +1 -0
  28. package/dist/element.cjs +433 -0
  29. package/dist/element.cjs.map +1 -0
  30. package/dist/element.d.cts +158 -0
  31. package/dist/element.d.ts +158 -0
  32. package/dist/element.js +431 -0
  33. package/dist/element.js.map +1 -0
  34. package/dist/index.cjs +576 -0
  35. package/dist/index.cjs.map +1 -0
  36. package/dist/index.d.cts +80 -0
  37. package/dist/index.d.ts +80 -0
  38. package/dist/index.js +554 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/libav-http-reader-FPYDBMYK.cjs +16 -0
  41. package/dist/libav-http-reader-FPYDBMYK.cjs.map +1 -0
  42. package/dist/libav-http-reader-NQJVY273.js +3 -0
  43. package/dist/libav-http-reader-NQJVY273.js.map +1 -0
  44. package/dist/libav-import-2JURFHEW.js +8 -0
  45. package/dist/libav-import-2JURFHEW.js.map +1 -0
  46. package/dist/libav-import-GST2AMPL.cjs +30 -0
  47. package/dist/libav-import-GST2AMPL.cjs.map +1 -0
  48. package/dist/libav-loader-KA2MAWLM.js +3 -0
  49. package/dist/libav-loader-KA2MAWLM.js.map +1 -0
  50. package/dist/libav-loader-ZHOERPHW.cjs +12 -0
  51. package/dist/libav-loader-ZHOERPHW.cjs.map +1 -0
  52. package/dist/player-BBwbCkdL.d.cts +365 -0
  53. package/dist/player-BBwbCkdL.d.ts +365 -0
  54. package/dist/source-SC6ZEQYR.cjs +28 -0
  55. package/dist/source-SC6ZEQYR.cjs.map +1 -0
  56. package/dist/source-ZFS4H7J3.js +3 -0
  57. package/dist/source-ZFS4H7J3.js.map +1 -0
  58. package/dist/variant-routing-GOHB2RZN.cjs +12 -0
  59. package/dist/variant-routing-GOHB2RZN.cjs.map +1 -0
  60. package/dist/variant-routing-JOBWXYKD.js +3 -0
  61. package/dist/variant-routing-JOBWXYKD.js.map +1 -0
  62. package/package.json +95 -0
  63. package/src/classify/index.ts +1 -0
  64. package/src/classify/rules.ts +214 -0
  65. package/src/convert/index.ts +2 -0
  66. package/src/convert/remux.ts +522 -0
  67. package/src/convert/transcode.ts +329 -0
  68. package/src/diagnostics.ts +99 -0
  69. package/src/element/avbridge-player.ts +576 -0
  70. package/src/element.ts +19 -0
  71. package/src/events.ts +71 -0
  72. package/src/index.ts +42 -0
  73. package/src/libav-stubs.d.ts +24 -0
  74. package/src/player.ts +455 -0
  75. package/src/plugins/builtin.ts +37 -0
  76. package/src/plugins/registry.ts +32 -0
  77. package/src/probe/avi.ts +242 -0
  78. package/src/probe/index.ts +59 -0
  79. package/src/probe/mediabunny.ts +194 -0
  80. package/src/strategies/fallback/audio-output.ts +293 -0
  81. package/src/strategies/fallback/clock.ts +7 -0
  82. package/src/strategies/fallback/decoder.ts +660 -0
  83. package/src/strategies/fallback/index.ts +170 -0
  84. package/src/strategies/fallback/libav-import.ts +27 -0
  85. package/src/strategies/fallback/libav-loader.ts +190 -0
  86. package/src/strategies/fallback/variant-routing.ts +43 -0
  87. package/src/strategies/fallback/video-renderer.ts +216 -0
  88. package/src/strategies/hybrid/decoder.ts +641 -0
  89. package/src/strategies/hybrid/index.ts +139 -0
  90. package/src/strategies/native.ts +107 -0
  91. package/src/strategies/remux/annexb.ts +112 -0
  92. package/src/strategies/remux/index.ts +79 -0
  93. package/src/strategies/remux/mse.ts +234 -0
  94. package/src/strategies/remux/pipeline.ts +254 -0
  95. package/src/subtitles/index.ts +91 -0
  96. package/src/subtitles/render.ts +62 -0
  97. package/src/subtitles/srt.ts +62 -0
  98. package/src/subtitles/vtt.ts +5 -0
  99. package/src/types-shim.d.ts +3 -0
  100. package/src/types.ts +360 -0
  101. package/src/util/codec-strings.ts +86 -0
  102. package/src/util/libav-http-reader.ts +315 -0
  103. package/src/util/source.ts +274 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Standalone transcode function: re-encode media into a modern container with
3
+ * modern codecs. Unlike {@link remux}, this is a lossy operation that decodes
4
+ * and re-encodes the streams.
5
+ *
6
+ * Built on top of mediabunny's `Conversion` class which handles the full
7
+ * decode → encode → mux pipeline. WebCodecs encoders are used when available;
8
+ * mediabunny falls back to other paths internally.
9
+ *
10
+ * Limitations in v1:
11
+ * - Input must be in a mediabunny-readable container (MP4, MKV, WebM, OGG, ...).
12
+ * AVI/ASF/FLV sources are not yet supported by transcode (use remux + native
13
+ * playback or wait for v1.1).
14
+ */
15
+
16
+ import { probe } from "../probe/index.js";
17
+ import { buildMediabunnySourceFromInput } from "../probe/mediabunny.js";
18
+ import { createOutputFormat, mimeForFormat, generateFilename } from "./remux.js";
19
+ import type {
20
+ MediaInput,
21
+ MediaContext,
22
+ TranscodeOptions,
23
+ ConvertResult,
24
+ OutputFormat,
25
+ OutputVideoCodec,
26
+ OutputAudioCodec,
27
+ TranscodeQuality,
28
+ } from "../types.js";
29
+
30
+ /** Containers mediabunny can demux. AVI/ASF/FLV are not in this set. */
31
+ const MEDIABUNNY_CONTAINERS = new Set([
32
+ "mp4", "mov", "mkv", "webm", "ogg", "wav", "mp3", "flac", "adts",
33
+ ]);
34
+
35
+ /**
36
+ * Transcode a media source into a modern container with modern codecs.
37
+ *
38
+ * Re-encodes both video and audio. Use {@link remux} instead when the source
39
+ * codecs are already modern and only the container needs changing — it's much
40
+ * faster and lossless.
41
+ */
42
+ export async function transcode(
43
+ source: MediaInput,
44
+ options: TranscodeOptions = {},
45
+ ): Promise<ConvertResult> {
46
+ const outputFormat: OutputFormat = options.outputFormat ?? "mp4";
47
+ const videoCodec = options.videoCodec ?? defaultVideoCodec(outputFormat);
48
+ const audioCodec = options.audioCodec ?? defaultAudioCodec(outputFormat);
49
+ const quality = options.quality ?? "medium";
50
+
51
+ validateCodecCompatibility(outputFormat, videoCodec, audioCodec);
52
+ options.signal?.throwIfAborted();
53
+
54
+ const ctx = await probe(source);
55
+ options.signal?.throwIfAborted();
56
+
57
+ if (!MEDIABUNNY_CONTAINERS.has(ctx.container)) {
58
+ throw new Error(
59
+ `Cannot transcode "${ctx.container}" sources in v1. ` +
60
+ `transcode() only supports inputs that mediabunny can read (MP4, MKV, WebM, OGG, MP3, FLAC, WAV, MOV). ` +
61
+ `For AVI/ASF/FLV sources, use the player's playback strategies instead.`,
62
+ );
63
+ }
64
+
65
+ return doTranscode(ctx, outputFormat, videoCodec, audioCodec, quality, options);
66
+ }
67
+
68
+ /**
69
+ * One attempt at the full mediabunny conversion. Each attempt allocates
70
+ * fresh `Input` / `Output` / `Conversion` instances because they are all
71
+ * single-use in mediabunny.
72
+ *
73
+ * Returns the muxed `ArrayBuffer` on success. Throws on failure.
74
+ */
75
+ async function attemptTranscode(
76
+ ctx: MediaContext,
77
+ outputFormat: OutputFormat,
78
+ videoCodec: OutputVideoCodec,
79
+ audioCodec: OutputAudioCodec,
80
+ quality: TranscodeQuality,
81
+ options: TranscodeOptions,
82
+ ): Promise<ArrayBuffer> {
83
+ const mb = await import("mediabunny");
84
+
85
+ const input = new mb.Input({
86
+ source: await buildMediabunnySourceFromInput(mb, ctx.source),
87
+ formats: mb.ALL_FORMATS,
88
+ });
89
+
90
+ const target = new mb.BufferTarget();
91
+ const output = new mb.Output({
92
+ format: createOutputFormat(mb, outputFormat),
93
+ target,
94
+ });
95
+
96
+ // Build mediabunny ConversionVideoOptions
97
+ const videoOptions = options.dropVideo
98
+ ? { discard: true as const }
99
+ : {
100
+ codec: avbridgeVideoToMediabunny(videoCodec),
101
+ bitrate: options.videoBitrate ?? qualityToMediabunny(mb, quality),
102
+ forceTranscode: true,
103
+ ...(options.width !== undefined ? { width: options.width } : {}),
104
+ ...(options.height !== undefined ? { height: options.height } : {}),
105
+ ...(options.width !== undefined && options.height !== undefined
106
+ ? { fit: "contain" as const }
107
+ : {}),
108
+ ...(options.frameRate !== undefined ? { frameRate: options.frameRate } : {}),
109
+ ...(options.hardwareAcceleration !== undefined
110
+ ? { hardwareAcceleration: options.hardwareAcceleration }
111
+ : {}),
112
+ };
113
+
114
+ const audioOptions = options.dropAudio
115
+ ? { discard: true as const }
116
+ : {
117
+ codec: avbridgeAudioToMediabunny(audioCodec),
118
+ bitrate: options.audioBitrate ?? qualityToMediabunny(mb, quality),
119
+ forceTranscode: true,
120
+ };
121
+
122
+ const conversion = await mb.Conversion.init({
123
+ input,
124
+ output,
125
+ video: videoOptions,
126
+ audio: audioOptions,
127
+ showWarnings: false,
128
+ });
129
+
130
+ if (!conversion.isValid) {
131
+ const reasons = conversion.discardedTracks
132
+ .map((d) => `${d.track.type} track discarded: ${d.reason}`)
133
+ .join("; ");
134
+ throw new Error(
135
+ `Cannot transcode: mediabunny rejected the conversion. ${reasons || "(no reason given)"}`,
136
+ );
137
+ }
138
+
139
+ // Wire progress
140
+ if (options.onProgress) {
141
+ const onProgress = options.onProgress;
142
+ conversion.onProgress = (p) => {
143
+ onProgress({ percent: p * 100, bytesWritten: 0 });
144
+ };
145
+ }
146
+
147
+ // Wire cancellation
148
+ let abortHandler: (() => void) | undefined;
149
+ if (options.signal) {
150
+ options.signal.throwIfAborted();
151
+ abortHandler = () => void conversion.cancel();
152
+ options.signal.addEventListener("abort", abortHandler, { once: true });
153
+ }
154
+
155
+ try {
156
+ await conversion.execute();
157
+ } finally {
158
+ if (abortHandler && options.signal) {
159
+ options.signal.removeEventListener("abort", abortHandler);
160
+ }
161
+ }
162
+
163
+ if (!target.buffer) {
164
+ throw new Error("Transcode failed: mediabunny produced no output buffer.");
165
+ }
166
+ return target.buffer;
167
+ }
168
+
169
+ /**
170
+ * Detect the "Encoding error" failure pattern that headless Chromium's
171
+ * H.264 WebCodecs encoder hits on its first call per page. The encoder
172
+ * is fully usable on the second attempt, so we retry once.
173
+ *
174
+ * See <https://issues.chromium.org/> — this is a known first-call init
175
+ * issue in the OS-backed encoder pipeline (VideoToolbox on macOS).
176
+ */
177
+ function isLikelyEncoderInitError(err: unknown): boolean {
178
+ if (!err) return false;
179
+ const msg = err instanceof Error ? err.message : String(err);
180
+ const lower = msg.toLowerCase();
181
+ // The exact strings WebCodecs / mediabunny surface for encoder failures.
182
+ return (
183
+ lower.includes("encoding error") ||
184
+ lower.includes("encoder") ||
185
+ lower.includes("encode failed")
186
+ );
187
+ }
188
+
189
+ function describeError(err: unknown): string {
190
+ if (!err) return "(unknown)";
191
+ if (err instanceof Error) return err.message;
192
+ return String(err);
193
+ }
194
+
195
+ /**
196
+ * Maximum encoder retry attempts. Headless Chromium's H.264 WebCodecs
197
+ * encoder hits a first-call init failure and *usually* recovers on the
198
+ * second attempt — but in rare cases the second attempt also fails. Two
199
+ * extra attempts (3 total) is enough to make the smoke test reliable
200
+ * without masking real bugs.
201
+ */
202
+ const MAX_ENCODER_RETRIES = 2;
203
+
204
+ async function doTranscode(
205
+ ctx: MediaContext,
206
+ outputFormat: OutputFormat,
207
+ videoCodec: OutputVideoCodec,
208
+ audioCodec: OutputAudioCodec,
209
+ quality: TranscodeQuality,
210
+ options: TranscodeOptions,
211
+ ): Promise<ConvertResult> {
212
+ const notes: string[] = [];
213
+ let buffer: ArrayBuffer | null = null;
214
+ let lastError: unknown;
215
+
216
+ for (let attempt = 0; attempt <= MAX_ENCODER_RETRIES; attempt++) {
217
+ try {
218
+ buffer = await attemptTranscode(ctx, outputFormat, videoCodec, audioCodec, quality, options);
219
+ if (attempt > 0) {
220
+ notes.push(
221
+ `Encoder failed ${attempt} time${attempt === 1 ? "" : "s"} before succeeding ` +
222
+ `(known headless Chromium WebCodecs encoder init issue): ${describeError(lastError)}`,
223
+ );
224
+ }
225
+ break;
226
+ } catch (err) {
227
+ lastError = err;
228
+ // Don't retry on user cancellation or permanent setup errors.
229
+ if (options.signal?.aborted) throw err;
230
+ if (!isLikelyEncoderInitError(err)) throw err;
231
+ if (attempt === MAX_ENCODER_RETRIES) throw err;
232
+ // Small backoff between attempts.
233
+ await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
234
+ }
235
+ }
236
+
237
+ if (!buffer) {
238
+ throw new Error("Transcode failed: no buffer produced (this should be unreachable).");
239
+ }
240
+
241
+ const mimeType = mimeForFormat(outputFormat);
242
+ const blob = new Blob([buffer], { type: mimeType });
243
+ const filename = generateFilename(ctx.name, outputFormat);
244
+
245
+ options.onProgress?.({ percent: 100, bytesWritten: blob.size });
246
+
247
+ return {
248
+ blob,
249
+ mimeType,
250
+ container: outputFormat,
251
+ videoCodec: options.dropVideo ? undefined : videoCodec,
252
+ audioCodec: options.dropAudio ? undefined : audioCodec,
253
+ duration: ctx.duration,
254
+ filename,
255
+ ...(notes.length > 0 ? { notes } : {}),
256
+ };
257
+ }
258
+
259
+ // ── Helpers ─────────────────────────────────────────────────────────────────
260
+
261
+ /** @internal Exported for testing. */
262
+ export function defaultVideoCodec(format: OutputFormat): OutputVideoCodec {
263
+ switch (format) {
264
+ case "webm": return "vp9";
265
+ case "mp4":
266
+ case "mkv":
267
+ default: return "h264";
268
+ }
269
+ }
270
+
271
+ /** @internal Exported for testing. */
272
+ export function defaultAudioCodec(format: OutputFormat): OutputAudioCodec {
273
+ switch (format) {
274
+ case "webm": return "opus";
275
+ case "mp4":
276
+ case "mkv":
277
+ default: return "aac";
278
+ }
279
+ }
280
+
281
+ /** @internal Exported for testing. */
282
+ export function validateCodecCompatibility(
283
+ format: OutputFormat,
284
+ videoCodec: OutputVideoCodec,
285
+ audioCodec: OutputAudioCodec,
286
+ ): void {
287
+ // WebM only allows VP8/VP9/AV1 video and Opus/Vorbis audio.
288
+ if (format === "webm") {
289
+ if (videoCodec !== "vp9" && videoCodec !== "av1") {
290
+ throw new Error(
291
+ `WebM does not support video codec "${videoCodec}". Use "vp9" or "av1", or change outputFormat to "mp4" or "mkv".`,
292
+ );
293
+ }
294
+ if (audioCodec !== "opus") {
295
+ throw new Error(
296
+ `WebM does not support audio codec "${audioCodec}". Use "opus", or change outputFormat to "mp4" or "mkv".`,
297
+ );
298
+ }
299
+ }
300
+ }
301
+
302
+ function avbridgeVideoToMediabunny(c: OutputVideoCodec): "avc" | "hevc" | "vp9" | "av1" {
303
+ switch (c) {
304
+ case "h264": return "avc";
305
+ case "h265": return "hevc";
306
+ case "vp9": return "vp9";
307
+ case "av1": return "av1";
308
+ }
309
+ }
310
+
311
+ function avbridgeAudioToMediabunny(c: OutputAudioCodec): "aac" | "opus" | "flac" {
312
+ switch (c) {
313
+ case "aac": return "aac";
314
+ case "opus": return "opus";
315
+ case "flac": return "flac";
316
+ }
317
+ }
318
+
319
+ function qualityToMediabunny(
320
+ mb: typeof import("mediabunny"),
321
+ quality: TranscodeQuality,
322
+ ): InstanceType<typeof mb.Quality> {
323
+ switch (quality) {
324
+ case "low": return mb.QUALITY_LOW;
325
+ case "medium": return mb.QUALITY_MEDIUM;
326
+ case "high": return mb.QUALITY_HIGH;
327
+ case "very-high": return mb.QUALITY_VERY_HIGH;
328
+ }
329
+ }
@@ -0,0 +1,99 @@
1
+ import type {
2
+ Classification,
3
+ DiagnosticsSnapshot,
4
+ MediaContext,
5
+ StrategyName,
6
+ } from "./types.js";
7
+
8
+ /**
9
+ * Accumulates diagnostic info as the player walks probe → classify → play.
10
+ * `snapshot()` produces an immutable view shaped exactly like the example in
11
+ * design doc §12.
12
+ */
13
+ export class Diagnostics {
14
+ private container: DiagnosticsSnapshot["container"] = "unknown";
15
+ private videoCodec?: DiagnosticsSnapshot["videoCodec"];
16
+ private audioCodec?: DiagnosticsSnapshot["audioCodec"];
17
+ private width?: number;
18
+ private height?: number;
19
+ private fps?: number;
20
+ private duration?: number;
21
+ private strategy: DiagnosticsSnapshot["strategy"] = "pending";
22
+ private strategyClass: DiagnosticsSnapshot["strategyClass"] = "pending";
23
+ private reason = "";
24
+ private probedBy?: DiagnosticsSnapshot["probedBy"];
25
+ private sourceType?: DiagnosticsSnapshot["sourceType"];
26
+ private transport?: DiagnosticsSnapshot["transport"];
27
+ private rangeSupported?: DiagnosticsSnapshot["rangeSupported"];
28
+ private runtime: Record<string, unknown> = {};
29
+ private lastError?: Error;
30
+ private strategyHistory: Array<{ strategy: StrategyName; reason: string; at: number }> = [];
31
+
32
+ recordProbe(ctx: MediaContext): void {
33
+ this.container = ctx.container;
34
+ this.probedBy = ctx.probedBy;
35
+ this.duration = ctx.duration;
36
+ const v = ctx.videoTracks[0];
37
+ if (v) {
38
+ this.videoCodec = v.codec;
39
+ this.width = v.width;
40
+ this.height = v.height;
41
+ this.fps = v.fps;
42
+ }
43
+ const a = ctx.audioTracks[0];
44
+ if (a) this.audioCodec = a.codec;
45
+ // Source-type detection: URL strings / URL objects stream via Range
46
+ // requests; everything else is in-memory.
47
+ const src = ctx.source;
48
+ if (typeof src === "string" || src instanceof URL) {
49
+ this.sourceType = "url";
50
+ this.transport = "http-range";
51
+ this.rangeSupported = true; // we fail fast otherwise — see attachLibavHttpReader
52
+ } else {
53
+ this.sourceType = "blob";
54
+ this.transport = "memory";
55
+ }
56
+ }
57
+
58
+ recordClassification(c: Classification): void {
59
+ this.strategy = c.strategy;
60
+ this.strategyClass = c.class;
61
+ this.reason = c.reason;
62
+ }
63
+
64
+ recordRuntime(stats: Record<string, unknown>): void {
65
+ this.runtime = { ...this.runtime, ...stats };
66
+ }
67
+
68
+ recordStrategySwitch(strategy: StrategyName, reason: string): void {
69
+ this.strategy = strategy;
70
+ this.reason = reason;
71
+ this.strategyHistory.push({ strategy, reason, at: Date.now() });
72
+ }
73
+
74
+ recordError(err: Error): void {
75
+ this.lastError = err;
76
+ }
77
+
78
+ snapshot(): DiagnosticsSnapshot {
79
+ const snap: DiagnosticsSnapshot = {
80
+ container: this.container,
81
+ videoCodec: this.videoCodec,
82
+ audioCodec: this.audioCodec,
83
+ width: this.width,
84
+ height: this.height,
85
+ fps: this.fps,
86
+ duration: this.duration,
87
+ strategy: this.strategy,
88
+ strategyClass: this.strategyClass,
89
+ reason: this.reason,
90
+ probedBy: this.probedBy,
91
+ sourceType: this.sourceType,
92
+ transport: this.transport,
93
+ rangeSupported: this.rangeSupported,
94
+ runtime: { ...this.runtime, ...(this.lastError ? { error: this.lastError.message } : {}) },
95
+ strategyHistory: this.strategyHistory.length > 0 ? [...this.strategyHistory] : undefined,
96
+ };
97
+ return Object.freeze(snap);
98
+ }
99
+ }