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,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
|
+
}
|