@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/README.md +70 -0
- package/dist/index.cjs +1578 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +172 -0
- package/dist/index.d.ts +172 -0
- package/dist/index.js +1554 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
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
|