@catlabtech/webcvt-transcode 0.2.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/dist/index.js ADDED
@@ -0,0 +1,1554 @@
1
+ import { defaultRegistry, WebcvtError } from '@catlabtech/webcvt-core';
2
+ import { probeVideoCodec, WebCodecsNotSupportedError, WebCodecsAudioDecoder, probeAudioDecoder, probeAudioCodec, probeVideoDecoder, WebCodecsVideoEncoder, WebCodecsVideoDecoder, WebCodecsAudioEncoder } from '@catlabtech/webcvt-codec-webcodecs';
3
+ import { parseAdts, buildAudioSpecificConfig } from '@catlabtech/webcvt-container-aac';
4
+ import { parseFlac, encodeBlockHeader, BLOCK_TYPE_STREAMINFO, STREAMINFO_SIZE, encodeStreamInfo } from '@catlabtech/webcvt-container-flac';
5
+ import { parseMp3 } from '@catlabtech/webcvt-container-mp3';
6
+ import { buildOpusHead, buildOpusTags, serializeOgg, parseOgg } from '@catlabtech/webcvt-container-ogg';
7
+ import { parseWav, serializeWav } from '@catlabtech/webcvt-container-wav';
8
+ import { serializeMkv, parseMkv, iterateVideoChunks as iterateVideoChunks$1, iterateAudioChunks as iterateAudioChunks$1 } from '@catlabtech/webcvt-container-mkv';
9
+ import { parseMp4, findVideoTrack, iterateVideoSamples, findAudioTrack, deriveCodecString, iterateAudioSamplesAuto } from '@catlabtech/webcvt-container-mp4';
10
+ import { serializeWebm, parseWebm, iterateVideoChunks, iterateAudioChunks } from '@catlabtech/webcvt-container-webm';
11
+
12
+ // src/backend.ts
13
+ var TranscodeUnsupportedError = class extends WebcvtError {
14
+ constructor(from, to) {
15
+ super(
16
+ "TRANSCODE_UNSUPPORTED",
17
+ `Transcode backend cannot convert ${from} \u2192 ${to}. This pair is not in the v1 matrix, or the required WebCodecs decoder/encoder is unavailable in this runtime.`
18
+ );
19
+ this.name = "TranscodeUnsupportedError";
20
+ }
21
+ };
22
+ var TranscodeInputTooLargeError = class extends WebcvtError {
23
+ constructor(size, max) {
24
+ super(
25
+ "TRANSCODE_INPUT_TOO_LARGE",
26
+ `Transcode input is ${size} bytes; maximum supported is ${max} bytes.`
27
+ );
28
+ this.name = "TranscodeInputTooLargeError";
29
+ }
30
+ };
31
+ var TranscodeDemuxError = class extends WebcvtError {
32
+ constructor(message, options) {
33
+ super("TRANSCODE_DEMUX_FAILED", `Transcode demux failed: ${message}`, options);
34
+ this.name = "TranscodeDemuxError";
35
+ }
36
+ };
37
+ var TranscodeCodecError = class extends WebcvtError {
38
+ constructor(message, options) {
39
+ super("TRANSCODE_CODEC_ERROR", `Transcode codec error: ${message}`, options);
40
+ this.name = "TranscodeCodecError";
41
+ }
42
+ };
43
+ var TranscodeMuxError = class extends WebcvtError {
44
+ constructor(message, options) {
45
+ super("TRANSCODE_MUX_FAILED", `Transcode mux failed: ${message}`, options);
46
+ this.name = "TranscodeMuxError";
47
+ }
48
+ };
49
+
50
+ // src/abort.ts
51
+ function throwIfAborted(signal) {
52
+ if (signal?.aborted) throw createAbortError();
53
+ }
54
+ function createAbortError() {
55
+ return new DOMException("The transcode was aborted.", "AbortError");
56
+ }
57
+ function isAbortError(err) {
58
+ return err instanceof DOMException && err.name === "AbortError";
59
+ }
60
+ function asCodecError(err, phase) {
61
+ if (isAbortError(err)) return err;
62
+ const message = err instanceof Error ? err.message : String(err);
63
+ return new TranscodeCodecError(`${phase}: ${message}`, { cause: err });
64
+ }
65
+
66
+ // src/pcm.ts
67
+ function readAudioDataPlanes(data) {
68
+ const frames = data.numberOfFrames;
69
+ const planes = [];
70
+ for (let ch = 0; ch < data.numberOfChannels; ch++) {
71
+ const plane = new Float32Array(frames);
72
+ data.copyTo(plane, { planeIndex: ch, format: "f32-planar" });
73
+ planes.push(plane);
74
+ }
75
+ return planes;
76
+ }
77
+ var PlanarAccumulator = class {
78
+ #perChannel = [];
79
+ #frames = 0;
80
+ #numChannels = 0;
81
+ #sampleRate = 0;
82
+ add(planes, sampleRate) {
83
+ if (this.#numChannels === 0) {
84
+ this.#numChannels = planes.length;
85
+ this.#sampleRate = sampleRate;
86
+ this.#perChannel = planes.map(() => []);
87
+ }
88
+ for (let ch = 0; ch < this.#numChannels; ch++) {
89
+ this.#perChannel[ch]?.push(planes[ch] ?? planes[0] ?? new Float32Array(0));
90
+ }
91
+ this.#frames += planes[0]?.length ?? 0;
92
+ }
93
+ finish(fallbackSampleRate, fallbackChannels) {
94
+ if (this.#numChannels === 0) {
95
+ return {
96
+ sampleRate: fallbackSampleRate,
97
+ numberOfChannels: fallbackChannels,
98
+ numberOfFrames: 0,
99
+ channels: Array.from({ length: fallbackChannels }, () => new Float32Array(0))
100
+ };
101
+ }
102
+ const channels = this.#perChannel.map((chunks) => concatFloat(chunks, this.#frames));
103
+ return {
104
+ sampleRate: this.#sampleRate,
105
+ numberOfChannels: this.#numChannels,
106
+ numberOfFrames: this.#frames,
107
+ channels
108
+ };
109
+ }
110
+ };
111
+ var WAVE_FORMAT_IEEE_FLOAT = 3;
112
+ function wavToDecoded(file) {
113
+ const { format, audioData } = file;
114
+ const ch = Math.max(1, format.channels);
115
+ const bits = format.bitsPerSample;
116
+ const bytesPerSample = bits / 8;
117
+ const frameSize = bytesPerSample * ch;
118
+ const frames = frameSize > 0 ? Math.floor(audioData.length / frameSize) : 0;
119
+ const isFloat = format.audioFormat === WAVE_FORMAT_IEEE_FLOAT;
120
+ const view = new DataView(audioData.buffer, audioData.byteOffset, audioData.byteLength);
121
+ const channels = Array.from({ length: ch }, () => new Float32Array(frames));
122
+ for (let f = 0; f < frames; f++) {
123
+ for (let c = 0; c < ch; c++) {
124
+ const off = f * frameSize + c * bytesPerSample;
125
+ const target = channels[c];
126
+ if (target) target[f] = readSample(view, off, bits, isFloat);
127
+ }
128
+ }
129
+ return { sampleRate: format.sampleRate, numberOfChannels: ch, numberOfFrames: frames, channels };
130
+ }
131
+ function interleaveInt16(decoded) {
132
+ const { numberOfChannels: ch, numberOfFrames: n, channels } = decoded;
133
+ const out = new Uint8Array(n * ch * 2);
134
+ const view = new DataView(out.buffer);
135
+ let pos = 0;
136
+ for (let f = 0; f < n; f++) {
137
+ for (let c = 0; c < ch; c++) {
138
+ view.setInt16(pos, floatToInt16(channels[c]?.[f] ?? 0), true);
139
+ pos += 2;
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+ function buildAudioData(decoded, startFrame, frameCount, timestampUs) {
145
+ const ch = decoded.numberOfChannels;
146
+ const data = new Float32Array(ch * frameCount);
147
+ for (let c = 0; c < ch; c++) {
148
+ const plane = decoded.channels[c];
149
+ if (plane) data.set(plane.subarray(startFrame, startFrame + frameCount), c * frameCount);
150
+ }
151
+ return new AudioData({
152
+ format: "f32-planar",
153
+ sampleRate: decoded.sampleRate,
154
+ numberOfFrames: frameCount,
155
+ numberOfChannels: ch,
156
+ timestamp: timestampUs,
157
+ data
158
+ });
159
+ }
160
+ function concatFloat(chunks, total) {
161
+ const out = new Float32Array(total);
162
+ let off = 0;
163
+ for (const c of chunks) {
164
+ out.set(c, off);
165
+ off += c.length;
166
+ }
167
+ return out;
168
+ }
169
+ function floatToInt16(v) {
170
+ const s = Math.round(v * 32767);
171
+ return s < -32768 ? -32768 : s > 32767 ? 32767 : s;
172
+ }
173
+ function readSample(view, off, bits, isFloat) {
174
+ if (isFloat) {
175
+ return bits === 64 ? view.getFloat64(off, true) : view.getFloat32(off, true);
176
+ }
177
+ switch (bits) {
178
+ case 8:
179
+ return (view.getUint8(off) - 128) / 128;
180
+ case 16:
181
+ return view.getInt16(off, true) / 32768;
182
+ case 24: {
183
+ const b0 = view.getUint8(off);
184
+ const b1 = view.getUint8(off + 1);
185
+ const b2 = view.getUint8(off + 2);
186
+ let val = b0 | b1 << 8 | b2 << 16;
187
+ if (val & 8388608) val -= 16777216;
188
+ return val / 8388608;
189
+ }
190
+ case 32:
191
+ return view.getInt32(off, true) / 2147483648;
192
+ default:
193
+ return 0;
194
+ }
195
+ }
196
+
197
+ // src/decode.ts
198
+ async function decodeToPcm(demux, ctx) {
199
+ throwIfAborted(ctx.signal);
200
+ if (demux.kind === "pcm") {
201
+ ctx.onProgress?.(1);
202
+ return demux.decoded;
203
+ }
204
+ const { config, chunks, sampleRate, numberOfChannels } = demux;
205
+ const acc = new PlanarAccumulator();
206
+ const decoder = new WebCodecsAudioDecoder({ config }, (data) => {
207
+ acc.add(readAudioDataPlanes(data), data.sampleRate);
208
+ data.close();
209
+ });
210
+ const onAbort = () => decoder.close();
211
+ ctx.signal?.addEventListener("abort", onAbort, { once: true });
212
+ try {
213
+ const total = chunks.length || 1;
214
+ for (let i = 0; i < chunks.length; i++) {
215
+ throwIfAborted(ctx.signal);
216
+ const spec = chunks[i];
217
+ if (!spec) continue;
218
+ decoder.decode(
219
+ new EncodedAudioChunk({
220
+ type: spec.type,
221
+ timestamp: spec.timestampUs,
222
+ duration: spec.durationUs,
223
+ data: spec.data
224
+ })
225
+ );
226
+ ctx.onProgress?.((i + 1) / total);
227
+ }
228
+ await decoder.flush();
229
+ throwIfAborted(ctx.signal);
230
+ } catch (err) {
231
+ throw asCodecError(err, "decode");
232
+ } finally {
233
+ ctx.signal?.removeEventListener("abort", onAbort);
234
+ decoder.close();
235
+ }
236
+ return acc.finish(sampleRate, numberOfChannels);
237
+ }
238
+ var US_PER_SEC = 1e6;
239
+ var OPUS_RATE = 48e3;
240
+ var NOMINAL_AUDIO_FRAME_US = 2e4;
241
+ function demuxContainerVideo(family, bytes) {
242
+ return wrap(() => {
243
+ switch (family) {
244
+ case "mp4": {
245
+ const file = parseMp4(bytes);
246
+ return { video: mp4Video(file), audio: mp4Audio(file) };
247
+ }
248
+ case "webm": {
249
+ const file = parseWebm(bytes);
250
+ return { video: webmVideo(file), audio: webmAudio(file) };
251
+ }
252
+ case "mkv": {
253
+ const file = parseMkv(bytes);
254
+ return { video: mkvVideo(file), audio: mkvAudio(file) };
255
+ }
256
+ }
257
+ });
258
+ }
259
+ function demuxContainerAudioTrack(family, bytes) {
260
+ return wrap(() => {
261
+ let audio;
262
+ switch (family) {
263
+ case "mp4":
264
+ audio = mp4Audio(parseMp4(bytes));
265
+ break;
266
+ case "webm":
267
+ audio = webmAudio(parseWebm(bytes));
268
+ break;
269
+ case "mkv":
270
+ audio = mkvAudio(parseMkv(bytes));
271
+ break;
272
+ }
273
+ if (!audio) {
274
+ throw new TranscodeDemuxError(
275
+ `${family} container has no audio track decodable here (only AAC/Opus audio is supported)`
276
+ );
277
+ }
278
+ return audio;
279
+ });
280
+ }
281
+ function mp4Video(file) {
282
+ const track = findVideoTrack(file);
283
+ if (!track || track.sampleEntry.kind !== "video") {
284
+ throw new TranscodeDemuxError("mp4 has no video track");
285
+ }
286
+ const entry = track.sampleEntry.entry;
287
+ const config = {
288
+ codec: entry.codecString,
289
+ description: entry.codecConfig.bytes,
290
+ codedWidth: entry.width,
291
+ codedHeight: entry.height
292
+ };
293
+ const chunks = [];
294
+ for (const s of iterateVideoSamples(track, file.fileBytes)) {
295
+ chunks.push({
296
+ type: s.isKeyframe ? "key" : "delta",
297
+ timestampUs: Math.round(s.presentationTimeUs),
298
+ durationUs: Math.round(s.durationUs),
299
+ data: s.data
300
+ });
301
+ }
302
+ if (chunks.length === 0) throw new TranscodeDemuxError("mp4 video track has no samples");
303
+ return {
304
+ config,
305
+ chunks,
306
+ width: entry.width,
307
+ height: entry.height,
308
+ frameRate: estimateFrameRate(chunks),
309
+ codec: mp4VideoCodec(entry.format)
310
+ };
311
+ }
312
+ function mp4Audio(file) {
313
+ const track = findAudioTrack(file);
314
+ if (!track || track.sampleEntry.kind !== "audio") return null;
315
+ const entry = track.sampleEntry.entry;
316
+ const codec = deriveCodecString(entry.objectTypeIndication, entry.decoderSpecificInfo);
317
+ const numberOfChannels = Math.max(1, entry.channelCount);
318
+ const sampleRate = entry.sampleRate;
319
+ const chunks = [];
320
+ for (const s of iterateAudioSamplesAuto(file, track)) {
321
+ chunks.push({
322
+ type: "key",
323
+ timestampUs: Math.round(s.timestampUs),
324
+ durationUs: Math.round(s.durationUs),
325
+ data: s.data
326
+ });
327
+ }
328
+ if (chunks.length === 0) return null;
329
+ return {
330
+ config: { codec, description: entry.decoderSpecificInfo, sampleRate, numberOfChannels },
331
+ chunks,
332
+ sampleRate,
333
+ numberOfChannels
334
+ };
335
+ }
336
+ function mp4VideoCodec(format) {
337
+ if (format.startsWith("avc")) return "h264";
338
+ if (format.startsWith("hev") || format.startsWith("hvc")) return "hevc";
339
+ if (format === "vp09") return "vp9";
340
+ if (format === "av01") return "av1";
341
+ return "h264";
342
+ }
343
+ function webmVideo(file) {
344
+ const track = file.tracks.find((t) => t.trackType === 1);
345
+ if (!track) throw new TranscodeDemuxError("webm has no video track");
346
+ const { codec, config } = webmVideoConfig(track);
347
+ const chunks = collectVideoChunks(iterateVideoChunks(file, track.trackNumber));
348
+ if (chunks.length === 0) throw new TranscodeDemuxError("webm video track has no blocks");
349
+ return {
350
+ config,
351
+ chunks,
352
+ width: track.pixelWidth,
353
+ height: track.pixelHeight,
354
+ frameRate: estimateFrameRate(chunks),
355
+ codec
356
+ };
357
+ }
358
+ function webmVideoConfig(track) {
359
+ const base = { codedWidth: track.pixelWidth, codedHeight: track.pixelHeight };
360
+ switch (track.codecId) {
361
+ case "V_VP8":
362
+ return { codec: "vp8", config: { codec: "vp8", ...base } };
363
+ case "V_VP9":
364
+ return { codec: "vp9", config: { codec: "vp09.00.10.08", ...base } };
365
+ case "V_AV01":
366
+ return {
367
+ codec: "av1",
368
+ config: {
369
+ codec: "av01.0.04M.08",
370
+ ...track.codecPrivate ? { description: track.codecPrivate } : {},
371
+ ...base
372
+ }
373
+ };
374
+ }
375
+ }
376
+ function webmAudio(file) {
377
+ const track = file.tracks.find((t) => t.trackType === 2);
378
+ if (!track) return null;
379
+ if (track.codecId !== "A_OPUS") return null;
380
+ const numberOfChannels = Math.max(1, track.channels);
381
+ const chunks = deriveAudioDurations(
382
+ collectAudioChunks(iterateAudioChunks(file, track.trackNumber))
383
+ );
384
+ if (chunks.length === 0) return null;
385
+ return {
386
+ config: {
387
+ codec: "opus",
388
+ description: track.codecPrivate,
389
+ sampleRate: OPUS_RATE,
390
+ numberOfChannels
391
+ },
392
+ chunks,
393
+ sampleRate: OPUS_RATE,
394
+ numberOfChannels
395
+ };
396
+ }
397
+ function mkvVideo(file) {
398
+ const track = file.tracks.find((t) => t.trackType === 1);
399
+ if (!track) throw new TranscodeDemuxError("mkv has no video track");
400
+ const config = {
401
+ codec: track.webcodecsCodecString,
402
+ ...mkvNeedsDescription(track.codecId) && track.codecPrivate ? { description: track.codecPrivate } : {},
403
+ codedWidth: track.pixelWidth,
404
+ codedHeight: track.pixelHeight
405
+ };
406
+ const chunks = collectVideoChunks(iterateVideoChunks$1(file, track.trackNumber));
407
+ if (chunks.length === 0) throw new TranscodeDemuxError("mkv video track has no blocks");
408
+ return {
409
+ config,
410
+ chunks,
411
+ width: track.pixelWidth,
412
+ height: track.pixelHeight,
413
+ frameRate: estimateFrameRate(chunks),
414
+ codec: mkvVideoCodec(track.codecId)
415
+ };
416
+ }
417
+ function mkvAudio(file) {
418
+ const track = file.tracks.find((t) => t.trackType === 2);
419
+ if (!track) return null;
420
+ let config;
421
+ let sampleRate;
422
+ const numberOfChannels = Math.max(1, track.channels);
423
+ if (track.codecId === "A_OPUS") {
424
+ sampleRate = OPUS_RATE;
425
+ config = { codec: "opus", description: track.codecPrivate, sampleRate, numberOfChannels };
426
+ } else if (track.codecId === "A_AAC") {
427
+ sampleRate = track.samplingFrequency;
428
+ config = {
429
+ codec: track.webcodecsCodecString,
430
+ description: track.codecPrivate,
431
+ sampleRate,
432
+ numberOfChannels
433
+ };
434
+ } else {
435
+ return null;
436
+ }
437
+ const chunks = deriveAudioDurations(
438
+ collectAudioChunks(iterateAudioChunks$1(file, track.trackNumber))
439
+ );
440
+ if (chunks.length === 0) return null;
441
+ return { config, chunks, sampleRate, numberOfChannels };
442
+ }
443
+ function mkvNeedsDescription(codecId) {
444
+ return codecId === "V_MPEG4/ISO/AVC" || codecId === "V_MPEGH/ISO/HEVC" || codecId === "V_AV01";
445
+ }
446
+ function mkvVideoCodec(codecId) {
447
+ switch (codecId) {
448
+ case "V_MPEG4/ISO/AVC":
449
+ return "h264";
450
+ case "V_MPEGH/ISO/HEVC":
451
+ return "hevc";
452
+ case "V_VP8":
453
+ return "vp8";
454
+ case "V_VP9":
455
+ return "vp9";
456
+ case "V_AV01":
457
+ return "av1";
458
+ }
459
+ }
460
+ function collectVideoChunks(gen) {
461
+ const raw = [...gen];
462
+ const specs = [];
463
+ for (let i = 0; i < raw.length; i++) {
464
+ const c = raw[i];
465
+ if (!c) continue;
466
+ const next = raw[i + 1];
467
+ const durationUs = next ? Math.max(1, next.timestampUs - c.timestampUs) : specs.at(-1)?.durationUs ?? NOMINAL_AUDIO_FRAME_US;
468
+ specs.push({ type: c.type, timestampUs: c.timestampUs, durationUs, data: c.data });
469
+ }
470
+ return specs;
471
+ }
472
+ function collectAudioChunks(gen) {
473
+ return [...gen].map((c) => ({
474
+ type: "key",
475
+ timestampUs: c.timestampUs,
476
+ durationUs: 0,
477
+ // filled by deriveAudioDurations
478
+ data: c.data
479
+ }));
480
+ }
481
+ function deriveAudioDurations(chunks) {
482
+ return chunks.map((c, i) => {
483
+ const next = chunks[i + 1];
484
+ const durationUs = next ? Math.max(1, next.timestampUs - c.timestampUs) : chunks[i - 1] ? Math.max(1, c.timestampUs - (chunks[i - 1]?.timestampUs ?? 0)) : NOMINAL_AUDIO_FRAME_US;
485
+ return { ...c, durationUs };
486
+ });
487
+ }
488
+ function estimateFrameRate(chunks) {
489
+ if (chunks.length < 2) return 0;
490
+ const first = chunks[0];
491
+ const last = chunks[chunks.length - 1];
492
+ if (!first || !last) return 0;
493
+ const spanUs = last.timestampUs + last.durationUs - first.timestampUs;
494
+ if (spanUs <= 0) return 0;
495
+ return Math.round(chunks.length / spanUs * US_PER_SEC);
496
+ }
497
+ function wrap(run) {
498
+ try {
499
+ return run();
500
+ } catch (err) {
501
+ if (err instanceof TranscodeDemuxError) throw err;
502
+ throw new TranscodeDemuxError(err instanceof Error ? err.message : String(err), { cause: err });
503
+ }
504
+ }
505
+
506
+ // src/demux.ts
507
+ var US_PER_SEC2 = 1e6;
508
+ function demuxAudio(inputCodec, bytes) {
509
+ try {
510
+ switch (inputCodec) {
511
+ case "pcm":
512
+ return { kind: "pcm", decoded: wavToDecoded(parseWav(bytes)) };
513
+ case "mp3":
514
+ return demuxMp3(bytes);
515
+ case "aac":
516
+ return demuxAac(bytes);
517
+ case "flac":
518
+ return demuxFlac(bytes);
519
+ case "opus":
520
+ return demuxOpus(bytes);
521
+ default:
522
+ throw new TranscodeDemuxError(`no demuxer for input codec "${inputCodec}"`);
523
+ }
524
+ } catch (err) {
525
+ if (err instanceof TranscodeDemuxError) throw err;
526
+ throw new TranscodeDemuxError(err instanceof Error ? err.message : String(err), { cause: err });
527
+ }
528
+ }
529
+ function demuxContainerAudio(family, bytes) {
530
+ const track = demuxContainerAudioTrack(family, bytes);
531
+ return {
532
+ kind: "encoded",
533
+ config: track.config,
534
+ chunks: track.chunks,
535
+ sampleRate: track.sampleRate,
536
+ numberOfChannels: track.numberOfChannels
537
+ };
538
+ }
539
+ function demuxMp3(bytes) {
540
+ const { frames } = parseMp3(bytes);
541
+ if (frames.length === 0) throw new TranscodeDemuxError("mp3 has no audio frames");
542
+ const first = frames[0]?.header;
543
+ if (!first) throw new TranscodeDemuxError("mp3 has no audio frames");
544
+ const sampleRate = first.sampleRate;
545
+ const numberOfChannels = first.channelMode === "mono" ? 1 : 2;
546
+ const chunks = [];
547
+ let sample = 0;
548
+ for (const frame of frames) {
549
+ const spf = frame.header.samplesPerFrame;
550
+ chunks.push({
551
+ type: "key",
552
+ timestampUs: Math.round(sample / sampleRate * US_PER_SEC2),
553
+ durationUs: Math.round(spf / sampleRate * US_PER_SEC2),
554
+ data: frame.data
555
+ });
556
+ sample += spf;
557
+ }
558
+ return {
559
+ kind: "encoded",
560
+ config: { codec: "mp3", sampleRate, numberOfChannels },
561
+ chunks,
562
+ sampleRate,
563
+ numberOfChannels
564
+ };
565
+ }
566
+ var AAC_SAMPLES_PER_FRAME = 1024;
567
+ function demuxAac(bytes) {
568
+ const { frames } = parseAdts(bytes);
569
+ if (frames.length === 0) throw new TranscodeDemuxError("aac has no ADTS frames");
570
+ const header = frames[0]?.header;
571
+ if (!header) throw new TranscodeDemuxError("aac has no ADTS frames");
572
+ const sampleRate = header.sampleRate;
573
+ const numberOfChannels = Math.max(1, header.channelConfiguration);
574
+ const description = buildAudioSpecificConfig(header);
575
+ const chunks = [];
576
+ let sample = 0;
577
+ for (const frame of frames) {
578
+ const headerSize = frame.header.hasCrc ? 9 : 7;
579
+ chunks.push({
580
+ type: "key",
581
+ timestampUs: Math.round(sample / sampleRate * US_PER_SEC2),
582
+ durationUs: Math.round(AAC_SAMPLES_PER_FRAME / sampleRate * US_PER_SEC2),
583
+ data: frame.data.subarray(headerSize)
584
+ // raw AU (strip ADTS header)
585
+ });
586
+ sample += AAC_SAMPLES_PER_FRAME;
587
+ }
588
+ return {
589
+ kind: "encoded",
590
+ config: { codec: "mp4a.40.2", sampleRate, numberOfChannels, description },
591
+ chunks,
592
+ sampleRate,
593
+ numberOfChannels
594
+ };
595
+ }
596
+ function demuxFlac(bytes) {
597
+ const { streamInfo, frames } = parseFlac(bytes);
598
+ if (frames.length === 0) throw new TranscodeDemuxError("flac has no frames");
599
+ const sampleRate = streamInfo.sampleRate;
600
+ const numberOfChannels = streamInfo.channels;
601
+ const description = buildFlacDescription(streamInfo);
602
+ const chunks = [];
603
+ for (const frame of frames) {
604
+ chunks.push({
605
+ type: "key",
606
+ timestampUs: Math.round(frame.sampleNumber / sampleRate * US_PER_SEC2),
607
+ durationUs: Math.round(frame.blockSize / sampleRate * US_PER_SEC2),
608
+ data: frame.data
609
+ });
610
+ }
611
+ return {
612
+ kind: "encoded",
613
+ config: { codec: "flac", sampleRate, numberOfChannels, description },
614
+ chunks,
615
+ sampleRate,
616
+ numberOfChannels
617
+ };
618
+ }
619
+ function buildFlacDescription(info) {
620
+ const magic = new Uint8Array([102, 76, 97, 67]);
621
+ const header = encodeBlockHeader(true, BLOCK_TYPE_STREAMINFO, STREAMINFO_SIZE);
622
+ const body = encodeStreamInfo(info);
623
+ const out = new Uint8Array(magic.length + header.length + body.length);
624
+ out.set(magic, 0);
625
+ out.set(header, magic.length);
626
+ out.set(body, magic.length + header.length);
627
+ return out;
628
+ }
629
+ var OPUS_RATE2 = 48e3;
630
+ var OPUS_NOMINAL_FRAME = 960;
631
+ function demuxOpus(bytes) {
632
+ const { streams } = parseOgg(bytes);
633
+ const stream = streams[0];
634
+ if (!stream) throw new TranscodeDemuxError("ogg has no logical stream");
635
+ if (stream.codec !== "opus") {
636
+ throw new TranscodeDemuxError(
637
+ `ogg stream codec "${stream.codec}" is not decodable here (only opus-in-ogg is supported)`
638
+ );
639
+ }
640
+ const numberOfChannels = Math.max(1, stream.channels);
641
+ const chunks = [];
642
+ let sample = 0;
643
+ for (const pkt of stream.packets) {
644
+ let end = sample + OPUS_NOMINAL_FRAME;
645
+ if (pkt.granulePosition >= 0n) {
646
+ const g = Number(pkt.granulePosition);
647
+ if (g > sample) end = g;
648
+ }
649
+ const durationSamples = Math.max(1, end - sample);
650
+ chunks.push({
651
+ type: "key",
652
+ timestampUs: Math.round(sample / OPUS_RATE2 * US_PER_SEC2),
653
+ durationUs: Math.round(durationSamples / OPUS_RATE2 * US_PER_SEC2),
654
+ data: pkt.data
655
+ });
656
+ sample = end;
657
+ }
658
+ return {
659
+ kind: "encoded",
660
+ config: {
661
+ codec: "opus",
662
+ sampleRate: OPUS_RATE2,
663
+ numberOfChannels,
664
+ description: stream.identification
665
+ },
666
+ chunks,
667
+ sampleRate: OPUS_RATE2,
668
+ numberOfChannels
669
+ };
670
+ }
671
+ function encodeWav(decoded) {
672
+ const audioData = interleaveInt16(decoded);
673
+ const channels = Math.max(1, decoded.numberOfChannels);
674
+ const bitsPerSample = 16;
675
+ const blockAlign = channels * bitsPerSample / 8;
676
+ const file = {
677
+ format: {
678
+ audioFormat: 1,
679
+ channels,
680
+ sampleRate: decoded.sampleRate,
681
+ bitsPerSample,
682
+ blockAlign,
683
+ byteRate: decoded.sampleRate * blockAlign
684
+ },
685
+ audioData
686
+ };
687
+ return serializeWav(file);
688
+ }
689
+ async function encodeAudioChunks(decoded, config, ctx) {
690
+ throwIfAborted(ctx.signal);
691
+ const out = [];
692
+ let description;
693
+ const encoder = new WebCodecsAudioEncoder({ config }, (chunk, metadata) => {
694
+ const bytes = new Uint8Array(chunk.byteLength);
695
+ chunk.copyTo(bytes);
696
+ out.push({
697
+ data: bytes,
698
+ timestampUs: chunk.timestamp,
699
+ durationUs: chunk.duration ?? 0
700
+ });
701
+ const desc = metadata?.decoderConfig?.description;
702
+ if (description === void 0 && desc !== void 0) {
703
+ description = new Uint8Array(
704
+ desc instanceof ArrayBuffer ? desc : desc.buffer,
705
+ desc instanceof ArrayBuffer ? 0 : desc.byteOffset,
706
+ desc instanceof ArrayBuffer ? desc.byteLength : desc.byteLength
707
+ ).slice();
708
+ }
709
+ });
710
+ const onAbort = () => encoder.close();
711
+ ctx.signal?.addEventListener("abort", onAbort, { once: true });
712
+ try {
713
+ const framesPerChunk = Math.max(1, Math.round(decoded.sampleRate / 50));
714
+ const total = decoded.numberOfFrames || 1;
715
+ for (let start = 0; start < decoded.numberOfFrames; start += framesPerChunk) {
716
+ throwIfAborted(ctx.signal);
717
+ const count = Math.min(framesPerChunk, decoded.numberOfFrames - start);
718
+ const timestampUs = Math.round(start / decoded.sampleRate * 1e6);
719
+ encoder.encode(buildAudioData(decoded, start, count, timestampUs));
720
+ ctx.onProgress?.(Math.min(1, (start + count) / total));
721
+ }
722
+ await encoder.flush();
723
+ throwIfAborted(ctx.signal);
724
+ } catch (err) {
725
+ throw asCodecError(err, "encode");
726
+ } finally {
727
+ ctx.signal?.removeEventListener("abort", onAbort);
728
+ encoder.close();
729
+ }
730
+ return { chunks: out, description };
731
+ }
732
+ var OPUS_RATE3 = 48e3;
733
+ var OPUS_PRE_SKIP = 3840;
734
+ var OGG_SERIAL = 1;
735
+ var WEBM_TIMECODE_SCALE = 1e6;
736
+ var WEBM_CLUSTER_MAX_TICKS = 3e4;
737
+ function encodeOpusChunks(decoded, opts, ctx) {
738
+ return encodeOpus(decoded, opts, ctx);
739
+ }
740
+ async function encodeOpus(decoded, opts, ctx) {
741
+ const config = {
742
+ codec: "opus",
743
+ sampleRate: decoded.sampleRate,
744
+ numberOfChannels: Math.max(1, decoded.numberOfChannels),
745
+ ...opts.bitrate ? { bitrate: opts.bitrate } : {}
746
+ };
747
+ const { chunks } = await encodeAudioChunks(decoded, config, ctx);
748
+ return chunks;
749
+ }
750
+ async function encodeOpusOgg(decoded, opts, ctx) {
751
+ const channels = Math.max(1, decoded.numberOfChannels);
752
+ const chunks = await encodeOpus(decoded, opts, ctx);
753
+ const identification = buildOpusHead({
754
+ channelCount: channels,
755
+ preSkip: OPUS_PRE_SKIP,
756
+ inputSampleRate: decoded.sampleRate
757
+ });
758
+ const comments = buildOpusTags("webcvt-transcode", [
759
+ { key: "ENCODER", value: "webcvt-transcode" }
760
+ ]);
761
+ const packets = chunks.map((c) => ({
762
+ data: c.data,
763
+ // Opus granule = decoded 48 kHz samples (incl. pre-skip) at packet end.
764
+ granulePosition: BigInt(
765
+ Math.round((c.timestampUs + c.durationUs) / 1e6 * OPUS_RATE3) + OPUS_PRE_SKIP
766
+ ),
767
+ serialNumber: OGG_SERIAL
768
+ }));
769
+ const file = {
770
+ streams: [
771
+ {
772
+ serialNumber: OGG_SERIAL,
773
+ codec: "opus",
774
+ identification,
775
+ comments,
776
+ setup: void 0,
777
+ packets,
778
+ preSkip: OPUS_PRE_SKIP,
779
+ sampleRate: OPUS_RATE3,
780
+ channels
781
+ }
782
+ ]
783
+ };
784
+ return serializeOgg(file);
785
+ }
786
+ async function encodeOpusWebm(decoded, opts, ctx) {
787
+ const channels = Math.max(1, decoded.numberOfChannels);
788
+ const chunks = await encodeOpus(decoded, opts, ctx);
789
+ const codecPrivate = buildOpusHead({
790
+ channelCount: channels,
791
+ preSkip: OPUS_PRE_SKIP,
792
+ inputSampleRate: decoded.sampleRate
793
+ });
794
+ const clusters = [];
795
+ let current = null;
796
+ for (const c of chunks) {
797
+ const tick = BigInt(Math.round(c.timestampUs / 1e3));
798
+ if (current === null || tick - current.timecode > BigInt(WEBM_CLUSTER_MAX_TICKS)) {
799
+ current = { fileOffset: 0, timecode: tick, blocks: [] };
800
+ clusters.push(current);
801
+ }
802
+ current.blocks.push({
803
+ trackNumber: 1,
804
+ timestampNs: tick * BigInt(WEBM_TIMECODE_SCALE),
805
+ keyframe: true,
806
+ invisible: false,
807
+ discardable: false,
808
+ frames: [c.data]
809
+ });
810
+ }
811
+ const track = {
812
+ trackNumber: 1,
813
+ trackUid: 1n,
814
+ trackType: 2,
815
+ codecId: "A_OPUS",
816
+ codecPrivate,
817
+ samplingFrequency: OPUS_RATE3,
818
+ channels
819
+ };
820
+ const file = {
821
+ ebmlHeader: {
822
+ ebmlVersion: 1,
823
+ ebmlReadVersion: 1,
824
+ ebmlMaxIdLength: 4,
825
+ ebmlMaxSizeLength: 8,
826
+ docType: "webm",
827
+ docTypeVersion: 4,
828
+ docTypeReadVersion: 2
829
+ },
830
+ segmentPayloadOffset: 0,
831
+ // placeholder — ignored on write.
832
+ info: {
833
+ timecodeScale: WEBM_TIMECODE_SCALE,
834
+ muxingApp: "webcvt-transcode",
835
+ writingApp: "webcvt-transcode"
836
+ },
837
+ tracks: [track],
838
+ clusters,
839
+ fileBytes: new Uint8Array(0)
840
+ // placeholder — ignored on write.
841
+ };
842
+ return serializeWebm(file);
843
+ }
844
+ async function encodeAac(decoded, opts, ctx) {
845
+ const config = {
846
+ codec: "mp4a.40.2",
847
+ sampleRate: decoded.sampleRate,
848
+ numberOfChannels: Math.max(1, decoded.numberOfChannels),
849
+ ...opts.bitrate ? { bitrate: opts.bitrate } : {},
850
+ // Ask the encoder for self-framed ADTS output so concatenation is a valid
851
+ // .aac file (no container muxer needed).
852
+ aac: { format: "adts" }
853
+ };
854
+ const { chunks } = await encodeAudioChunks(decoded, config, ctx);
855
+ return concatBytes(chunks.map((c) => c.data));
856
+ }
857
+ async function encodeFlac(decoded, _opts, ctx) {
858
+ const config = {
859
+ codec: "flac",
860
+ sampleRate: decoded.sampleRate,
861
+ numberOfChannels: Math.max(1, decoded.numberOfChannels)
862
+ };
863
+ const { chunks, description } = await encodeAudioChunks(decoded, config, ctx);
864
+ if (description === void 0) {
865
+ throw new TranscodeCodecError(
866
+ "flac encoder did not supply a stream header (decoderConfig.description); cannot mux FLAC"
867
+ );
868
+ }
869
+ return concatBytes([description, ...chunks.map((c) => c.data)]);
870
+ }
871
+ function concatBytes(parts) {
872
+ const total = parts.reduce((s, p) => s + p.length, 0);
873
+ const out = new Uint8Array(total);
874
+ let off = 0;
875
+ for (const p of parts) {
876
+ out.set(p, off);
877
+ off += p.length;
878
+ }
879
+ return out;
880
+ }
881
+
882
+ // src/matrix.ts
883
+ var INPUT_CODECS = Object.freeze({
884
+ "audio/wav": "pcm",
885
+ "audio/x-wav": "pcm",
886
+ "audio/wave": "pcm",
887
+ "audio/mpeg": "mp3",
888
+ "audio/mp3": "mp3",
889
+ "audio/x-mpeg": "mp3",
890
+ "audio/aac": "aac",
891
+ "audio/x-aac": "aac",
892
+ "audio/ogg": "opus",
893
+ "audio/opus": "opus",
894
+ "audio/flac": "flac",
895
+ "audio/x-flac": "flac"
896
+ });
897
+ var OUTPUT_TARGETS = Object.freeze({
898
+ "audio/wav": { container: "wav", codec: "pcm" },
899
+ "audio/x-wav": { container: "wav", codec: "pcm" },
900
+ "audio/wave": { container: "wav", codec: "pcm" },
901
+ "audio/ogg": { container: "ogg", codec: "opus" },
902
+ "audio/opus": { container: "ogg", codec: "opus" },
903
+ "audio/webm": { container: "webm", codec: "opus" },
904
+ "audio/aac": { container: "aac", codec: "aac" },
905
+ "audio/flac": { container: "flac", codec: "flac" }
906
+ });
907
+ var TRANSCODE_MATRIX = Object.freeze(
908
+ new Set(
909
+ Object.keys(INPUT_CODECS).flatMap(
910
+ (inMime) => Object.keys(OUTPUT_TARGETS).map((outMime) => `${inMime}|${outMime}`)
911
+ )
912
+ )
913
+ );
914
+ function matrixKey(inputMime, outputMime) {
915
+ return `${inputMime}|${outputMime}`;
916
+ }
917
+ function inputCodecFor(mime) {
918
+ return INPUT_CODECS[mime];
919
+ }
920
+ function outputTargetFor(mime) {
921
+ return OUTPUT_TARGETS[mime];
922
+ }
923
+ var CONTAINER_INPUTS = Object.freeze({
924
+ "video/mp4": "mp4",
925
+ "audio/mp4": "mp4",
926
+ // m4a — audio-only track extraction
927
+ "video/webm": "webm",
928
+ "audio/webm": "webm",
929
+ // webm audio-only extraction
930
+ "video/x-matroska": "mkv",
931
+ "video/mkv": "mkv"
932
+ });
933
+ var VIDEO_TARGETS = Object.freeze({
934
+ "video/webm": { container: "webm" },
935
+ "video/x-matroska": { container: "mkv" },
936
+ "video/mkv": { container: "mkv" }
937
+ });
938
+ var CONTAINER_AUDIO_CODEC = Object.freeze({ mp4: "aac", webm: "opus", mkv: "opus" });
939
+ var CONTAINER_VIDEO_CODEC = Object.freeze({ mp4: "h264", webm: "vp9", mkv: "h264" });
940
+ function containerFamilyFor(mime) {
941
+ return CONTAINER_INPUTS[mime];
942
+ }
943
+ function videoTargetFor(mime) {
944
+ return VIDEO_TARGETS[mime];
945
+ }
946
+ var DEFAULT_QUALITY = 0.7;
947
+ var MAX_INPUT_BYTES = 256 * 1024 * 1024;
948
+ function resolveBitrate(codec, quality, channels) {
949
+ const q = clamp01(quality);
950
+ let stereo;
951
+ switch (codec) {
952
+ case "opus":
953
+ stereo = ladder(q, 64e3, 128e3, 256e3);
954
+ break;
955
+ case "aac":
956
+ stereo = ladder(q, 96e3, 128e3, 256e3);
957
+ break;
958
+ default:
959
+ return void 0;
960
+ }
961
+ const scale = channels <= 1 ? 0.6 : 1;
962
+ return Math.round(stereo * scale);
963
+ }
964
+ function resolveVideoBitrate(width, height, quality, codec) {
965
+ const q = clamp01(quality);
966
+ const pixels = Math.max(1, width) * Math.max(1, height);
967
+ const ratio = pixels / (1280 * 720);
968
+ const base = VP9_720P_BPS * ratio;
969
+ const scale = q <= DEFAULT_QUALITY ? 0.6 + 0.4 * q / DEFAULT_QUALITY : 1 + 0.4 * (q - DEFAULT_QUALITY) / (1 - DEFAULT_QUALITY);
970
+ const codecFactor = codec === "vp8" ? 1.3 : 1;
971
+ return Math.max(VIDEO_MIN_BPS, Math.round(base * scale * codecFactor));
972
+ }
973
+ var VP9_720P_BPS = 3e6;
974
+ var VIDEO_MIN_BPS = 1e5;
975
+ function clamp01(v) {
976
+ if (Number.isNaN(v)) return DEFAULT_QUALITY;
977
+ return Math.max(0, Math.min(1, v));
978
+ }
979
+ function ladder(q, lo, mid, hi) {
980
+ const KNEE = DEFAULT_QUALITY;
981
+ if (q <= KNEE) return lo + (mid - lo) * q / KNEE;
982
+ return mid + (hi - mid) * (q - KNEE) / (1 - KNEE);
983
+ }
984
+ var ProbeCache = class {
985
+ #decode = /* @__PURE__ */ new Map();
986
+ #encode = /* @__PURE__ */ new Map();
987
+ #videoDecode = /* @__PURE__ */ new Map();
988
+ #videoEncode = /* @__PURE__ */ new Map();
989
+ /** True iff the current runtime can DECODE `codec`. Memoised. */
990
+ async canDecode(codec, hints = {}) {
991
+ const cached = this.#decode.get(codec);
992
+ if (cached !== void 0) return cached;
993
+ const ok = await probeSafely(() => probeAudioDecoder({ codec, ...hints }));
994
+ this.#decode.set(codec, ok);
995
+ return ok;
996
+ }
997
+ /** True iff the current runtime can ENCODE `codec` (probeAudioCodec). Memoised. */
998
+ async canEncode(codec, hints = {}) {
999
+ const cached = this.#encode.get(codec);
1000
+ if (cached !== void 0) return cached;
1001
+ const ok = await probeSafely(() => probeAudioCodec({ codec, ...hints }));
1002
+ this.#encode.set(codec, ok);
1003
+ return ok;
1004
+ }
1005
+ /** True iff the current runtime can DECODE video `codec`. Memoised. */
1006
+ async canDecodeVideo(codec) {
1007
+ const cached = this.#videoDecode.get(codec);
1008
+ if (cached !== void 0) return cached;
1009
+ const ok = await probeSafely(() => probeVideoDecoder({ codec }));
1010
+ this.#videoDecode.set(codec, ok);
1011
+ return ok;
1012
+ }
1013
+ /**
1014
+ * Full encode probe for a video `codec` at the given geometry — returns the
1015
+ * {@link ProbeResult} (with `hardwareAccelerated`) or `null` when unsupported
1016
+ * / no WebCodecs. Memoised by codec + geometry so `canHandle` (default dims)
1017
+ * and the pipeline (real dims) each cache their own lookup.
1018
+ */
1019
+ async probeEncodeVideo(codec, hints = {}) {
1020
+ const key = `${codec}:${hints.width ?? 0}x${hints.height ?? 0}`;
1021
+ const cached = this.#videoEncode.get(key);
1022
+ if (cached !== void 0) return cached;
1023
+ let result;
1024
+ try {
1025
+ const r = await probeVideoCodec({ codec, ...hints });
1026
+ result = r.supported ? r : null;
1027
+ } catch (err) {
1028
+ if (err instanceof WebCodecsNotSupportedError) result = null;
1029
+ else throw err;
1030
+ }
1031
+ this.#videoEncode.set(key, result);
1032
+ return result;
1033
+ }
1034
+ /** True iff the current runtime can ENCODE video `codec`. Memoised. */
1035
+ async canEncodeVideo(codec, hints = {}) {
1036
+ return await this.probeEncodeVideo(codec, hints) !== null;
1037
+ }
1038
+ };
1039
+ async function probeSafely(run) {
1040
+ try {
1041
+ const result = await run();
1042
+ return result.supported;
1043
+ } catch (err) {
1044
+ if (err instanceof WebCodecsNotSupportedError) return false;
1045
+ throw err;
1046
+ }
1047
+ }
1048
+ var KEYFRAME_INTERVAL_US = 2e6;
1049
+ var WEBM_TIMECODE_SCALE2 = 1e6;
1050
+ var CLUSTER_MAX_TICKS = 3e4;
1051
+ var VIDEO_TRACK = 1;
1052
+ var AUDIO_TRACK = 2;
1053
+ async function transcodeVideo(family, outputContainer, bytes, probes, opts) {
1054
+ const { signal } = opts;
1055
+ const emit = monotone(opts.onProgress);
1056
+ emit(0, "demux");
1057
+ const demux = demuxContainerVideo(family, bytes);
1058
+ emit(5, "demux");
1059
+ throwIfAborted(signal);
1060
+ const { videoCodec, codecString, hardwareAccelerated } = await selectVideoEncoder(
1061
+ demux.video,
1062
+ probes,
1063
+ opts.hardwareAcceleration
1064
+ );
1065
+ const audioPlan = await planAudio(demux.audio, probes);
1066
+ const videoCap = audioPlan ? 60 : 90;
1067
+ emit(5, "decode");
1068
+ const videoOut = await encodeVideoTrack(demux, videoCodec, codecString, {
1069
+ signal,
1070
+ hardwareAcceleration: opts.hardwareAcceleration,
1071
+ quality: opts.quality,
1072
+ onEncoded: (fraction) => emit(5 + fraction * (videoCap - 5), "encode")
1073
+ });
1074
+ throwIfAborted(signal);
1075
+ let audioOut = null;
1076
+ let audioChannels = 2;
1077
+ if (audioPlan) {
1078
+ emit(videoCap, "decode");
1079
+ const decoded = await decodeToPcm(
1080
+ {
1081
+ kind: "encoded",
1082
+ config: audioPlan.track.config,
1083
+ chunks: audioPlan.track.chunks,
1084
+ sampleRate: audioPlan.track.sampleRate,
1085
+ numberOfChannels: audioPlan.track.numberOfChannels
1086
+ },
1087
+ { signal, onProgress: (f) => emit(videoCap + f * 10, "decode") }
1088
+ );
1089
+ throwIfAborted(signal);
1090
+ audioChannels = Math.max(1, decoded.numberOfChannels);
1091
+ audioOut = await encodeOpusChunks(
1092
+ decoded,
1093
+ {},
1094
+ { signal, onProgress: (f) => emit(70 + f * 20, "encode") }
1095
+ );
1096
+ throwIfAborted(signal);
1097
+ }
1098
+ emit(90, "mux");
1099
+ const outBytes = outputContainer === "mkv" ? buildMkv(
1100
+ demux.video.width,
1101
+ demux.video.height,
1102
+ videoCodec,
1103
+ videoOut,
1104
+ audioOut,
1105
+ audioChannels
1106
+ ) : buildWebm(
1107
+ demux.video.width,
1108
+ demux.video.height,
1109
+ videoCodec,
1110
+ videoOut,
1111
+ audioOut,
1112
+ audioChannels
1113
+ );
1114
+ throwIfAborted(signal);
1115
+ emit(100, "done");
1116
+ return { bytes: outBytes, hardwareAccelerated, audioIncluded: audioOut !== null };
1117
+ }
1118
+ async function selectVideoEncoder(video, probes, hardwareAcceleration) {
1119
+ const hints = {
1120
+ width: video.width,
1121
+ height: video.height,
1122
+ ...hardwareAcceleration ? { hardwareAcceleration } : {}
1123
+ };
1124
+ const vp9 = await probes.probeEncodeVideo("vp9", hints);
1125
+ if (vp9) {
1126
+ return {
1127
+ videoCodec: "vp9",
1128
+ codecString: "vp09.00.10.08",
1129
+ hardwareAccelerated: vp9.hardwareAccelerated
1130
+ };
1131
+ }
1132
+ const vp8 = await probes.probeEncodeVideo("vp8", hints);
1133
+ if (vp8) {
1134
+ return { videoCodec: "vp8", codecString: "vp8", hardwareAccelerated: vp8.hardwareAccelerated };
1135
+ }
1136
+ throw new TranscodeCodecError("no VP9/VP8 video encoder is supported in this runtime");
1137
+ }
1138
+ async function planAudio(audio, probes) {
1139
+ if (!audio) return null;
1140
+ const inputCodec = audioCodecName(audio.config.codec);
1141
+ if (!inputCodec) return null;
1142
+ if (!await probes.canDecode(inputCodec)) return null;
1143
+ if (!await probes.canEncode("opus")) return null;
1144
+ return { track: audio };
1145
+ }
1146
+ function audioCodecName(codec) {
1147
+ if (codec === "opus") return "opus";
1148
+ if (codec.startsWith("mp4a") || codec === "aac") return "aac";
1149
+ if (codec === "flac") return "flac";
1150
+ return null;
1151
+ }
1152
+ async function encodeVideoTrack(demux, videoCodec, codecString, ctx) {
1153
+ const { video } = demux;
1154
+ const { width, height } = video;
1155
+ const frameRate = video.frameRate > 0 ? video.frameRate : 30;
1156
+ const bitrate = resolveVideoBitrate(width, height, ctx.quality, videoCodec);
1157
+ const total = video.chunks.length || 1;
1158
+ const out = [];
1159
+ const encoder = new WebCodecsVideoEncoder(
1160
+ {
1161
+ config: {
1162
+ codec: codecString,
1163
+ width,
1164
+ height,
1165
+ bitrate,
1166
+ framerate: frameRate,
1167
+ ...ctx.hardwareAcceleration ? { hardwareAcceleration: ctx.hardwareAcceleration } : {}
1168
+ }
1169
+ },
1170
+ (chunk) => {
1171
+ const data = new Uint8Array(chunk.byteLength);
1172
+ chunk.copyTo(data);
1173
+ out.push({ data, timestampUs: chunk.timestamp, keyframe: chunk.type === "key" });
1174
+ ctx.onEncoded(Math.min(1, out.length / total));
1175
+ }
1176
+ );
1177
+ let frameIndex = 0;
1178
+ let lastKeyframeUs = Number.NEGATIVE_INFINITY;
1179
+ const decoder = new WebCodecsVideoDecoder({ config: video.config }, (frame) => {
1180
+ if (ctx.signal?.aborted) {
1181
+ frame.close();
1182
+ return;
1183
+ }
1184
+ const ts = frame.timestamp;
1185
+ const keyFrame = frameIndex === 0 || ts - lastKeyframeUs >= KEYFRAME_INTERVAL_US;
1186
+ if (keyFrame) lastKeyframeUs = ts;
1187
+ frameIndex++;
1188
+ encoder.encode(frame, { keyFrame });
1189
+ });
1190
+ const onAbort = () => {
1191
+ decoder.close();
1192
+ encoder.close();
1193
+ };
1194
+ ctx.signal?.addEventListener("abort", onAbort, { once: true });
1195
+ try {
1196
+ for (let i = 0; i < video.chunks.length; i++) {
1197
+ throwIfAborted(ctx.signal);
1198
+ const c = video.chunks[i];
1199
+ if (!c) continue;
1200
+ decoder.decode(
1201
+ new EncodedVideoChunk({
1202
+ type: c.type,
1203
+ timestamp: c.timestampUs,
1204
+ duration: c.durationUs,
1205
+ data: c.data
1206
+ })
1207
+ );
1208
+ }
1209
+ await decoder.flush();
1210
+ throwIfAborted(ctx.signal);
1211
+ await encoder.flush();
1212
+ throwIfAborted(ctx.signal);
1213
+ } catch (err) {
1214
+ throw asCodecError(err, "video");
1215
+ } finally {
1216
+ ctx.signal?.removeEventListener("abort", onAbort);
1217
+ decoder.close();
1218
+ encoder.close();
1219
+ }
1220
+ return out;
1221
+ }
1222
+ function buildClusters(videoOut, audioOut) {
1223
+ const blocks = [];
1224
+ for (const v of videoOut) {
1225
+ blocks.push({
1226
+ trackNumber: VIDEO_TRACK,
1227
+ timestampUs: v.timestampUs,
1228
+ keyframe: v.keyframe,
1229
+ data: v.data
1230
+ });
1231
+ }
1232
+ if (audioOut) {
1233
+ for (const a of audioOut) {
1234
+ blocks.push({
1235
+ trackNumber: AUDIO_TRACK,
1236
+ timestampUs: a.timestampUs,
1237
+ keyframe: true,
1238
+ data: a.data
1239
+ });
1240
+ }
1241
+ }
1242
+ blocks.sort((x, y) => x.timestampUs - y.timestampUs);
1243
+ const clusters = [];
1244
+ let current = null;
1245
+ for (const b of blocks) {
1246
+ const tick = BigInt(Math.round(b.timestampUs / 1e3));
1247
+ const startNew = current === null || b.trackNumber === VIDEO_TRACK && b.keyframe || tick - current.timecode > BigInt(CLUSTER_MAX_TICKS);
1248
+ if (startNew) {
1249
+ current = { fileOffset: 0, timecode: tick, blocks: [] };
1250
+ clusters.push(current);
1251
+ }
1252
+ current?.blocks.push({
1253
+ trackNumber: b.trackNumber,
1254
+ timestampNs: tick * BigInt(WEBM_TIMECODE_SCALE2),
1255
+ keyframe: b.keyframe,
1256
+ invisible: false,
1257
+ discardable: false,
1258
+ frames: [b.data]
1259
+ });
1260
+ }
1261
+ return clusters;
1262
+ }
1263
+ function buildWebm(width, height, videoCodec, videoOut, audioOut, audioChannels) {
1264
+ const videoTrack = {
1265
+ trackNumber: VIDEO_TRACK,
1266
+ trackUid: 1n,
1267
+ trackType: 1,
1268
+ codecId: videoCodec === "vp8" ? "V_VP8" : "V_VP9",
1269
+ pixelWidth: width,
1270
+ pixelHeight: height
1271
+ };
1272
+ const tracks = [videoTrack];
1273
+ if (audioOut) {
1274
+ const audioTrack = {
1275
+ trackNumber: AUDIO_TRACK,
1276
+ trackUid: 2n,
1277
+ trackType: 2,
1278
+ codecId: "A_OPUS",
1279
+ codecPrivate: opusHead(audioChannels),
1280
+ samplingFrequency: OPUS_RATE3,
1281
+ channels: audioChannels
1282
+ };
1283
+ tracks.push(audioTrack);
1284
+ }
1285
+ const file = {
1286
+ ebmlHeader: {
1287
+ ebmlVersion: 1,
1288
+ ebmlReadVersion: 1,
1289
+ ebmlMaxIdLength: 4,
1290
+ ebmlMaxSizeLength: 8,
1291
+ docType: "webm",
1292
+ docTypeVersion: 4,
1293
+ docTypeReadVersion: 2
1294
+ },
1295
+ segmentPayloadOffset: 0,
1296
+ info: {
1297
+ timecodeScale: WEBM_TIMECODE_SCALE2,
1298
+ muxingApp: "webcvt-transcode",
1299
+ writingApp: "webcvt-transcode"
1300
+ },
1301
+ tracks,
1302
+ clusters: buildClusters(videoOut, audioOut),
1303
+ fileBytes: new Uint8Array(0)
1304
+ };
1305
+ return serializeWebm(file);
1306
+ }
1307
+ function buildMkv(width, height, videoCodec, videoOut, audioOut, audioChannels) {
1308
+ const videoTrack = {
1309
+ trackNumber: VIDEO_TRACK,
1310
+ trackUid: 1n,
1311
+ trackType: 1,
1312
+ codecId: videoCodec === "vp8" ? "V_VP8" : "V_VP9",
1313
+ pixelWidth: width,
1314
+ pixelHeight: height,
1315
+ webcodecsCodecString: videoCodec === "vp8" ? "vp8" : "vp09.00.10.08"
1316
+ };
1317
+ const tracks = [videoTrack];
1318
+ if (audioOut) {
1319
+ const audioTrack = {
1320
+ trackNumber: AUDIO_TRACK,
1321
+ trackUid: 2n,
1322
+ trackType: 2,
1323
+ codecId: "A_OPUS",
1324
+ codecPrivate: opusHead(audioChannels),
1325
+ samplingFrequency: OPUS_RATE3,
1326
+ channels: audioChannels,
1327
+ webcodecsCodecString: "opus"
1328
+ };
1329
+ tracks.push(audioTrack);
1330
+ }
1331
+ const file = {
1332
+ ebmlHeader: {
1333
+ ebmlVersion: 1,
1334
+ ebmlReadVersion: 1,
1335
+ ebmlMaxIdLength: 4,
1336
+ ebmlMaxSizeLength: 8,
1337
+ docType: "matroska",
1338
+ docTypeVersion: 4,
1339
+ docTypeReadVersion: 2
1340
+ },
1341
+ segmentPayloadOffset: 0,
1342
+ info: {
1343
+ timecodeScale: WEBM_TIMECODE_SCALE2,
1344
+ muxingApp: "webcvt-transcode",
1345
+ writingApp: "webcvt-transcode"
1346
+ },
1347
+ tracks,
1348
+ clusters: buildClusters(videoOut, audioOut),
1349
+ chapters: [],
1350
+ tags: [],
1351
+ fileBytes: new Uint8Array(0)
1352
+ };
1353
+ return serializeMkv(file);
1354
+ }
1355
+ function opusHead(channels) {
1356
+ return buildOpusHead({
1357
+ channelCount: channels,
1358
+ preSkip: OPUS_PRE_SKIP,
1359
+ inputSampleRate: OPUS_RATE3
1360
+ });
1361
+ }
1362
+ function monotone(onProgress) {
1363
+ let last = 0;
1364
+ return (percent, phase) => {
1365
+ const p = Math.max(last, Math.min(100, percent));
1366
+ last = p;
1367
+ onProgress?.(p, phase);
1368
+ };
1369
+ }
1370
+
1371
+ // src/backend.ts
1372
+ var DEFAULT_QUALITY2 = 0.7;
1373
+ var TranscodeBackend = class {
1374
+ name = "webcodecs-transcode";
1375
+ priority = 0;
1376
+ #probes = new ProbeCache();
1377
+ async canHandle(input, output) {
1378
+ if (TRANSCODE_MATRIX.has(matrixKey(input.mime, output.mime))) {
1379
+ return this.#canHandleAudioMatrix(input.mime, output.mime);
1380
+ }
1381
+ const family = containerFamilyFor(input.mime);
1382
+ if (family) return this.#canHandleContainer(family, input, output);
1383
+ return false;
1384
+ }
1385
+ /** Stage-2 both-sided probe for the frozen audio matrix. */
1386
+ async #canHandleAudioMatrix(inputMime, outputMime) {
1387
+ const inputCodec = inputCodecFor(inputMime);
1388
+ const target = outputTargetFor(outputMime);
1389
+ if (!inputCodec || !target) return false;
1390
+ if (inputCodec !== "pcm") {
1391
+ if (!await this.#probes.canDecode(asAudioCodec(inputCodec))) return false;
1392
+ }
1393
+ if (target.codec !== "pcm") {
1394
+ if (!await this.#probes.canEncode(asAudioCodec(target.codec))) return false;
1395
+ }
1396
+ return true;
1397
+ }
1398
+ /** Container input: gate a video-target or an audio-track-extraction pair. */
1399
+ async #canHandleContainer(family, input, output) {
1400
+ const videoTarget = videoTargetFor(output.mime);
1401
+ if (videoTarget && input.category === "video") {
1402
+ if (!await this.#probes.canDecodeVideo(CONTAINER_VIDEO_CODEC[family])) return false;
1403
+ const canVp9 = await this.#probes.canEncodeVideo("vp9");
1404
+ const canVp8 = canVp9 || await this.#probes.canEncodeVideo("vp8");
1405
+ return canVp9 || canVp8;
1406
+ }
1407
+ const target = outputTargetFor(output.mime);
1408
+ if (target && output.mime !== input.mime) {
1409
+ if (!await this.#probes.canDecode(CONTAINER_AUDIO_CODEC[family])) return false;
1410
+ if (target.codec !== "pcm") {
1411
+ if (!await this.#probes.canEncode(asAudioCodec(target.codec))) return false;
1412
+ }
1413
+ return true;
1414
+ }
1415
+ return false;
1416
+ }
1417
+ async convert(input, output, options) {
1418
+ const startMs = Date.now();
1419
+ if (input.size > MAX_INPUT_BYTES) {
1420
+ throw new TranscodeInputTooLargeError(input.size, MAX_INPUT_BYTES);
1421
+ }
1422
+ const signal = options.signal;
1423
+ throwIfAborted(signal);
1424
+ const inputMime = resolveInputMime(input, options);
1425
+ const inputCodec = inputCodecFor(inputMime);
1426
+ const target = outputTargetFor(output.mime);
1427
+ if (inputCodec && target && TRANSCODE_MATRIX.has(matrixKey(inputMime, output.mime))) {
1428
+ const bytes = new Uint8Array(await input.arrayBuffer());
1429
+ const outputBytes = await this.#runAudio(demuxAudio(inputCodec, bytes), target, options);
1430
+ return this.#audioResult(outputBytes, output, startMs);
1431
+ }
1432
+ const family = containerFamilyFor(inputMime);
1433
+ if (family) {
1434
+ const bytes = new Uint8Array(await input.arrayBuffer());
1435
+ const videoTarget = videoTargetFor(output.mime);
1436
+ if (videoTarget && output.category === "video") {
1437
+ return this.#convertVideo(family, videoTarget, bytes, output, options, startMs);
1438
+ }
1439
+ if (target) {
1440
+ const outputBytes = await this.#runAudio(
1441
+ demuxContainerAudio(family, bytes),
1442
+ target,
1443
+ options
1444
+ );
1445
+ return this.#audioResult(outputBytes, output, startMs);
1446
+ }
1447
+ }
1448
+ throw new TranscodeUnsupportedError(inputMime, output.mime);
1449
+ }
1450
+ /** Shared audio demux→decode→encode→mux for both audio-matrix and container-audio. */
1451
+ async #runAudio(demux, target, options) {
1452
+ const signal = options.signal;
1453
+ const report = (percent, phase) => options.onProgress?.({ percent, phase });
1454
+ report(10, "demux");
1455
+ throwIfAborted(signal);
1456
+ const decoded = await decodeToPcm(demux, {
1457
+ signal,
1458
+ onProgress: (f) => report(10 + f * 35, "decode")
1459
+ });
1460
+ report(45, "decode");
1461
+ throwIfAborted(signal);
1462
+ const quality = options.quality ?? DEFAULT_QUALITY2;
1463
+ const bitrate = resolveBitrate(target.codec, quality, decoded.numberOfChannels);
1464
+ const encodeCtx = {
1465
+ signal,
1466
+ onProgress: (f) => report(45 + f * 45, "encode")
1467
+ };
1468
+ const outputBytes = await runSink(target, decoded, { bitrate }, encodeCtx);
1469
+ report(90, "mux");
1470
+ throwIfAborted(signal);
1471
+ report(100, "done");
1472
+ return outputBytes;
1473
+ }
1474
+ #audioResult(outputBytes, output, startMs) {
1475
+ return {
1476
+ blob: new Blob([outputBytes.buffer], { type: output.mime }),
1477
+ format: output,
1478
+ durationMs: Date.now() - startMs,
1479
+ backend: this.name,
1480
+ hardwareAccelerated: false
1481
+ // audio decode/encode is always software today.
1482
+ };
1483
+ }
1484
+ /** Video transcode dispatch (mp4/webm/mkv → webm/mkv, VP9|VP8 + Opus). */
1485
+ async #convertVideo(family, videoTarget, bytes, output, options, startMs) {
1486
+ const result = await transcodeVideo(family, videoTarget.container, bytes, this.#probes, {
1487
+ quality: options.quality ?? DEFAULT_QUALITY2,
1488
+ signal: options.signal,
1489
+ ...options.hardwareAcceleration ? { hardwareAcceleration: mapHwAccel(options.hardwareAcceleration) } : {},
1490
+ onProgress: (percent, phase) => options.onProgress?.({ percent, phase })
1491
+ });
1492
+ if (!result.audioIncluded) {
1493
+ console.warn(
1494
+ "[webcvt-transcode] emitted a VIDEO-ONLY output: the source audio track could not be transcoded (no WebCodecs AudioDecoder/AudioEncoder, or an unsupported audio codec)."
1495
+ );
1496
+ }
1497
+ return {
1498
+ blob: new Blob([result.bytes.buffer], { type: output.mime }),
1499
+ format: output,
1500
+ durationMs: Date.now() - startMs,
1501
+ backend: this.name,
1502
+ hardwareAccelerated: result.hardwareAccelerated
1503
+ };
1504
+ }
1505
+ };
1506
+ function registerTranscodeBackend(registry = defaultRegistry) {
1507
+ const backend = new TranscodeBackend();
1508
+ registry.register(backend);
1509
+ return backend;
1510
+ }
1511
+ function runSink(target, decoded, opts, ctx) {
1512
+ switch (target.container) {
1513
+ case "wav":
1514
+ return encodeWav(decoded);
1515
+ case "ogg":
1516
+ return encodeOpusOgg(decoded, opts, ctx);
1517
+ case "webm":
1518
+ return encodeOpusWebm(decoded, opts, ctx);
1519
+ case "aac":
1520
+ return encodeAac(decoded, opts, ctx);
1521
+ case "flac":
1522
+ return encodeFlac(decoded, opts, ctx);
1523
+ default:
1524
+ throw new TranscodeUnsupportedError("audio", target.container);
1525
+ }
1526
+ }
1527
+ function resolveInputMime(input, options) {
1528
+ if (options.inputFormat !== void 0) {
1529
+ if (typeof options.inputFormat === "string") {
1530
+ if (options.inputFormat.includes("/")) return options.inputFormat;
1531
+ } else {
1532
+ return options.inputFormat.mime;
1533
+ }
1534
+ }
1535
+ return input.type;
1536
+ }
1537
+ function asAudioCodec(codec) {
1538
+ return codec;
1539
+ }
1540
+ function mapHwAccel(pref) {
1541
+ switch (pref) {
1542
+ case "no":
1543
+ return "prefer-software";
1544
+ case "preferred":
1545
+ case "required":
1546
+ return "prefer-hardware";
1547
+ default:
1548
+ return "no-preference";
1549
+ }
1550
+ }
1551
+
1552
+ export { CONTAINER_AUDIO_CODEC, CONTAINER_INPUTS, CONTAINER_VIDEO_CODEC, DEFAULT_QUALITY, INPUT_CODECS, MAX_INPUT_BYTES, OUTPUT_TARGETS, TRANSCODE_MATRIX, TranscodeBackend, TranscodeCodecError, TranscodeDemuxError, TranscodeInputTooLargeError, TranscodeMuxError, TranscodeUnsupportedError, VIDEO_TARGETS, containerFamilyFor, inputCodecFor, matrixKey, outputTargetFor, registerTranscodeBackend, resolveBitrate, resolveVideoBitrate, videoTargetFor };
1553
+ //# sourceMappingURL=index.js.map
1554
+ //# sourceMappingURL=index.js.map