29217 lines
1.0 MiB
Plaintext
29217 lines
1.0 MiB
Plaintext
/*!
|
|
* Copyright (c) 2026-present, Vanilagy and contributors
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
*/
|
|
"use strict";
|
|
var Mediabunny = (() => {
|
|
var __create = Object.create;
|
|
var __defProp = Object.defineProperty;
|
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
var __getProtoOf = Object.getPrototypeOf;
|
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
var __commonJS = (cb, mod) => function __require() {
|
|
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
};
|
|
var __export = (target, all) => {
|
|
for (var name in all)
|
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
};
|
|
var __copyProps = (to, from, except, desc) => {
|
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
for (let key of __getOwnPropNames(from))
|
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
}
|
|
return to;
|
|
};
|
|
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
// If the importer is in node compatibility mode or this is not an ESM
|
|
// file that has been converted to a CommonJS file using a Babel-
|
|
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
mod
|
|
));
|
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
|
|
// (disabled):src/node
|
|
var require_node = __commonJS({
|
|
"(disabled):src/node"() {
|
|
}
|
|
});
|
|
|
|
// src/index.ts
|
|
var index_exports = {};
|
|
__export(index_exports, {
|
|
ADTS: () => ADTS,
|
|
ALL_FORMATS: () => ALL_FORMATS,
|
|
ALL_TRACK_TYPES: () => ALL_TRACK_TYPES,
|
|
AUDIO_CODECS: () => AUDIO_CODECS,
|
|
AdtsInputFormat: () => AdtsInputFormat,
|
|
AdtsOutputFormat: () => AdtsOutputFormat,
|
|
AttachedFile: () => AttachedFile,
|
|
AudioBufferSink: () => AudioBufferSink,
|
|
AudioBufferSource: () => AudioBufferSource,
|
|
AudioSample: () => AudioSample,
|
|
AudioSampleSink: () => AudioSampleSink,
|
|
AudioSampleSource: () => AudioSampleSource,
|
|
AudioSource: () => AudioSource,
|
|
BaseMediaSampleSink: () => BaseMediaSampleSink,
|
|
BlobSource: () => BlobSource,
|
|
BufferSource: () => BufferSource,
|
|
BufferTarget: () => BufferTarget,
|
|
CanvasSink: () => CanvasSink,
|
|
CanvasSource: () => CanvasSource,
|
|
Conversion: () => Conversion,
|
|
ConversionCanceledError: () => ConversionCanceledError,
|
|
CustomAudioDecoder: () => CustomAudioDecoder,
|
|
CustomAudioEncoder: () => CustomAudioEncoder,
|
|
CustomVideoDecoder: () => CustomVideoDecoder,
|
|
CustomVideoEncoder: () => CustomVideoEncoder,
|
|
EncodedAudioPacketSource: () => EncodedAudioPacketSource,
|
|
EncodedPacket: () => EncodedPacket,
|
|
EncodedPacketSink: () => EncodedPacketSink,
|
|
EncodedVideoPacketSource: () => EncodedVideoPacketSource,
|
|
FLAC: () => FLAC,
|
|
FilePathSource: () => FilePathSource,
|
|
FilePathTarget: () => FilePathTarget,
|
|
FlacInputFormat: () => FlacInputFormat,
|
|
FlacOutputFormat: () => FlacOutputFormat,
|
|
Input: () => Input,
|
|
InputAudioTrack: () => InputAudioTrack,
|
|
InputDisposedError: () => InputDisposedError,
|
|
InputFormat: () => InputFormat,
|
|
InputTrack: () => InputTrack,
|
|
InputVideoTrack: () => InputVideoTrack,
|
|
IsobmffInputFormat: () => IsobmffInputFormat,
|
|
IsobmffOutputFormat: () => IsobmffOutputFormat2,
|
|
MATROSKA: () => MATROSKA,
|
|
MP3: () => MP3,
|
|
MP4: () => MP4,
|
|
MPEG_TS: () => MPEG_TS,
|
|
MatroskaInputFormat: () => MatroskaInputFormat,
|
|
MediaSource: () => MediaSource,
|
|
MediaStreamAudioTrackSource: () => MediaStreamAudioTrackSource,
|
|
MediaStreamVideoTrackSource: () => MediaStreamVideoTrackSource,
|
|
MkvOutputFormat: () => MkvOutputFormat2,
|
|
MovOutputFormat: () => MovOutputFormat,
|
|
Mp3InputFormat: () => Mp3InputFormat,
|
|
Mp3OutputFormat: () => Mp3OutputFormat,
|
|
Mp4InputFormat: () => Mp4InputFormat,
|
|
Mp4OutputFormat: () => Mp4OutputFormat,
|
|
MpegTsInputFormat: () => MpegTsInputFormat,
|
|
MpegTsOutputFormat: () => MpegTsOutputFormat,
|
|
NON_PCM_AUDIO_CODECS: () => NON_PCM_AUDIO_CODECS,
|
|
NullTarget: () => NullTarget,
|
|
OGG: () => OGG,
|
|
OggInputFormat: () => OggInputFormat,
|
|
OggOutputFormat: () => OggOutputFormat,
|
|
Output: () => Output,
|
|
OutputFormat: () => OutputFormat,
|
|
PCM_AUDIO_CODECS: () => PCM_AUDIO_CODECS,
|
|
QTFF: () => QTFF,
|
|
QUALITY_HIGH: () => QUALITY_HIGH,
|
|
QUALITY_LOW: () => QUALITY_LOW,
|
|
QUALITY_MEDIUM: () => QUALITY_MEDIUM,
|
|
QUALITY_VERY_HIGH: () => QUALITY_VERY_HIGH,
|
|
QUALITY_VERY_LOW: () => QUALITY_VERY_LOW,
|
|
Quality: () => Quality,
|
|
QuickTimeInputFormat: () => QuickTimeInputFormat,
|
|
ReadableStreamSource: () => ReadableStreamSource,
|
|
RichImageData: () => RichImageData,
|
|
SUBTITLE_CODECS: () => SUBTITLE_CODECS,
|
|
Source: () => Source,
|
|
StreamSource: () => StreamSource,
|
|
StreamTarget: () => StreamTarget,
|
|
SubtitleSource: () => SubtitleSource,
|
|
Target: () => Target,
|
|
TextSubtitleSource: () => TextSubtitleSource,
|
|
UrlSource: () => UrlSource,
|
|
VIDEO_CODECS: () => VIDEO_CODECS,
|
|
VIDEO_SAMPLE_PIXEL_FORMATS: () => VIDEO_SAMPLE_PIXEL_FORMATS,
|
|
VideoSample: () => VideoSample,
|
|
VideoSampleColorSpace: () => VideoSampleColorSpace,
|
|
VideoSampleSink: () => VideoSampleSink,
|
|
VideoSampleSource: () => VideoSampleSource,
|
|
VideoSource: () => VideoSource,
|
|
WAVE: () => WAVE,
|
|
WEBM: () => WEBM,
|
|
WavOutputFormat: () => WavOutputFormat,
|
|
WaveInputFormat: () => WaveInputFormat,
|
|
WebMInputFormat: () => WebMInputFormat,
|
|
WebMOutputFormat: () => WebMOutputFormat,
|
|
canEncode: () => canEncode,
|
|
canEncodeAudio: () => canEncodeAudio,
|
|
canEncodeSubtitles: () => canEncodeSubtitles,
|
|
canEncodeVideo: () => canEncodeVideo,
|
|
getEncodableAudioCodecs: () => getEncodableAudioCodecs,
|
|
getEncodableCodecs: () => getEncodableCodecs,
|
|
getEncodableSubtitleCodecs: () => getEncodableSubtitleCodecs,
|
|
getEncodableVideoCodecs: () => getEncodableVideoCodecs,
|
|
getFirstEncodableAudioCodec: () => getFirstEncodableAudioCodec,
|
|
getFirstEncodableSubtitleCodec: () => getFirstEncodableSubtitleCodec,
|
|
getFirstEncodableVideoCodec: () => getFirstEncodableVideoCodec,
|
|
registerDecoder: () => registerDecoder,
|
|
registerEncoder: () => registerEncoder
|
|
});
|
|
|
|
// src/misc.ts
|
|
function assert(x) {
|
|
if (!x) {
|
|
throw new Error("Assertion failed.");
|
|
}
|
|
}
|
|
var normalizeRotation = (rotation) => {
|
|
const mappedRotation = (rotation % 360 + 360) % 360;
|
|
if (mappedRotation === 0 || mappedRotation === 90 || mappedRotation === 180 || mappedRotation === 270) {
|
|
return mappedRotation;
|
|
} else {
|
|
throw new Error(`Invalid rotation ${rotation}.`);
|
|
}
|
|
};
|
|
var last = (arr) => {
|
|
return arr && arr[arr.length - 1];
|
|
};
|
|
var isU32 = (value) => {
|
|
return value >= 0 && value < 2 ** 32;
|
|
};
|
|
var Bitstream = class _Bitstream {
|
|
constructor(bytes2) {
|
|
this.bytes = bytes2;
|
|
/** Current offset in bits. */
|
|
this.pos = 0;
|
|
}
|
|
seekToByte(byteOffset) {
|
|
this.pos = 8 * byteOffset;
|
|
}
|
|
readBit() {
|
|
const byteIndex = Math.floor(this.pos / 8);
|
|
const byte = this.bytes[byteIndex] ?? 0;
|
|
const bitIndex = 7 - (this.pos & 7);
|
|
const bit = (byte & 1 << bitIndex) >> bitIndex;
|
|
this.pos++;
|
|
return bit;
|
|
}
|
|
readBits(n) {
|
|
if (n === 1) {
|
|
return this.readBit();
|
|
}
|
|
let result = 0;
|
|
for (let i = 0; i < n; i++) {
|
|
result <<= 1;
|
|
result |= this.readBit();
|
|
}
|
|
return result;
|
|
}
|
|
writeBits(n, value) {
|
|
const end = this.pos + n;
|
|
for (let i = this.pos; i < end; i++) {
|
|
const byteIndex = Math.floor(i / 8);
|
|
let byte = this.bytes[byteIndex];
|
|
const bitIndex = 7 - (i & 7);
|
|
byte &= ~(1 << bitIndex);
|
|
byte |= (value & 1 << end - i - 1) >> end - i - 1 << bitIndex;
|
|
this.bytes[byteIndex] = byte;
|
|
}
|
|
this.pos = end;
|
|
}
|
|
readAlignedByte() {
|
|
if (this.pos % 8 !== 0) {
|
|
throw new Error("Bitstream is not byte-aligned.");
|
|
}
|
|
const byteIndex = this.pos / 8;
|
|
const byte = this.bytes[byteIndex] ?? 0;
|
|
this.pos += 8;
|
|
return byte;
|
|
}
|
|
skipBits(n) {
|
|
this.pos += n;
|
|
}
|
|
getBitsLeft() {
|
|
return this.bytes.length * 8 - this.pos;
|
|
}
|
|
clone() {
|
|
const clone = new _Bitstream(this.bytes);
|
|
clone.pos = this.pos;
|
|
return clone;
|
|
}
|
|
};
|
|
var readExpGolomb = (bitstream) => {
|
|
let leadingZeroBits = 0;
|
|
while (bitstream.readBits(1) === 0 && leadingZeroBits < 32) {
|
|
leadingZeroBits++;
|
|
}
|
|
if (leadingZeroBits >= 32) {
|
|
throw new Error("Invalid exponential-Golomb code.");
|
|
}
|
|
const result = (1 << leadingZeroBits) - 1 + bitstream.readBits(leadingZeroBits);
|
|
return result;
|
|
};
|
|
var readSignedExpGolomb = (bitstream) => {
|
|
const codeNum = readExpGolomb(bitstream);
|
|
return (codeNum & 1) === 0 ? -(codeNum >> 1) : codeNum + 1 >> 1;
|
|
};
|
|
var writeBits = (bytes2, start, end, value) => {
|
|
for (let i = start; i < end; i++) {
|
|
const byteIndex = Math.floor(i / 8);
|
|
let byte = bytes2[byteIndex];
|
|
const bitIndex = 7 - (i & 7);
|
|
byte &= ~(1 << bitIndex);
|
|
byte |= (value & 1 << end - i - 1) >> end - i - 1 << bitIndex;
|
|
bytes2[byteIndex] = byte;
|
|
}
|
|
};
|
|
var toUint8Array = (source) => {
|
|
if (source.constructor === Uint8Array) {
|
|
return source;
|
|
} else if (ArrayBuffer.isView(source)) {
|
|
return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
|
|
} else {
|
|
return new Uint8Array(source);
|
|
}
|
|
};
|
|
var toDataView = (source) => {
|
|
if (source.constructor === DataView) {
|
|
return source;
|
|
} else if (ArrayBuffer.isView(source)) {
|
|
return new DataView(source.buffer, source.byteOffset, source.byteLength);
|
|
} else {
|
|
return new DataView(source);
|
|
}
|
|
};
|
|
var textDecoder = /* @__PURE__ */ new TextDecoder();
|
|
var textEncoder = /* @__PURE__ */ new TextEncoder();
|
|
var isIso88591Compatible = (text) => {
|
|
for (let i = 0; i < text.length; i++) {
|
|
const code = text.charCodeAt(i);
|
|
if (code > 255) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
var invertObject = (object) => {
|
|
return Object.fromEntries(Object.entries(object).map(([key, value]) => [value, key]));
|
|
};
|
|
var COLOR_PRIMARIES_MAP = {
|
|
bt709: 1,
|
|
// ITU-R BT.709
|
|
bt470bg: 5,
|
|
// ITU-R BT.470BG
|
|
smpte170m: 6,
|
|
// ITU-R BT.601 525 - SMPTE 170M
|
|
bt2020: 9,
|
|
// ITU-R BT.202
|
|
smpte432: 12
|
|
// SMPTE EG 432-1
|
|
};
|
|
var COLOR_PRIMARIES_MAP_INVERSE = /* @__PURE__ */ invertObject(COLOR_PRIMARIES_MAP);
|
|
var TRANSFER_CHARACTERISTICS_MAP = {
|
|
"bt709": 1,
|
|
// ITU-R BT.709
|
|
"smpte170m": 6,
|
|
// SMPTE 170M
|
|
"linear": 8,
|
|
// Linear transfer characteristics
|
|
"iec61966-2-1": 13,
|
|
// IEC 61966-2-1
|
|
"pq": 16,
|
|
// Rec. ITU-R BT.2100-2 perceptual quantization (PQ) system
|
|
"hlg": 18
|
|
// Rec. ITU-R BT.2100-2 hybrid loggamma (HLG) system
|
|
};
|
|
var TRANSFER_CHARACTERISTICS_MAP_INVERSE = /* @__PURE__ */ invertObject(TRANSFER_CHARACTERISTICS_MAP);
|
|
var MATRIX_COEFFICIENTS_MAP = {
|
|
"rgb": 0,
|
|
// Identity
|
|
"bt709": 1,
|
|
// ITU-R BT.709
|
|
"bt470bg": 5,
|
|
// ITU-R BT.470BG
|
|
"smpte170m": 6,
|
|
// SMPTE 170M
|
|
"bt2020-ncl": 9
|
|
// ITU-R BT.2020-2 (non-constant luminance)
|
|
};
|
|
var MATRIX_COEFFICIENTS_MAP_INVERSE = /* @__PURE__ */ invertObject(MATRIX_COEFFICIENTS_MAP);
|
|
var colorSpaceIsComplete = (colorSpace) => {
|
|
return !!colorSpace && !!colorSpace.primaries && !!colorSpace.transfer && !!colorSpace.matrix && colorSpace.fullRange !== void 0;
|
|
};
|
|
var isAllowSharedBufferSource = (x) => {
|
|
return x instanceof ArrayBuffer || typeof SharedArrayBuffer !== "undefined" && x instanceof SharedArrayBuffer || ArrayBuffer.isView(x);
|
|
};
|
|
var AsyncMutex = class {
|
|
constructor() {
|
|
this.currentPromise = Promise.resolve();
|
|
this.pending = 0;
|
|
}
|
|
async acquire() {
|
|
let resolver;
|
|
const nextPromise = new Promise((resolve) => {
|
|
let resolved = false;
|
|
resolver = () => {
|
|
if (resolved) {
|
|
return;
|
|
}
|
|
resolve();
|
|
this.pending--;
|
|
resolved = true;
|
|
};
|
|
});
|
|
const currentPromiseAlias = this.currentPromise;
|
|
this.currentPromise = nextPromise;
|
|
this.pending++;
|
|
await currentPromiseAlias;
|
|
return resolver;
|
|
}
|
|
};
|
|
var bytesToHexString = (bytes2) => {
|
|
return [...bytes2].map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
};
|
|
var reverseBitsU32 = (x) => {
|
|
x = x >> 1 & 1431655765 | (x & 1431655765) << 1;
|
|
x = x >> 2 & 858993459 | (x & 858993459) << 2;
|
|
x = x >> 4 & 252645135 | (x & 252645135) << 4;
|
|
x = x >> 8 & 16711935 | (x & 16711935) << 8;
|
|
x = x >> 16 & 65535 | (x & 65535) << 16;
|
|
return x >>> 0;
|
|
};
|
|
var binarySearchExact = (arr, key, valueGetter) => {
|
|
let low = 0;
|
|
let high = arr.length - 1;
|
|
let ans = -1;
|
|
while (low <= high) {
|
|
const mid = low + high >> 1;
|
|
const midVal = valueGetter(arr[mid]);
|
|
if (midVal === key) {
|
|
ans = mid;
|
|
high = mid - 1;
|
|
} else if (midVal < key) {
|
|
low = mid + 1;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
return ans;
|
|
};
|
|
var binarySearchLessOrEqual = (arr, key, valueGetter) => {
|
|
let low = 0;
|
|
let high = arr.length - 1;
|
|
let ans = -1;
|
|
while (low <= high) {
|
|
const mid = low + (high - low + 1) / 2 | 0;
|
|
const midVal = valueGetter(arr[mid]);
|
|
if (midVal <= key) {
|
|
ans = mid;
|
|
low = mid + 1;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
return ans;
|
|
};
|
|
var insertSorted = (arr, item, valueGetter) => {
|
|
const insertionIndex = binarySearchLessOrEqual(arr, valueGetter(item), valueGetter);
|
|
arr.splice(insertionIndex + 1, 0, item);
|
|
};
|
|
var promiseWithResolvers = () => {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
};
|
|
var findLast = (arr, predicate) => {
|
|
for (let i = arr.length - 1; i >= 0; i--) {
|
|
if (predicate(arr[i])) {
|
|
return arr[i];
|
|
}
|
|
}
|
|
return void 0;
|
|
};
|
|
var findLastIndex = (arr, predicate) => {
|
|
for (let i = arr.length - 1; i >= 0; i--) {
|
|
if (predicate(arr[i])) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
};
|
|
var toAsyncIterator = async function* (source) {
|
|
if (Symbol.iterator in source) {
|
|
yield* source[Symbol.iterator]();
|
|
} else {
|
|
yield* source[Symbol.asyncIterator]();
|
|
}
|
|
};
|
|
var validateAnyIterable = (iterable) => {
|
|
if (!(Symbol.iterator in iterable) && !(Symbol.asyncIterator in iterable)) {
|
|
throw new TypeError("Argument must be an iterable or async iterable.");
|
|
}
|
|
};
|
|
var assertNever = (x) => {
|
|
throw new Error(`Unexpected value: ${x}`);
|
|
};
|
|
var getUint24 = (view2, byteOffset, littleEndian) => {
|
|
const byte1 = view2.getUint8(byteOffset);
|
|
const byte2 = view2.getUint8(byteOffset + 1);
|
|
const byte3 = view2.getUint8(byteOffset + 2);
|
|
if (littleEndian) {
|
|
return byte1 | byte2 << 8 | byte3 << 16;
|
|
} else {
|
|
return byte1 << 16 | byte2 << 8 | byte3;
|
|
}
|
|
};
|
|
var getInt24 = (view2, byteOffset, littleEndian) => {
|
|
return getUint24(view2, byteOffset, littleEndian) << 8 >> 8;
|
|
};
|
|
var setUint24 = (view2, byteOffset, value, littleEndian) => {
|
|
value = value >>> 0;
|
|
value = value & 16777215;
|
|
if (littleEndian) {
|
|
view2.setUint8(byteOffset, value & 255);
|
|
view2.setUint8(byteOffset + 1, value >>> 8 & 255);
|
|
view2.setUint8(byteOffset + 2, value >>> 16 & 255);
|
|
} else {
|
|
view2.setUint8(byteOffset, value >>> 16 & 255);
|
|
view2.setUint8(byteOffset + 1, value >>> 8 & 255);
|
|
view2.setUint8(byteOffset + 2, value & 255);
|
|
}
|
|
};
|
|
var setInt24 = (view2, byteOffset, value, littleEndian) => {
|
|
value = clamp(value, -8388608, 8388607);
|
|
if (value < 0) {
|
|
value = value + 16777216 & 16777215;
|
|
}
|
|
setUint24(view2, byteOffset, value, littleEndian);
|
|
};
|
|
var setInt64 = (view2, byteOffset, value, littleEndian) => {
|
|
if (littleEndian) {
|
|
view2.setUint32(byteOffset + 0, value, true);
|
|
view2.setInt32(byteOffset + 4, Math.floor(value / 2 ** 32), true);
|
|
} else {
|
|
view2.setInt32(byteOffset + 0, Math.floor(value / 2 ** 32), true);
|
|
view2.setUint32(byteOffset + 4, value, true);
|
|
}
|
|
};
|
|
var mapAsyncGenerator = (generator, map) => {
|
|
return {
|
|
async next() {
|
|
const result = await generator.next();
|
|
if (result.done) {
|
|
return { value: void 0, done: true };
|
|
} else {
|
|
return { value: map(result.value), done: false };
|
|
}
|
|
},
|
|
return() {
|
|
return generator.return();
|
|
},
|
|
throw(error) {
|
|
return generator.throw(error);
|
|
},
|
|
[Symbol.asyncIterator]() {
|
|
return this;
|
|
}
|
|
};
|
|
};
|
|
var clamp = (value, min, max) => {
|
|
return Math.max(min, Math.min(max, value));
|
|
};
|
|
var UNDETERMINED_LANGUAGE = "und";
|
|
var roundIfAlmostInteger = (value) => {
|
|
const rounded = Math.round(value);
|
|
if (Math.abs(value / rounded - 1) < 10 * Number.EPSILON) {
|
|
return rounded;
|
|
} else {
|
|
return value;
|
|
}
|
|
};
|
|
var roundToMultiple = (value, multiple) => {
|
|
return Math.round(value / multiple) * multiple;
|
|
};
|
|
var floorToMultiple = (value, multiple) => {
|
|
return Math.floor(value / multiple) * multiple;
|
|
};
|
|
var ilog = (x) => {
|
|
let ret = 0;
|
|
while (x) {
|
|
ret++;
|
|
x >>= 1;
|
|
}
|
|
return ret;
|
|
};
|
|
var ISO_639_2_REGEX = /^[a-z]{3}$/;
|
|
var isIso639Dash2LanguageCode = (x) => {
|
|
return ISO_639_2_REGEX.test(x);
|
|
};
|
|
var SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);
|
|
var mergeRequestInit = (init1, init2) => {
|
|
const merged = { ...init1, ...init2 };
|
|
if (init1.headers || init2.headers) {
|
|
const headers1 = init1.headers ? normalizeHeaders(init1.headers) : {};
|
|
const headers2 = init2.headers ? normalizeHeaders(init2.headers) : {};
|
|
const mergedHeaders = { ...headers1 };
|
|
Object.entries(headers2).forEach(([key2, value2]) => {
|
|
const existingKey = Object.keys(mergedHeaders).find(
|
|
(key1) => key1.toLowerCase() === key2.toLowerCase()
|
|
);
|
|
if (existingKey) {
|
|
delete mergedHeaders[existingKey];
|
|
}
|
|
mergedHeaders[key2] = value2;
|
|
});
|
|
merged.headers = mergedHeaders;
|
|
}
|
|
return merged;
|
|
};
|
|
var normalizeHeaders = (headers) => {
|
|
if (headers instanceof Headers) {
|
|
const result = {};
|
|
headers.forEach((value, key) => {
|
|
result[key] = value;
|
|
});
|
|
return result;
|
|
}
|
|
if (Array.isArray(headers)) {
|
|
const result = {};
|
|
headers.forEach(([key, value]) => {
|
|
result[key] = value;
|
|
});
|
|
return result;
|
|
}
|
|
return headers;
|
|
};
|
|
var retriedFetch = async (fetchFn, url2, requestInit, getRetryDelay, shouldStop) => {
|
|
let attempts = 0;
|
|
while (true) {
|
|
try {
|
|
return await fetchFn(url2, requestInit);
|
|
} catch (error) {
|
|
if (shouldStop()) {
|
|
throw error;
|
|
}
|
|
attempts++;
|
|
const retryDelayInSeconds = getRetryDelay(attempts, error, url2);
|
|
if (retryDelayInSeconds === null) {
|
|
throw error;
|
|
}
|
|
console.error("Retrying failed fetch. Error:", error);
|
|
if (!Number.isFinite(retryDelayInSeconds) || retryDelayInSeconds < 0) {
|
|
throw new TypeError("Retry delay must be a non-negative finite number.");
|
|
}
|
|
if (retryDelayInSeconds > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1e3 * retryDelayInSeconds));
|
|
}
|
|
if (shouldStop()) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
var computeRationalApproximation = (x, maxDenominator) => {
|
|
const sign = x < 0 ? -1 : 1;
|
|
x = Math.abs(x);
|
|
let prevNumerator = 0, prevDenominator = 1;
|
|
let currNumerator = 1, currDenominator = 0;
|
|
let remainder = x;
|
|
while (true) {
|
|
const integer = Math.floor(remainder);
|
|
const nextNumerator = integer * currNumerator + prevNumerator;
|
|
const nextDenominator = integer * currDenominator + prevDenominator;
|
|
if (nextDenominator > maxDenominator) {
|
|
return {
|
|
numerator: sign * currNumerator,
|
|
denominator: currDenominator
|
|
};
|
|
}
|
|
prevNumerator = currNumerator;
|
|
prevDenominator = currDenominator;
|
|
currNumerator = nextNumerator;
|
|
currDenominator = nextDenominator;
|
|
remainder = 1 / (remainder - integer);
|
|
if (!isFinite(remainder)) {
|
|
break;
|
|
}
|
|
}
|
|
return {
|
|
numerator: sign * currNumerator,
|
|
denominator: currDenominator
|
|
};
|
|
};
|
|
var CallSerializer = class {
|
|
constructor() {
|
|
this.currentPromise = Promise.resolve();
|
|
}
|
|
call(fn) {
|
|
return this.currentPromise = this.currentPromise.then(fn);
|
|
}
|
|
};
|
|
var isWebKitCache = null;
|
|
var isWebKit = () => {
|
|
if (isWebKitCache !== null) {
|
|
return isWebKitCache;
|
|
}
|
|
return isWebKitCache = !!(typeof navigator !== "undefined" && (navigator.vendor?.match(/apple/i) || /AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent) || /\b(iPad|iPhone|iPod)\b/.test(navigator.userAgent)));
|
|
};
|
|
var isFirefoxCache = null;
|
|
var isFirefox = () => {
|
|
if (isFirefoxCache !== null) {
|
|
return isFirefoxCache;
|
|
}
|
|
return isFirefoxCache = typeof navigator !== "undefined" && navigator.userAgent?.includes("Firefox");
|
|
};
|
|
var isChromiumCache = null;
|
|
var isChromium = () => {
|
|
if (isChromiumCache !== null) {
|
|
return isChromiumCache;
|
|
}
|
|
return isChromiumCache = !!(typeof navigator !== "undefined" && (navigator.vendor?.includes("Google Inc") || /Chrome/.test(navigator.userAgent)));
|
|
};
|
|
var chromiumVersionCache = null;
|
|
var getChromiumVersion = () => {
|
|
if (chromiumVersionCache !== null) {
|
|
return chromiumVersionCache;
|
|
}
|
|
if (typeof navigator === "undefined") {
|
|
return null;
|
|
}
|
|
const match = /\bChrome\/(\d+)/.exec(navigator.userAgent);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return chromiumVersionCache = Number(match[1]);
|
|
};
|
|
var coalesceIndex = (a, b) => {
|
|
return a !== -1 ? a : b;
|
|
};
|
|
var closedIntervalsOverlap = (startA, endA, startB, endB) => {
|
|
return startA <= endB && startB <= endA;
|
|
};
|
|
var keyValueIterator = function* (object) {
|
|
for (const key in object) {
|
|
const value = object[key];
|
|
if (value === void 0) {
|
|
continue;
|
|
}
|
|
yield { key, value };
|
|
}
|
|
};
|
|
var imageMimeTypeToExtension = (mimeType) => {
|
|
switch (mimeType.toLowerCase()) {
|
|
case "image/jpeg":
|
|
case "image/jpg":
|
|
return ".jpg";
|
|
case "image/png":
|
|
return ".png";
|
|
case "image/gif":
|
|
return ".gif";
|
|
case "image/webp":
|
|
return ".webp";
|
|
case "image/bmp":
|
|
return ".bmp";
|
|
case "image/svg+xml":
|
|
return ".svg";
|
|
case "image/tiff":
|
|
return ".tiff";
|
|
case "image/avif":
|
|
return ".avif";
|
|
case "image/x-icon":
|
|
case "image/vnd.microsoft.icon":
|
|
return ".ico";
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
var base64ToBytes = (base64) => {
|
|
const decoded = atob(base64);
|
|
const bytes2 = new Uint8Array(decoded.length);
|
|
for (let i = 0; i < decoded.length; i++) {
|
|
bytes2[i] = decoded.charCodeAt(i);
|
|
}
|
|
return bytes2;
|
|
};
|
|
var bytesToBase64 = (bytes2) => {
|
|
let string = "";
|
|
for (let i = 0; i < bytes2.length; i++) {
|
|
string += String.fromCharCode(bytes2[i]);
|
|
}
|
|
return btoa(string);
|
|
};
|
|
var uint8ArraysAreEqual = (a, b) => {
|
|
if (a.length !== b.length) {
|
|
return false;
|
|
}
|
|
for (let i = 0; i < a.length; i++) {
|
|
if (a[i] !== b[i]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
var polyfillSymbolDispose = () => {
|
|
Symbol.dispose ??= Symbol("Symbol.dispose");
|
|
};
|
|
var isNumber = (x) => {
|
|
return typeof x === "number" && !Number.isNaN(x);
|
|
};
|
|
|
|
// src/metadata.ts
|
|
var RichImageData = class {
|
|
/** Creates a new {@link RichImageData}. */
|
|
constructor(data, mimeType) {
|
|
this.data = data;
|
|
this.mimeType = mimeType;
|
|
if (!(data instanceof Uint8Array)) {
|
|
throw new TypeError("data must be a Uint8Array.");
|
|
}
|
|
if (typeof mimeType !== "string") {
|
|
throw new TypeError("mimeType must be a string.");
|
|
}
|
|
}
|
|
};
|
|
var AttachedFile = class {
|
|
/** Creates a new {@link AttachedFile}. */
|
|
constructor(data, mimeType, name, description) {
|
|
this.data = data;
|
|
this.mimeType = mimeType;
|
|
this.name = name;
|
|
this.description = description;
|
|
if (!(data instanceof Uint8Array)) {
|
|
throw new TypeError("data must be a Uint8Array.");
|
|
}
|
|
if (mimeType !== void 0 && typeof mimeType !== "string") {
|
|
throw new TypeError("mimeType, when provided, must be a string.");
|
|
}
|
|
if (name !== void 0 && typeof name !== "string") {
|
|
throw new TypeError("name, when provided, must be a string.");
|
|
}
|
|
if (description !== void 0 && typeof description !== "string") {
|
|
throw new TypeError("description, when provided, must be a string.");
|
|
}
|
|
}
|
|
};
|
|
var validateMetadataTags = (tags) => {
|
|
if (!tags || typeof tags !== "object") {
|
|
throw new TypeError("tags must be an object.");
|
|
}
|
|
if (tags.title !== void 0 && typeof tags.title !== "string") {
|
|
throw new TypeError("tags.title, when provided, must be a string.");
|
|
}
|
|
if (tags.description !== void 0 && typeof tags.description !== "string") {
|
|
throw new TypeError("tags.description, when provided, must be a string.");
|
|
}
|
|
if (tags.artist !== void 0 && typeof tags.artist !== "string") {
|
|
throw new TypeError("tags.artist, when provided, must be a string.");
|
|
}
|
|
if (tags.album !== void 0 && typeof tags.album !== "string") {
|
|
throw new TypeError("tags.album, when provided, must be a string.");
|
|
}
|
|
if (tags.albumArtist !== void 0 && typeof tags.albumArtist !== "string") {
|
|
throw new TypeError("tags.albumArtist, when provided, must be a string.");
|
|
}
|
|
if (tags.trackNumber !== void 0 && (!Number.isInteger(tags.trackNumber) || tags.trackNumber <= 0)) {
|
|
throw new TypeError("tags.trackNumber, when provided, must be a positive integer.");
|
|
}
|
|
if (tags.tracksTotal !== void 0 && (!Number.isInteger(tags.tracksTotal) || tags.tracksTotal <= 0)) {
|
|
throw new TypeError("tags.tracksTotal, when provided, must be a positive integer.");
|
|
}
|
|
if (tags.discNumber !== void 0 && (!Number.isInteger(tags.discNumber) || tags.discNumber <= 0)) {
|
|
throw new TypeError("tags.discNumber, when provided, must be a positive integer.");
|
|
}
|
|
if (tags.discsTotal !== void 0 && (!Number.isInteger(tags.discsTotal) || tags.discsTotal <= 0)) {
|
|
throw new TypeError("tags.discsTotal, when provided, must be a positive integer.");
|
|
}
|
|
if (tags.genre !== void 0 && typeof tags.genre !== "string") {
|
|
throw new TypeError("tags.genre, when provided, must be a string.");
|
|
}
|
|
if (tags.date !== void 0 && (!(tags.date instanceof Date) || Number.isNaN(tags.date.getTime()))) {
|
|
throw new TypeError("tags.date, when provided, must be a valid Date.");
|
|
}
|
|
if (tags.lyrics !== void 0 && typeof tags.lyrics !== "string") {
|
|
throw new TypeError("tags.lyrics, when provided, must be a string.");
|
|
}
|
|
if (tags.images !== void 0) {
|
|
if (!Array.isArray(tags.images)) {
|
|
throw new TypeError("tags.images, when provided, must be an array.");
|
|
}
|
|
for (const image of tags.images) {
|
|
if (!image || typeof image !== "object") {
|
|
throw new TypeError("Each image in tags.images must be an object.");
|
|
}
|
|
if (!(image.data instanceof Uint8Array)) {
|
|
throw new TypeError("Each image.data must be a Uint8Array.");
|
|
}
|
|
if (typeof image.mimeType !== "string") {
|
|
throw new TypeError("Each image.mimeType must be a string.");
|
|
}
|
|
if (!["coverFront", "coverBack", "unknown"].includes(image.kind)) {
|
|
throw new TypeError("Each image.kind must be 'coverFront', 'coverBack', or 'unknown'.");
|
|
}
|
|
}
|
|
}
|
|
if (tags.comment !== void 0 && typeof tags.comment !== "string") {
|
|
throw new TypeError("tags.comment, when provided, must be a string.");
|
|
}
|
|
if (tags.raw !== void 0) {
|
|
if (!tags.raw || typeof tags.raw !== "object") {
|
|
throw new TypeError("tags.raw, when provided, must be an object.");
|
|
}
|
|
for (const value of Object.values(tags.raw)) {
|
|
if (value !== null && typeof value !== "string" && !(value instanceof Uint8Array) && !(value instanceof RichImageData) && !(value instanceof AttachedFile)) {
|
|
throw new TypeError(
|
|
"Each value in tags.raw must be a string, Uint8Array, RichImageData, AttachedFile, or null."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
var metadataTagsAreEmpty = (tags) => {
|
|
return tags.title === void 0 && tags.description === void 0 && tags.artist === void 0 && tags.album === void 0 && tags.albumArtist === void 0 && tags.trackNumber === void 0 && tags.tracksTotal === void 0 && tags.discNumber === void 0 && tags.discsTotal === void 0 && tags.genre === void 0 && tags.date === void 0 && tags.lyrics === void 0 && (!tags.images || tags.images.length === 0) && tags.comment === void 0 && (tags.raw === void 0 || Object.keys(tags.raw).length === 0);
|
|
};
|
|
var DEFAULT_TRACK_DISPOSITION = {
|
|
default: true,
|
|
forced: false,
|
|
original: false,
|
|
commentary: false,
|
|
hearingImpaired: false,
|
|
visuallyImpaired: false
|
|
};
|
|
var validateTrackDisposition = (disposition) => {
|
|
if (!disposition || typeof disposition !== "object") {
|
|
throw new TypeError("disposition must be an object.");
|
|
}
|
|
if (disposition.default !== void 0 && typeof disposition.default !== "boolean") {
|
|
throw new TypeError("disposition.default must be a boolean.");
|
|
}
|
|
if (disposition.forced !== void 0 && typeof disposition.forced !== "boolean") {
|
|
throw new TypeError("disposition.forced must be a boolean.");
|
|
}
|
|
if (disposition.original !== void 0 && typeof disposition.original !== "boolean") {
|
|
throw new TypeError("disposition.original must be a boolean.");
|
|
}
|
|
if (disposition.commentary !== void 0 && typeof disposition.commentary !== "boolean") {
|
|
throw new TypeError("disposition.commentary must be a boolean.");
|
|
}
|
|
if (disposition.hearingImpaired !== void 0 && typeof disposition.hearingImpaired !== "boolean") {
|
|
throw new TypeError("disposition.hearingImpaired must be a boolean.");
|
|
}
|
|
if (disposition.visuallyImpaired !== void 0 && typeof disposition.visuallyImpaired !== "boolean") {
|
|
throw new TypeError("disposition.visuallyImpaired must be a boolean.");
|
|
}
|
|
};
|
|
|
|
// src/codec.ts
|
|
var VIDEO_CODECS = [
|
|
"avc",
|
|
"hevc",
|
|
"vp9",
|
|
"av1",
|
|
"vp8"
|
|
];
|
|
var PCM_AUDIO_CODECS = [
|
|
"pcm-s16",
|
|
// We don't prefix 'le' so we're compatible with the WebCodecs-registered PCM codec strings
|
|
"pcm-s16be",
|
|
"pcm-s24",
|
|
"pcm-s24be",
|
|
"pcm-s32",
|
|
"pcm-s32be",
|
|
"pcm-f32",
|
|
"pcm-f32be",
|
|
"pcm-f64",
|
|
"pcm-f64be",
|
|
"pcm-u8",
|
|
"pcm-s8",
|
|
"ulaw",
|
|
"alaw"
|
|
];
|
|
var NON_PCM_AUDIO_CODECS = [
|
|
"aac",
|
|
"opus",
|
|
"mp3",
|
|
"vorbis",
|
|
"flac",
|
|
"ac3",
|
|
"eac3"
|
|
];
|
|
var AUDIO_CODECS = [
|
|
...NON_PCM_AUDIO_CODECS,
|
|
...PCM_AUDIO_CODECS
|
|
];
|
|
var SUBTITLE_CODECS = [
|
|
"webvtt"
|
|
];
|
|
var AVC_LEVEL_TABLE = [
|
|
{ maxMacroblocks: 99, maxBitrate: 64e3, maxDpbMbs: 396, level: 10 },
|
|
// Level 1
|
|
{ maxMacroblocks: 396, maxBitrate: 192e3, maxDpbMbs: 900, level: 11 },
|
|
// Level 1.1
|
|
{ maxMacroblocks: 396, maxBitrate: 384e3, maxDpbMbs: 2376, level: 12 },
|
|
// Level 1.2
|
|
{ maxMacroblocks: 396, maxBitrate: 768e3, maxDpbMbs: 2376, level: 13 },
|
|
// Level 1.3
|
|
{ maxMacroblocks: 396, maxBitrate: 2e6, maxDpbMbs: 2376, level: 20 },
|
|
// Level 2
|
|
{ maxMacroblocks: 792, maxBitrate: 4e6, maxDpbMbs: 4752, level: 21 },
|
|
// Level 2.1
|
|
{ maxMacroblocks: 1620, maxBitrate: 4e6, maxDpbMbs: 8100, level: 22 },
|
|
// Level 2.2
|
|
{ maxMacroblocks: 1620, maxBitrate: 1e7, maxDpbMbs: 8100, level: 30 },
|
|
// Level 3
|
|
{ maxMacroblocks: 3600, maxBitrate: 14e6, maxDpbMbs: 18e3, level: 31 },
|
|
// Level 3.1
|
|
{ maxMacroblocks: 5120, maxBitrate: 2e7, maxDpbMbs: 20480, level: 32 },
|
|
// Level 3.2
|
|
{ maxMacroblocks: 8192, maxBitrate: 2e7, maxDpbMbs: 32768, level: 40 },
|
|
// Level 4
|
|
{ maxMacroblocks: 8192, maxBitrate: 5e7, maxDpbMbs: 32768, level: 41 },
|
|
// Level 4.1
|
|
{ maxMacroblocks: 8704, maxBitrate: 5e7, maxDpbMbs: 34816, level: 42 },
|
|
// Level 4.2
|
|
{ maxMacroblocks: 22080, maxBitrate: 135e6, maxDpbMbs: 110400, level: 50 },
|
|
// Level 5
|
|
{ maxMacroblocks: 36864, maxBitrate: 24e7, maxDpbMbs: 184320, level: 51 },
|
|
// Level 5.1
|
|
{ maxMacroblocks: 36864, maxBitrate: 24e7, maxDpbMbs: 184320, level: 52 },
|
|
// Level 5.2
|
|
{ maxMacroblocks: 139264, maxBitrate: 24e7, maxDpbMbs: 696320, level: 60 },
|
|
// Level 6
|
|
{ maxMacroblocks: 139264, maxBitrate: 48e7, maxDpbMbs: 696320, level: 61 },
|
|
// Level 6.1
|
|
{ maxMacroblocks: 139264, maxBitrate: 8e8, maxDpbMbs: 696320, level: 62 }
|
|
// Level 6.2
|
|
];
|
|
var HEVC_LEVEL_TABLE = [
|
|
{ maxPictureSize: 36864, maxBitrate: 128e3, tier: "L", level: 30 },
|
|
// Level 1 (Low Tier)
|
|
{ maxPictureSize: 122880, maxBitrate: 15e5, tier: "L", level: 60 },
|
|
// Level 2 (Low Tier)
|
|
{ maxPictureSize: 245760, maxBitrate: 3e6, tier: "L", level: 63 },
|
|
// Level 2.1 (Low Tier)
|
|
{ maxPictureSize: 552960, maxBitrate: 6e6, tier: "L", level: 90 },
|
|
// Level 3 (Low Tier)
|
|
{ maxPictureSize: 983040, maxBitrate: 1e7, tier: "L", level: 93 },
|
|
// Level 3.1 (Low Tier)
|
|
{ maxPictureSize: 2228224, maxBitrate: 12e6, tier: "L", level: 120 },
|
|
// Level 4 (Low Tier)
|
|
{ maxPictureSize: 2228224, maxBitrate: 3e7, tier: "H", level: 120 },
|
|
// Level 4 (High Tier)
|
|
{ maxPictureSize: 2228224, maxBitrate: 2e7, tier: "L", level: 123 },
|
|
// Level 4.1 (Low Tier)
|
|
{ maxPictureSize: 2228224, maxBitrate: 5e7, tier: "H", level: 123 },
|
|
// Level 4.1 (High Tier)
|
|
{ maxPictureSize: 8912896, maxBitrate: 25e6, tier: "L", level: 150 },
|
|
// Level 5 (Low Tier)
|
|
{ maxPictureSize: 8912896, maxBitrate: 1e8, tier: "H", level: 150 },
|
|
// Level 5 (High Tier)
|
|
{ maxPictureSize: 8912896, maxBitrate: 4e7, tier: "L", level: 153 },
|
|
// Level 5.1 (Low Tier)
|
|
{ maxPictureSize: 8912896, maxBitrate: 16e7, tier: "H", level: 153 },
|
|
// Level 5.1 (High Tier)
|
|
{ maxPictureSize: 8912896, maxBitrate: 6e7, tier: "L", level: 156 },
|
|
// Level 5.2 (Low Tier)
|
|
{ maxPictureSize: 8912896, maxBitrate: 24e7, tier: "H", level: 156 },
|
|
// Level 5.2 (High Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 6e7, tier: "L", level: 180 },
|
|
// Level 6 (Low Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 24e7, tier: "H", level: 180 },
|
|
// Level 6 (High Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 12e7, tier: "L", level: 183 },
|
|
// Level 6.1 (Low Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 48e7, tier: "H", level: 183 },
|
|
// Level 6.1 (High Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 24e7, tier: "L", level: 186 },
|
|
// Level 6.2 (Low Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 8e8, tier: "H", level: 186 }
|
|
// Level 6.2 (High Tier)
|
|
];
|
|
var VP9_LEVEL_TABLE = [
|
|
{ maxPictureSize: 36864, maxBitrate: 2e5, level: 10 },
|
|
// Level 1
|
|
{ maxPictureSize: 73728, maxBitrate: 8e5, level: 11 },
|
|
// Level 1.1
|
|
{ maxPictureSize: 122880, maxBitrate: 18e5, level: 20 },
|
|
// Level 2
|
|
{ maxPictureSize: 245760, maxBitrate: 36e5, level: 21 },
|
|
// Level 2.1
|
|
{ maxPictureSize: 552960, maxBitrate: 72e5, level: 30 },
|
|
// Level 3
|
|
{ maxPictureSize: 983040, maxBitrate: 12e6, level: 31 },
|
|
// Level 3.1
|
|
{ maxPictureSize: 2228224, maxBitrate: 18e6, level: 40 },
|
|
// Level 4
|
|
{ maxPictureSize: 2228224, maxBitrate: 3e7, level: 41 },
|
|
// Level 4.1
|
|
{ maxPictureSize: 8912896, maxBitrate: 6e7, level: 50 },
|
|
// Level 5
|
|
{ maxPictureSize: 8912896, maxBitrate: 12e7, level: 51 },
|
|
// Level 5.1
|
|
{ maxPictureSize: 8912896, maxBitrate: 18e7, level: 52 },
|
|
// Level 5.2
|
|
{ maxPictureSize: 35651584, maxBitrate: 18e7, level: 60 },
|
|
// Level 6
|
|
{ maxPictureSize: 35651584, maxBitrate: 24e7, level: 61 },
|
|
// Level 6.1
|
|
{ maxPictureSize: 35651584, maxBitrate: 48e7, level: 62 }
|
|
// Level 6.2
|
|
];
|
|
var AV1_LEVEL_TABLE = [
|
|
{ maxPictureSize: 147456, maxBitrate: 15e5, tier: "M", level: 0 },
|
|
// Level 2.0 (Main Tier)
|
|
{ maxPictureSize: 278784, maxBitrate: 3e6, tier: "M", level: 1 },
|
|
// Level 2.1 (Main Tier)
|
|
{ maxPictureSize: 665856, maxBitrate: 6e6, tier: "M", level: 4 },
|
|
// Level 3.0 (Main Tier)
|
|
{ maxPictureSize: 1065024, maxBitrate: 1e7, tier: "M", level: 5 },
|
|
// Level 3.1 (Main Tier)
|
|
{ maxPictureSize: 2359296, maxBitrate: 12e6, tier: "M", level: 8 },
|
|
// Level 4.0 (Main Tier)
|
|
{ maxPictureSize: 2359296, maxBitrate: 3e7, tier: "H", level: 8 },
|
|
// Level 4.0 (High Tier)
|
|
{ maxPictureSize: 2359296, maxBitrate: 2e7, tier: "M", level: 9 },
|
|
// Level 4.1 (Main Tier)
|
|
{ maxPictureSize: 2359296, maxBitrate: 5e7, tier: "H", level: 9 },
|
|
// Level 4.1 (High Tier)
|
|
{ maxPictureSize: 8912896, maxBitrate: 3e7, tier: "M", level: 12 },
|
|
// Level 5.0 (Main Tier)
|
|
{ maxPictureSize: 8912896, maxBitrate: 1e8, tier: "H", level: 12 },
|
|
// Level 5.0 (High Tier)
|
|
{ maxPictureSize: 8912896, maxBitrate: 4e7, tier: "M", level: 13 },
|
|
// Level 5.1 (Main Tier)
|
|
{ maxPictureSize: 8912896, maxBitrate: 16e7, tier: "H", level: 13 },
|
|
// Level 5.1 (High Tier)
|
|
{ maxPictureSize: 8912896, maxBitrate: 6e7, tier: "M", level: 14 },
|
|
// Level 5.2 (Main Tier)
|
|
{ maxPictureSize: 8912896, maxBitrate: 24e7, tier: "H", level: 14 },
|
|
// Level 5.2 (High Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 6e7, tier: "M", level: 15 },
|
|
// Level 5.3 (Main Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 24e7, tier: "H", level: 15 },
|
|
// Level 5.3 (High Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 6e7, tier: "M", level: 16 },
|
|
// Level 6.0 (Main Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 24e7, tier: "H", level: 16 },
|
|
// Level 6.0 (High Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 1e8, tier: "M", level: 17 },
|
|
// Level 6.1 (Main Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 48e7, tier: "H", level: 17 },
|
|
// Level 6.1 (High Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 16e7, tier: "M", level: 18 },
|
|
// Level 6.2 (Main Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 8e8, tier: "H", level: 18 },
|
|
// Level 6.2 (High Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 16e7, tier: "M", level: 19 },
|
|
// Level 6.3 (Main Tier)
|
|
{ maxPictureSize: 35651584, maxBitrate: 8e8, tier: "H", level: 19 }
|
|
// Level 6.3 (High Tier)
|
|
];
|
|
var VP9_DEFAULT_SUFFIX = ".01.01.01.01.00";
|
|
var AV1_DEFAULT_SUFFIX = ".0.110.01.01.01.0";
|
|
var buildVideoCodecString = (codec, width, height, bitrate) => {
|
|
if (codec === "avc") {
|
|
const profileIndication = 100;
|
|
const totalMacroblocks = Math.ceil(width / 16) * Math.ceil(height / 16);
|
|
const levelInfo = AVC_LEVEL_TABLE.find(
|
|
(level) => totalMacroblocks <= level.maxMacroblocks && bitrate <= level.maxBitrate
|
|
) ?? last(AVC_LEVEL_TABLE);
|
|
const levelIndication = levelInfo ? levelInfo.level : 0;
|
|
const hexProfileIndication = profileIndication.toString(16).padStart(2, "0");
|
|
const hexProfileCompatibility = "00";
|
|
const hexLevelIndication = levelIndication.toString(16).padStart(2, "0");
|
|
return `avc1.${hexProfileIndication}${hexProfileCompatibility}${hexLevelIndication}`;
|
|
} else if (codec === "hevc") {
|
|
const profilePrefix = "";
|
|
const profileIdc = 1;
|
|
const compatibilityFlags = "6";
|
|
const pictureSize = width * height;
|
|
const levelInfo = HEVC_LEVEL_TABLE.find(
|
|
(level) => pictureSize <= level.maxPictureSize && bitrate <= level.maxBitrate
|
|
) ?? last(HEVC_LEVEL_TABLE);
|
|
const constraintFlags = "B0";
|
|
return `hev1.${profilePrefix}${profileIdc}.${compatibilityFlags}.${levelInfo.tier}${levelInfo.level}.${constraintFlags}`;
|
|
} else if (codec === "vp8") {
|
|
return "vp8";
|
|
} else if (codec === "vp9") {
|
|
const profile = "00";
|
|
const pictureSize = width * height;
|
|
const levelInfo = VP9_LEVEL_TABLE.find(
|
|
(level) => pictureSize <= level.maxPictureSize && bitrate <= level.maxBitrate
|
|
) ?? last(VP9_LEVEL_TABLE);
|
|
const bitDepth = "08";
|
|
return `vp09.${profile}.${levelInfo.level.toString().padStart(2, "0")}.${bitDepth}`;
|
|
} else if (codec === "av1") {
|
|
const profile = 0;
|
|
const pictureSize = width * height;
|
|
const levelInfo = AV1_LEVEL_TABLE.find(
|
|
(level2) => pictureSize <= level2.maxPictureSize && bitrate <= level2.maxBitrate
|
|
) ?? last(AV1_LEVEL_TABLE);
|
|
const level = levelInfo.level.toString().padStart(2, "0");
|
|
const bitDepth = "08";
|
|
return `av01.${profile}.${level}${levelInfo.tier}.${bitDepth}`;
|
|
}
|
|
throw new TypeError(`Unhandled codec '${codec}'.`);
|
|
};
|
|
var generateVp9CodecConfigurationFromCodecString = (codecString) => {
|
|
const parts = codecString.split(".");
|
|
const profile = Number(parts[1]);
|
|
const level = Number(parts[2]);
|
|
const bitDepth = Number(parts[3]);
|
|
const chromaSubsampling = parts[4] ? Number(parts[4]) : 1;
|
|
return [
|
|
1,
|
|
1,
|
|
profile,
|
|
2,
|
|
1,
|
|
level,
|
|
3,
|
|
1,
|
|
bitDepth,
|
|
4,
|
|
1,
|
|
chromaSubsampling
|
|
];
|
|
};
|
|
var generateAv1CodecConfigurationFromCodecString = (codecString) => {
|
|
const parts = codecString.split(".");
|
|
const marker = 1;
|
|
const version = 1;
|
|
const firstByte = (marker << 7) + version;
|
|
const profile = Number(parts[1]);
|
|
const levelAndTier = parts[2];
|
|
const level = Number(levelAndTier.slice(0, -1));
|
|
const secondByte = (profile << 5) + level;
|
|
const tier = levelAndTier.slice(-1) === "H" ? 1 : 0;
|
|
const bitDepth = Number(parts[3]);
|
|
const highBitDepth = bitDepth === 8 ? 0 : 1;
|
|
const twelveBit = 0;
|
|
const monochrome = parts[4] ? Number(parts[4]) : 0;
|
|
const chromaSubsamplingX = parts[5] ? Number(parts[5][0]) : 1;
|
|
const chromaSubsamplingY = parts[5] ? Number(parts[5][1]) : 1;
|
|
const chromaSamplePosition = parts[5] ? Number(parts[5][2]) : 0;
|
|
const thirdByte = (tier << 7) + (highBitDepth << 6) + (twelveBit << 5) + (monochrome << 4) + (chromaSubsamplingX << 3) + (chromaSubsamplingY << 2) + chromaSamplePosition;
|
|
const initialPresentationDelayPresent = 0;
|
|
const fourthByte = initialPresentationDelayPresent;
|
|
return [firstByte, secondByte, thirdByte, fourthByte];
|
|
};
|
|
var extractVideoCodecString = (trackInfo) => {
|
|
const { codec, codecDescription, colorSpace, avcCodecInfo, hevcCodecInfo, vp9CodecInfo, av1CodecInfo } = trackInfo;
|
|
if (codec === "avc") {
|
|
assert(trackInfo.avcType !== null);
|
|
if (avcCodecInfo) {
|
|
const bytes2 = new Uint8Array([
|
|
avcCodecInfo.avcProfileIndication,
|
|
avcCodecInfo.profileCompatibility,
|
|
avcCodecInfo.avcLevelIndication
|
|
]);
|
|
return `avc${trackInfo.avcType}.${bytesToHexString(bytes2)}`;
|
|
}
|
|
if (!codecDescription || codecDescription.byteLength < 4) {
|
|
throw new TypeError("AVC decoder description is not provided or is not at least 4 bytes long.");
|
|
}
|
|
return `avc${trackInfo.avcType}.${bytesToHexString(codecDescription.subarray(1, 4))}`;
|
|
} else if (codec === "hevc") {
|
|
let generalProfileSpace;
|
|
let generalProfileIdc;
|
|
let compatibilityFlags;
|
|
let generalTierFlag;
|
|
let generalLevelIdc;
|
|
let constraintFlags;
|
|
if (hevcCodecInfo) {
|
|
generalProfileSpace = hevcCodecInfo.generalProfileSpace;
|
|
generalProfileIdc = hevcCodecInfo.generalProfileIdc;
|
|
compatibilityFlags = reverseBitsU32(hevcCodecInfo.generalProfileCompatibilityFlags);
|
|
generalTierFlag = hevcCodecInfo.generalTierFlag;
|
|
generalLevelIdc = hevcCodecInfo.generalLevelIdc;
|
|
constraintFlags = [...hevcCodecInfo.generalConstraintIndicatorFlags];
|
|
} else {
|
|
if (!codecDescription || codecDescription.byteLength < 23) {
|
|
throw new TypeError("HEVC decoder description is not provided or is not at least 23 bytes long.");
|
|
}
|
|
const view2 = toDataView(codecDescription);
|
|
const profileByte = view2.getUint8(1);
|
|
generalProfileSpace = profileByte >> 6 & 3;
|
|
generalProfileIdc = profileByte & 31;
|
|
compatibilityFlags = reverseBitsU32(view2.getUint32(2));
|
|
generalTierFlag = profileByte >> 5 & 1;
|
|
generalLevelIdc = view2.getUint8(12);
|
|
constraintFlags = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
constraintFlags.push(view2.getUint8(6 + i));
|
|
}
|
|
}
|
|
let codecString = "hev1.";
|
|
codecString += ["", "A", "B", "C"][generalProfileSpace] + generalProfileIdc;
|
|
codecString += ".";
|
|
codecString += compatibilityFlags.toString(16).toUpperCase();
|
|
codecString += ".";
|
|
codecString += generalTierFlag === 0 ? "L" : "H";
|
|
codecString += generalLevelIdc;
|
|
while (constraintFlags.length > 0 && constraintFlags[constraintFlags.length - 1] === 0) {
|
|
constraintFlags.pop();
|
|
}
|
|
if (constraintFlags.length > 0) {
|
|
codecString += ".";
|
|
codecString += constraintFlags.map((x) => x.toString(16).toUpperCase()).join(".");
|
|
}
|
|
return codecString;
|
|
} else if (codec === "vp8") {
|
|
return "vp8";
|
|
} else if (codec === "vp9") {
|
|
if (!vp9CodecInfo) {
|
|
const pictureSize = trackInfo.width * trackInfo.height;
|
|
let level2 = last(VP9_LEVEL_TABLE).level;
|
|
for (const entry of VP9_LEVEL_TABLE) {
|
|
if (pictureSize <= entry.maxPictureSize) {
|
|
level2 = entry.level;
|
|
break;
|
|
}
|
|
}
|
|
return `vp09.00.${level2.toString().padStart(2, "0")}.08`;
|
|
}
|
|
const profile = vp9CodecInfo.profile.toString().padStart(2, "0");
|
|
const level = vp9CodecInfo.level.toString().padStart(2, "0");
|
|
const bitDepth = vp9CodecInfo.bitDepth.toString().padStart(2, "0");
|
|
const chromaSubsampling = vp9CodecInfo.chromaSubsampling.toString().padStart(2, "0");
|
|
const colourPrimaries = vp9CodecInfo.colourPrimaries.toString().padStart(2, "0");
|
|
const transferCharacteristics = vp9CodecInfo.transferCharacteristics.toString().padStart(2, "0");
|
|
const matrixCoefficients = vp9CodecInfo.matrixCoefficients.toString().padStart(2, "0");
|
|
const videoFullRangeFlag = vp9CodecInfo.videoFullRangeFlag.toString().padStart(2, "0");
|
|
let string = `vp09.${profile}.${level}.${bitDepth}.${chromaSubsampling}`;
|
|
string += `.${colourPrimaries}.${transferCharacteristics}.${matrixCoefficients}.${videoFullRangeFlag}`;
|
|
if (string.endsWith(VP9_DEFAULT_SUFFIX)) {
|
|
string = string.slice(0, -VP9_DEFAULT_SUFFIX.length);
|
|
}
|
|
return string;
|
|
} else if (codec === "av1") {
|
|
if (!av1CodecInfo) {
|
|
const pictureSize = trackInfo.width * trackInfo.height;
|
|
let level2 = last(VP9_LEVEL_TABLE).level;
|
|
for (const entry of VP9_LEVEL_TABLE) {
|
|
if (pictureSize <= entry.maxPictureSize) {
|
|
level2 = entry.level;
|
|
break;
|
|
}
|
|
}
|
|
return `av01.0.${level2.toString().padStart(2, "0")}M.08`;
|
|
}
|
|
const profile = av1CodecInfo.profile;
|
|
const level = av1CodecInfo.level.toString().padStart(2, "0");
|
|
const tier = av1CodecInfo.tier ? "H" : "M";
|
|
const bitDepth = av1CodecInfo.bitDepth.toString().padStart(2, "0");
|
|
const monochrome = av1CodecInfo.monochrome ? "1" : "0";
|
|
const chromaSubsampling = 100 * av1CodecInfo.chromaSubsamplingX + 10 * av1CodecInfo.chromaSubsamplingY + 1 * (av1CodecInfo.chromaSubsamplingX && av1CodecInfo.chromaSubsamplingY ? av1CodecInfo.chromaSamplePosition : 0);
|
|
const colorPrimaries = colorSpace?.primaries ? COLOR_PRIMARIES_MAP[colorSpace.primaries] : 1;
|
|
const transferCharacteristics = colorSpace?.transfer ? TRANSFER_CHARACTERISTICS_MAP[colorSpace.transfer] : 1;
|
|
const matrixCoefficients = colorSpace?.matrix ? MATRIX_COEFFICIENTS_MAP[colorSpace.matrix] : 1;
|
|
const videoFullRangeFlag = colorSpace?.fullRange ? 1 : 0;
|
|
let string = `av01.${profile}.${level}${tier}.${bitDepth}`;
|
|
string += `.${monochrome}.${chromaSubsampling.toString().padStart(3, "0")}`;
|
|
string += `.${colorPrimaries.toString().padStart(2, "0")}`;
|
|
string += `.${transferCharacteristics.toString().padStart(2, "0")}`;
|
|
string += `.${matrixCoefficients.toString().padStart(2, "0")}`;
|
|
string += `.${videoFullRangeFlag}`;
|
|
if (string.endsWith(AV1_DEFAULT_SUFFIX)) {
|
|
string = string.slice(0, -AV1_DEFAULT_SUFFIX.length);
|
|
}
|
|
return string;
|
|
}
|
|
throw new TypeError(`Unhandled codec '${codec}'.`);
|
|
};
|
|
var buildAudioCodecString = (codec, numberOfChannels, sampleRate) => {
|
|
if (codec === "aac") {
|
|
if (numberOfChannels >= 2 && sampleRate <= 24e3) {
|
|
return "mp4a.40.29";
|
|
}
|
|
if (sampleRate <= 24e3) {
|
|
return "mp4a.40.5";
|
|
}
|
|
return "mp4a.40.2";
|
|
} else if (codec === "mp3") {
|
|
return "mp3";
|
|
} else if (codec === "opus") {
|
|
return "opus";
|
|
} else if (codec === "vorbis") {
|
|
return "vorbis";
|
|
} else if (codec === "flac") {
|
|
return "flac";
|
|
} else if (codec === "ac3") {
|
|
return "ac-3";
|
|
} else if (codec === "eac3") {
|
|
return "ec-3";
|
|
} else if (PCM_AUDIO_CODECS.includes(codec)) {
|
|
return codec;
|
|
}
|
|
throw new TypeError(`Unhandled codec '${codec}'.`);
|
|
};
|
|
var extractAudioCodecString = (trackInfo) => {
|
|
const { codec, codecDescription, aacCodecInfo } = trackInfo;
|
|
if (codec === "aac") {
|
|
if (!aacCodecInfo) {
|
|
throw new TypeError("AAC codec info must be provided.");
|
|
}
|
|
if (aacCodecInfo.isMpeg2) {
|
|
return "mp4a.67";
|
|
} else {
|
|
let objectType;
|
|
if (aacCodecInfo.objectType !== null) {
|
|
objectType = aacCodecInfo.objectType;
|
|
} else {
|
|
const audioSpecificConfig = parseAacAudioSpecificConfig(codecDescription);
|
|
objectType = audioSpecificConfig.objectType;
|
|
}
|
|
return `mp4a.40.${objectType}`;
|
|
}
|
|
} else if (codec === "mp3") {
|
|
return "mp3";
|
|
} else if (codec === "opus") {
|
|
return "opus";
|
|
} else if (codec === "vorbis") {
|
|
return "vorbis";
|
|
} else if (codec === "flac") {
|
|
return "flac";
|
|
} else if (codec === "ac3") {
|
|
return "ac-3";
|
|
} else if (codec === "eac3") {
|
|
return "ec-3";
|
|
} else if (codec && PCM_AUDIO_CODECS.includes(codec)) {
|
|
return codec;
|
|
}
|
|
throw new TypeError(`Unhandled codec '${codec}'.`);
|
|
};
|
|
var aacFrequencyTable = [
|
|
96e3,
|
|
88200,
|
|
64e3,
|
|
48e3,
|
|
44100,
|
|
32e3,
|
|
24e3,
|
|
22050,
|
|
16e3,
|
|
12e3,
|
|
11025,
|
|
8e3,
|
|
7350
|
|
];
|
|
var aacChannelMap = [-1, 1, 2, 3, 4, 5, 6, 8];
|
|
var parseAacAudioSpecificConfig = (bytes2) => {
|
|
if (!bytes2 || bytes2.byteLength < 2) {
|
|
throw new TypeError("AAC description must be at least 2 bytes long.");
|
|
}
|
|
const bitstream = new Bitstream(bytes2);
|
|
let objectType = bitstream.readBits(5);
|
|
if (objectType === 31) {
|
|
objectType = 32 + bitstream.readBits(6);
|
|
}
|
|
const frequencyIndex = bitstream.readBits(4);
|
|
let sampleRate = null;
|
|
if (frequencyIndex === 15) {
|
|
sampleRate = bitstream.readBits(24);
|
|
} else {
|
|
if (frequencyIndex < aacFrequencyTable.length) {
|
|
sampleRate = aacFrequencyTable[frequencyIndex];
|
|
}
|
|
}
|
|
const channelConfiguration = bitstream.readBits(4);
|
|
let numberOfChannels = null;
|
|
if (channelConfiguration >= 1 && channelConfiguration <= 7) {
|
|
numberOfChannels = aacChannelMap[channelConfiguration];
|
|
}
|
|
return {
|
|
objectType,
|
|
frequencyIndex,
|
|
sampleRate,
|
|
channelConfiguration,
|
|
numberOfChannels
|
|
};
|
|
};
|
|
var buildAacAudioSpecificConfig = (config) => {
|
|
let frequencyIndex = aacFrequencyTable.indexOf(config.sampleRate);
|
|
let customSampleRate = null;
|
|
if (frequencyIndex === -1) {
|
|
frequencyIndex = 15;
|
|
customSampleRate = config.sampleRate;
|
|
}
|
|
const channelConfiguration = aacChannelMap.indexOf(config.numberOfChannels);
|
|
if (channelConfiguration === -1) {
|
|
throw new TypeError(`Unsupported number of channels: ${config.numberOfChannels}`);
|
|
}
|
|
let bitCount = 5 + 4 + 4;
|
|
if (config.objectType >= 32) {
|
|
bitCount += 6;
|
|
}
|
|
if (frequencyIndex === 15) {
|
|
bitCount += 24;
|
|
}
|
|
const byteCount = Math.ceil(bitCount / 8);
|
|
const bytes2 = new Uint8Array(byteCount);
|
|
const bitstream = new Bitstream(bytes2);
|
|
if (config.objectType < 32) {
|
|
bitstream.writeBits(5, config.objectType);
|
|
} else {
|
|
bitstream.writeBits(5, 31);
|
|
bitstream.writeBits(6, config.objectType - 32);
|
|
}
|
|
bitstream.writeBits(4, frequencyIndex);
|
|
if (frequencyIndex === 15) {
|
|
bitstream.writeBits(24, customSampleRate);
|
|
}
|
|
bitstream.writeBits(4, channelConfiguration);
|
|
return bytes2;
|
|
};
|
|
var OPUS_SAMPLE_RATE = 48e3;
|
|
var PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/;
|
|
var parsePcmCodec = (codec) => {
|
|
assert(PCM_AUDIO_CODECS.includes(codec));
|
|
if (codec === "ulaw") {
|
|
return { dataType: "ulaw", sampleSize: 1, littleEndian: true, silentValue: 255 };
|
|
} else if (codec === "alaw") {
|
|
return { dataType: "alaw", sampleSize: 1, littleEndian: true, silentValue: 213 };
|
|
}
|
|
const match = PCM_CODEC_REGEX.exec(codec);
|
|
assert(match);
|
|
let dataType;
|
|
if (match[1] === "u") {
|
|
dataType = "unsigned";
|
|
} else if (match[1] === "s") {
|
|
dataType = "signed";
|
|
} else {
|
|
dataType = "float";
|
|
}
|
|
const sampleSize = Number(match[2]) / 8;
|
|
const littleEndian = match[3] !== "be";
|
|
const silentValue = codec === "pcm-u8" ? 2 ** 7 : 0;
|
|
return { dataType, sampleSize, littleEndian, silentValue };
|
|
};
|
|
var inferCodecFromCodecString = (codecString) => {
|
|
if (codecString.startsWith("avc1") || codecString.startsWith("avc3")) {
|
|
return "avc";
|
|
} else if (codecString.startsWith("hev1") || codecString.startsWith("hvc1")) {
|
|
return "hevc";
|
|
} else if (codecString === "vp8") {
|
|
return "vp8";
|
|
} else if (codecString.startsWith("vp09")) {
|
|
return "vp9";
|
|
} else if (codecString.startsWith("av01")) {
|
|
return "av1";
|
|
}
|
|
if (codecString.startsWith("mp4a.40") || codecString === "mp4a.67") {
|
|
return "aac";
|
|
} else if (codecString === "mp3" || codecString === "mp4a.69" || codecString === "mp4a.6B" || codecString === "mp4a.6b") {
|
|
return "mp3";
|
|
} else if (codecString === "opus") {
|
|
return "opus";
|
|
} else if (codecString === "vorbis") {
|
|
return "vorbis";
|
|
} else if (codecString === "flac") {
|
|
return "flac";
|
|
} else if (codecString === "ac-3" || codecString === "ac3") {
|
|
return "ac3";
|
|
} else if (codecString === "ec-3" || codecString === "eac3") {
|
|
return "eac3";
|
|
} else if (codecString === "ulaw") {
|
|
return "ulaw";
|
|
} else if (codecString === "alaw") {
|
|
return "alaw";
|
|
} else if (PCM_CODEC_REGEX.test(codecString)) {
|
|
return codecString;
|
|
}
|
|
if (codecString === "webvtt") {
|
|
return "webvtt";
|
|
}
|
|
return null;
|
|
};
|
|
var getVideoEncoderConfigExtension = (codec) => {
|
|
if (codec === "avc") {
|
|
return {
|
|
avc: {
|
|
format: "avc"
|
|
// Ensure the format is not Annex B
|
|
}
|
|
};
|
|
} else if (codec === "hevc") {
|
|
return {
|
|
hevc: {
|
|
format: "hevc"
|
|
// Ensure the format is not Annex B
|
|
}
|
|
};
|
|
}
|
|
return {};
|
|
};
|
|
var getAudioEncoderConfigExtension = (codec) => {
|
|
if (codec === "aac") {
|
|
return {
|
|
aac: {
|
|
format: "aac"
|
|
// Ensure the format is not ADTS
|
|
}
|
|
};
|
|
} else if (codec === "opus") {
|
|
return {
|
|
opus: {
|
|
format: "opus"
|
|
}
|
|
};
|
|
}
|
|
return {};
|
|
};
|
|
var VALID_VIDEO_CODEC_STRING_PREFIXES = ["avc1", "avc3", "hev1", "hvc1", "vp8", "vp09", "av01"];
|
|
var AVC_CODEC_STRING_REGEX = /^(avc1|avc3)\.[0-9a-fA-F]{6}$/;
|
|
var HEVC_CODEC_STRING_REGEX = /^(hev1|hvc1)\.(?:[ABC]?\d+)\.[0-9a-fA-F]{1,8}\.[LH]\d+(?:\.[0-9a-fA-F]{1,2}){0,6}$/;
|
|
var VP9_CODEC_STRING_REGEX = /^vp09(?:\.\d{2}){3}(?:(?:\.\d{2}){5})?$/;
|
|
var AV1_CODEC_STRING_REGEX = /^av01\.\d\.\d{2}[MH]\.\d{2}(?:\.\d\.\d{3}\.\d{2}\.\d{2}\.\d{2}\.\d)?$/;
|
|
var validateVideoChunkMetadata = (metadata) => {
|
|
if (!metadata) {
|
|
throw new TypeError("Video chunk metadata must be provided.");
|
|
}
|
|
if (typeof metadata !== "object") {
|
|
throw new TypeError("Video chunk metadata must be an object.");
|
|
}
|
|
if (!metadata.decoderConfig) {
|
|
throw new TypeError("Video chunk metadata must include a decoder configuration.");
|
|
}
|
|
if (typeof metadata.decoderConfig !== "object") {
|
|
throw new TypeError("Video chunk metadata decoder configuration must be an object.");
|
|
}
|
|
if (typeof metadata.decoderConfig.codec !== "string") {
|
|
throw new TypeError("Video chunk metadata decoder configuration must specify a codec string.");
|
|
}
|
|
if (!VALID_VIDEO_CODEC_STRING_PREFIXES.some((prefix) => metadata.decoderConfig.codec.startsWith(prefix))) {
|
|
throw new TypeError(
|
|
"Video chunk metadata decoder configuration codec string must be a valid video codec string as specified in the Mediabunny Codec Registry."
|
|
);
|
|
}
|
|
if (!Number.isInteger(metadata.decoderConfig.codedWidth) || metadata.decoderConfig.codedWidth <= 0) {
|
|
throw new TypeError(
|
|
"Video chunk metadata decoder configuration must specify a valid codedWidth (positive integer)."
|
|
);
|
|
}
|
|
if (!Number.isInteger(metadata.decoderConfig.codedHeight) || metadata.decoderConfig.codedHeight <= 0) {
|
|
throw new TypeError(
|
|
"Video chunk metadata decoder configuration must specify a valid codedHeight (positive integer)."
|
|
);
|
|
}
|
|
if (metadata.decoderConfig.description !== void 0) {
|
|
if (!isAllowSharedBufferSource(metadata.decoderConfig.description)) {
|
|
throw new TypeError(
|
|
"Video chunk metadata decoder configuration description, when defined, must be an ArrayBuffer or an ArrayBuffer view."
|
|
);
|
|
}
|
|
}
|
|
if (metadata.decoderConfig.colorSpace !== void 0) {
|
|
const { colorSpace } = metadata.decoderConfig;
|
|
if (typeof colorSpace !== "object") {
|
|
throw new TypeError(
|
|
"Video chunk metadata decoder configuration colorSpace, when provided, must be an object."
|
|
);
|
|
}
|
|
const primariesValues = Object.keys(COLOR_PRIMARIES_MAP);
|
|
if (colorSpace.primaries != null && !primariesValues.includes(colorSpace.primaries)) {
|
|
throw new TypeError(
|
|
`Video chunk metadata decoder configuration colorSpace primaries, when defined, must be one of ${primariesValues.join(", ")}.`
|
|
);
|
|
}
|
|
const transferValues = Object.keys(TRANSFER_CHARACTERISTICS_MAP);
|
|
if (colorSpace.transfer != null && !transferValues.includes(colorSpace.transfer)) {
|
|
throw new TypeError(
|
|
`Video chunk metadata decoder configuration colorSpace transfer, when defined, must be one of ${transferValues.join(", ")}.`
|
|
);
|
|
}
|
|
const matrixValues = Object.keys(MATRIX_COEFFICIENTS_MAP);
|
|
if (colorSpace.matrix != null && !matrixValues.includes(colorSpace.matrix)) {
|
|
throw new TypeError(
|
|
`Video chunk metadata decoder configuration colorSpace matrix, when defined, must be one of ${matrixValues.join(", ")}.`
|
|
);
|
|
}
|
|
if (colorSpace.fullRange != null && typeof colorSpace.fullRange !== "boolean") {
|
|
throw new TypeError(
|
|
"Video chunk metadata decoder configuration colorSpace fullRange, when defined, must be a boolean."
|
|
);
|
|
}
|
|
}
|
|
if (metadata.decoderConfig.codec.startsWith("avc1") || metadata.decoderConfig.codec.startsWith("avc3")) {
|
|
if (!AVC_CODEC_STRING_REGEX.test(metadata.decoderConfig.codec)) {
|
|
throw new TypeError(
|
|
"Video chunk metadata decoder configuration codec string for AVC must be a valid AVC codec string as specified in Section 3.4 of RFC 6381."
|
|
);
|
|
}
|
|
} else if (metadata.decoderConfig.codec.startsWith("hev1") || metadata.decoderConfig.codec.startsWith("hvc1")) {
|
|
if (!HEVC_CODEC_STRING_REGEX.test(metadata.decoderConfig.codec)) {
|
|
throw new TypeError(
|
|
"Video chunk metadata decoder configuration codec string for HEVC must be a valid HEVC codec string as specified in Section E.3 of ISO 14496-15."
|
|
);
|
|
}
|
|
} else if (metadata.decoderConfig.codec.startsWith("vp8")) {
|
|
if (metadata.decoderConfig.codec !== "vp8") {
|
|
throw new TypeError('Video chunk metadata decoder configuration codec string for VP8 must be "vp8".');
|
|
}
|
|
} else if (metadata.decoderConfig.codec.startsWith("vp09")) {
|
|
if (!VP9_CODEC_STRING_REGEX.test(metadata.decoderConfig.codec)) {
|
|
throw new TypeError(
|
|
'Video chunk metadata decoder configuration codec string for VP9 must be a valid VP9 codec string as specified in Section "Codecs Parameter String" of https://www.webmproject.org/vp9/mp4/.'
|
|
);
|
|
}
|
|
} else if (metadata.decoderConfig.codec.startsWith("av01")) {
|
|
if (!AV1_CODEC_STRING_REGEX.test(metadata.decoderConfig.codec)) {
|
|
throw new TypeError(
|
|
'Video chunk metadata decoder configuration codec string for AV1 must be a valid AV1 codec string as specified in Section "Codecs Parameter String" of https://aomediacodec.github.io/av1-isobmff/.'
|
|
);
|
|
}
|
|
}
|
|
};
|
|
var VALID_AUDIO_CODEC_STRING_PREFIXES = [
|
|
"mp4a",
|
|
"mp3",
|
|
"opus",
|
|
"vorbis",
|
|
"flac",
|
|
"ulaw",
|
|
"alaw",
|
|
"pcm",
|
|
"ac-3",
|
|
"ec-3"
|
|
];
|
|
var validateAudioChunkMetadata = (metadata) => {
|
|
if (!metadata) {
|
|
throw new TypeError("Audio chunk metadata must be provided.");
|
|
}
|
|
if (typeof metadata !== "object") {
|
|
throw new TypeError("Audio chunk metadata must be an object.");
|
|
}
|
|
if (!metadata.decoderConfig) {
|
|
throw new TypeError("Audio chunk metadata must include a decoder configuration.");
|
|
}
|
|
if (typeof metadata.decoderConfig !== "object") {
|
|
throw new TypeError("Audio chunk metadata decoder configuration must be an object.");
|
|
}
|
|
if (typeof metadata.decoderConfig.codec !== "string") {
|
|
throw new TypeError("Audio chunk metadata decoder configuration must specify a codec string.");
|
|
}
|
|
if (!VALID_AUDIO_CODEC_STRING_PREFIXES.some((prefix) => metadata.decoderConfig.codec.startsWith(prefix))) {
|
|
throw new TypeError(
|
|
"Audio chunk metadata decoder configuration codec string must be a valid audio codec string as specified in the Mediabunny Codec Registry."
|
|
);
|
|
}
|
|
if (!Number.isInteger(metadata.decoderConfig.sampleRate) || metadata.decoderConfig.sampleRate <= 0) {
|
|
throw new TypeError(
|
|
"Audio chunk metadata decoder configuration must specify a valid sampleRate (positive integer)."
|
|
);
|
|
}
|
|
if (!Number.isInteger(metadata.decoderConfig.numberOfChannels) || metadata.decoderConfig.numberOfChannels <= 0) {
|
|
throw new TypeError(
|
|
"Audio chunk metadata decoder configuration must specify a valid numberOfChannels (positive integer)."
|
|
);
|
|
}
|
|
if (metadata.decoderConfig.description !== void 0) {
|
|
if (!isAllowSharedBufferSource(metadata.decoderConfig.description)) {
|
|
throw new TypeError(
|
|
"Audio chunk metadata decoder configuration description, when defined, must be an ArrayBuffer or an ArrayBuffer view."
|
|
);
|
|
}
|
|
}
|
|
if (metadata.decoderConfig.codec.startsWith("mp4a") && metadata.decoderConfig.codec !== "mp4a.69" && metadata.decoderConfig.codec !== "mp4a.6B" && metadata.decoderConfig.codec !== "mp4a.6b") {
|
|
const validStrings = ["mp4a.40.2", "mp4a.40.02", "mp4a.40.5", "mp4a.40.05", "mp4a.40.29", "mp4a.67"];
|
|
if (!validStrings.includes(metadata.decoderConfig.codec)) {
|
|
throw new TypeError(
|
|
"Audio chunk metadata decoder configuration codec string for AAC must be a valid AAC codec string as specified in https://www.w3.org/TR/webcodecs-aac-codec-registration/."
|
|
);
|
|
}
|
|
} else if (metadata.decoderConfig.codec.startsWith("mp3") || metadata.decoderConfig.codec.startsWith("mp4a")) {
|
|
if (metadata.decoderConfig.codec !== "mp3" && metadata.decoderConfig.codec !== "mp4a.69" && metadata.decoderConfig.codec !== "mp4a.6B" && metadata.decoderConfig.codec !== "mp4a.6b") {
|
|
throw new TypeError(
|
|
'Audio chunk metadata decoder configuration codec string for MP3 must be "mp3", "mp4a.69" or "mp4a.6B".'
|
|
);
|
|
}
|
|
} else if (metadata.decoderConfig.codec.startsWith("opus")) {
|
|
if (metadata.decoderConfig.codec !== "opus") {
|
|
throw new TypeError('Audio chunk metadata decoder configuration codec string for Opus must be "opus".');
|
|
}
|
|
if (metadata.decoderConfig.description && metadata.decoderConfig.description.byteLength < 18) {
|
|
throw new TypeError(
|
|
"Audio chunk metadata decoder configuration description, when specified, is expected to be an Identification Header as specified in Section 5.1 of RFC 7845."
|
|
);
|
|
}
|
|
} else if (metadata.decoderConfig.codec.startsWith("vorbis")) {
|
|
if (metadata.decoderConfig.codec !== "vorbis") {
|
|
throw new TypeError('Audio chunk metadata decoder configuration codec string for Vorbis must be "vorbis".');
|
|
}
|
|
if (!metadata.decoderConfig.description) {
|
|
throw new TypeError(
|
|
"Audio chunk metadata decoder configuration for Vorbis must include a description, which is expected to adhere to the format described in https://www.w3.org/TR/webcodecs-vorbis-codec-registration/."
|
|
);
|
|
}
|
|
} else if (metadata.decoderConfig.codec.startsWith("flac")) {
|
|
if (metadata.decoderConfig.codec !== "flac") {
|
|
throw new TypeError('Audio chunk metadata decoder configuration codec string for FLAC must be "flac".');
|
|
}
|
|
const minDescriptionSize = 4 + 4 + 34;
|
|
if (!metadata.decoderConfig.description || metadata.decoderConfig.description.byteLength < minDescriptionSize) {
|
|
throw new TypeError(
|
|
"Audio chunk metadata decoder configuration for FLAC must include a description, which is expected to adhere to the format described in https://www.w3.org/TR/webcodecs-flac-codec-registration/."
|
|
);
|
|
}
|
|
} else if (metadata.decoderConfig.codec.startsWith("ac-3") || metadata.decoderConfig.codec.startsWith("ac3")) {
|
|
if (metadata.decoderConfig.codec !== "ac-3") {
|
|
throw new TypeError('Audio chunk metadata decoder configuration codec string for AC-3 must be "ac-3".');
|
|
}
|
|
} else if (metadata.decoderConfig.codec.startsWith("ec-3") || metadata.decoderConfig.codec.startsWith("eac3")) {
|
|
if (metadata.decoderConfig.codec !== "ec-3") {
|
|
throw new TypeError('Audio chunk metadata decoder configuration codec string for EC-3 must be "ec-3".');
|
|
}
|
|
} else if (metadata.decoderConfig.codec.startsWith("pcm") || metadata.decoderConfig.codec.startsWith("ulaw") || metadata.decoderConfig.codec.startsWith("alaw")) {
|
|
if (!PCM_AUDIO_CODECS.includes(metadata.decoderConfig.codec)) {
|
|
throw new TypeError(
|
|
`Audio chunk metadata decoder configuration codec string for PCM must be one of the supported PCM codecs (${PCM_AUDIO_CODECS.join(", ")}).`
|
|
);
|
|
}
|
|
}
|
|
};
|
|
var validateSubtitleMetadata = (metadata) => {
|
|
if (!metadata) {
|
|
throw new TypeError("Subtitle metadata must be provided.");
|
|
}
|
|
if (typeof metadata !== "object") {
|
|
throw new TypeError("Subtitle metadata must be an object.");
|
|
}
|
|
if (!metadata.config) {
|
|
throw new TypeError("Subtitle metadata must include a config object.");
|
|
}
|
|
if (typeof metadata.config !== "object") {
|
|
throw new TypeError("Subtitle metadata config must be an object.");
|
|
}
|
|
if (typeof metadata.config.description !== "string") {
|
|
throw new TypeError("Subtitle metadata config description must be a string.");
|
|
}
|
|
};
|
|
|
|
// shared/mp3-misc.ts
|
|
var FRAME_HEADER_SIZE = 4;
|
|
var SAMPLING_RATES = [44100, 48e3, 32e3];
|
|
var KILOBIT_RATES = [
|
|
// lowSamplingFrequency === 0
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
// layer = 0
|
|
-1,
|
|
32,
|
|
40,
|
|
48,
|
|
56,
|
|
64,
|
|
80,
|
|
96,
|
|
112,
|
|
128,
|
|
160,
|
|
192,
|
|
224,
|
|
256,
|
|
320,
|
|
-1,
|
|
// layer 1
|
|
-1,
|
|
32,
|
|
48,
|
|
56,
|
|
64,
|
|
80,
|
|
96,
|
|
112,
|
|
128,
|
|
160,
|
|
192,
|
|
224,
|
|
256,
|
|
320,
|
|
384,
|
|
-1,
|
|
// layer = 2
|
|
-1,
|
|
32,
|
|
64,
|
|
96,
|
|
128,
|
|
160,
|
|
192,
|
|
224,
|
|
256,
|
|
288,
|
|
320,
|
|
352,
|
|
384,
|
|
416,
|
|
448,
|
|
-1,
|
|
// layer = 3
|
|
// lowSamplingFrequency === 1
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
-1,
|
|
// layer = 0
|
|
-1,
|
|
8,
|
|
16,
|
|
24,
|
|
32,
|
|
40,
|
|
48,
|
|
56,
|
|
64,
|
|
80,
|
|
96,
|
|
112,
|
|
128,
|
|
144,
|
|
160,
|
|
-1,
|
|
// layer = 1
|
|
-1,
|
|
8,
|
|
16,
|
|
24,
|
|
32,
|
|
40,
|
|
48,
|
|
56,
|
|
64,
|
|
80,
|
|
96,
|
|
112,
|
|
128,
|
|
144,
|
|
160,
|
|
-1,
|
|
// layer = 2
|
|
-1,
|
|
32,
|
|
48,
|
|
56,
|
|
64,
|
|
80,
|
|
96,
|
|
112,
|
|
128,
|
|
144,
|
|
160,
|
|
176,
|
|
192,
|
|
224,
|
|
256,
|
|
-1
|
|
// layer = 3
|
|
];
|
|
var XING = 1483304551;
|
|
var INFO = 1231971951;
|
|
var computeMp3FrameSize = (lowSamplingFrequency, layer, bitrate, sampleRate, padding) => {
|
|
if (layer === 0) {
|
|
return 0;
|
|
} else if (layer === 1) {
|
|
return Math.floor(144 * bitrate / (sampleRate << lowSamplingFrequency)) + padding;
|
|
} else if (layer === 2) {
|
|
return Math.floor(144 * bitrate / sampleRate) + padding;
|
|
} else {
|
|
return (Math.floor(12 * bitrate / sampleRate) + padding) * 4;
|
|
}
|
|
};
|
|
var getXingOffset = (mpegVersionId, channel) => {
|
|
return mpegVersionId === 3 ? channel === 3 ? 21 : 36 : channel === 3 ? 13 : 21;
|
|
};
|
|
var readMp3FrameHeader = (word, remainingBytes) => {
|
|
const firstByte = word >>> 24;
|
|
const secondByte = word >>> 16 & 255;
|
|
const thirdByte = word >>> 8 & 255;
|
|
const fourthByte = word & 255;
|
|
if (firstByte !== 255 && secondByte !== 255 && thirdByte !== 255 && fourthByte !== 255) {
|
|
return {
|
|
header: null,
|
|
bytesAdvanced: 4
|
|
};
|
|
}
|
|
if (firstByte !== 255) {
|
|
return { header: null, bytesAdvanced: 1 };
|
|
}
|
|
if ((secondByte & 224) !== 224) {
|
|
return { header: null, bytesAdvanced: 1 };
|
|
}
|
|
let lowSamplingFrequency = 0;
|
|
let mpeg25 = 0;
|
|
if (secondByte & 1 << 4) {
|
|
lowSamplingFrequency = secondByte & 1 << 3 ? 0 : 1;
|
|
} else {
|
|
lowSamplingFrequency = 1;
|
|
mpeg25 = 1;
|
|
}
|
|
const mpegVersionId = secondByte >> 3 & 3;
|
|
const layer = secondByte >> 1 & 3;
|
|
const bitrateIndex = thirdByte >> 4 & 15;
|
|
const frequencyIndex = (thirdByte >> 2 & 3) % 3;
|
|
const padding = thirdByte >> 1 & 1;
|
|
const channel = fourthByte >> 6 & 3;
|
|
const modeExtension = fourthByte >> 4 & 3;
|
|
const copyright = fourthByte >> 3 & 1;
|
|
const original = fourthByte >> 2 & 1;
|
|
const emphasis = fourthByte & 3;
|
|
const kilobitRate = KILOBIT_RATES[lowSamplingFrequency * 16 * 4 + layer * 16 + bitrateIndex];
|
|
if (kilobitRate === -1) {
|
|
return { header: null, bytesAdvanced: 1 };
|
|
}
|
|
const bitrate = kilobitRate * 1e3;
|
|
const sampleRate = SAMPLING_RATES[frequencyIndex] >> lowSamplingFrequency + mpeg25;
|
|
const frameLength = computeMp3FrameSize(lowSamplingFrequency, layer, bitrate, sampleRate, padding);
|
|
if (remainingBytes !== null && remainingBytes < frameLength) {
|
|
return { header: null, bytesAdvanced: 1 };
|
|
}
|
|
let audioSamplesInFrame;
|
|
if (mpegVersionId === 3) {
|
|
audioSamplesInFrame = layer === 3 ? 384 : 1152;
|
|
} else {
|
|
if (layer === 3) {
|
|
audioSamplesInFrame = 384;
|
|
} else if (layer === 2) {
|
|
audioSamplesInFrame = 1152;
|
|
} else {
|
|
audioSamplesInFrame = 576;
|
|
}
|
|
}
|
|
return {
|
|
header: {
|
|
totalSize: frameLength,
|
|
mpegVersionId,
|
|
layer,
|
|
bitrate,
|
|
frequencyIndex,
|
|
sampleRate,
|
|
channel,
|
|
modeExtension,
|
|
copyright,
|
|
original,
|
|
emphasis,
|
|
audioSamplesInFrame
|
|
},
|
|
bytesAdvanced: 1
|
|
};
|
|
};
|
|
var encodeSynchsafe = (unsynchsafed) => {
|
|
let mask = 127;
|
|
let synchsafed = 0;
|
|
let unsynchsafedRest = unsynchsafed;
|
|
while ((mask ^ 2147483647) !== 0) {
|
|
synchsafed = unsynchsafedRest & ~mask;
|
|
synchsafed <<= 1;
|
|
synchsafed |= unsynchsafedRest & mask;
|
|
mask = (mask + 1 << 8) - 1;
|
|
unsynchsafedRest = synchsafed;
|
|
}
|
|
return synchsafed;
|
|
};
|
|
var decodeSynchsafe = (synchsafed) => {
|
|
let mask = 2130706432;
|
|
let unsynchsafed = 0;
|
|
while (mask !== 0) {
|
|
unsynchsafed >>= 1;
|
|
unsynchsafed |= synchsafed & mask;
|
|
mask >>= 8;
|
|
}
|
|
return unsynchsafed;
|
|
};
|
|
|
|
// shared/ac3-misc.ts
|
|
var AC3_SAMPLE_RATES = [48e3, 44100, 32e3];
|
|
var EAC3_REDUCED_SAMPLE_RATES = [24e3, 22050, 16e3];
|
|
|
|
// src/codec-data.ts
|
|
var iterateNalUnitsInAnnexB = function* (packetData) {
|
|
let i = 0;
|
|
let nalStart = -1;
|
|
while (i < packetData.length - 2) {
|
|
const zeroIndex = packetData.indexOf(0, i);
|
|
if (zeroIndex === -1 || zeroIndex >= packetData.length - 2) {
|
|
break;
|
|
}
|
|
i = zeroIndex;
|
|
let startCodeLength = 0;
|
|
if (i + 3 < packetData.length && packetData[i + 1] === 0 && packetData[i + 2] === 0 && packetData[i + 3] === 1) {
|
|
startCodeLength = 4;
|
|
} else if (packetData[i + 1] === 0 && packetData[i + 2] === 1) {
|
|
startCodeLength = 3;
|
|
}
|
|
if (startCodeLength === 0) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (nalStart !== -1 && i > nalStart) {
|
|
yield {
|
|
offset: nalStart,
|
|
length: i - nalStart
|
|
};
|
|
}
|
|
nalStart = i + startCodeLength;
|
|
i = nalStart;
|
|
}
|
|
if (nalStart !== -1 && nalStart < packetData.length) {
|
|
yield {
|
|
offset: nalStart,
|
|
length: packetData.length - nalStart
|
|
};
|
|
}
|
|
};
|
|
var iterateNalUnitsInLengthPrefixed = function* (packetData, lengthSize) {
|
|
let offset = 0;
|
|
const dataView = new DataView(packetData.buffer, packetData.byteOffset, packetData.byteLength);
|
|
while (offset + lengthSize <= packetData.length) {
|
|
let nalUnitLength;
|
|
if (lengthSize === 1) {
|
|
nalUnitLength = dataView.getUint8(offset);
|
|
} else if (lengthSize === 2) {
|
|
nalUnitLength = dataView.getUint16(offset, false);
|
|
} else if (lengthSize === 3) {
|
|
nalUnitLength = getUint24(dataView, offset, false);
|
|
} else {
|
|
assert(lengthSize === 4);
|
|
nalUnitLength = dataView.getUint32(offset, false);
|
|
}
|
|
offset += lengthSize;
|
|
yield {
|
|
offset,
|
|
length: nalUnitLength
|
|
};
|
|
offset += nalUnitLength;
|
|
}
|
|
};
|
|
var iterateAvcNalUnits = (packetData, decoderConfig) => {
|
|
if (decoderConfig.description) {
|
|
const bytes2 = toUint8Array(decoderConfig.description);
|
|
const lengthSizeMinusOne = bytes2[4] & 3;
|
|
const lengthSize = lengthSizeMinusOne + 1;
|
|
return iterateNalUnitsInLengthPrefixed(packetData, lengthSize);
|
|
} else {
|
|
return iterateNalUnitsInAnnexB(packetData);
|
|
}
|
|
};
|
|
var extractNalUnitTypeForAvc = (byte) => {
|
|
return byte & 31;
|
|
};
|
|
var removeEmulationPreventionBytes = (data) => {
|
|
const result = [];
|
|
const len = data.length;
|
|
for (let i = 0; i < len; i++) {
|
|
if (i + 2 < len && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 3) {
|
|
result.push(0, 0);
|
|
i += 2;
|
|
} else {
|
|
result.push(data[i]);
|
|
}
|
|
}
|
|
return new Uint8Array(result);
|
|
};
|
|
var ANNEX_B_START_CODE = new Uint8Array([0, 0, 0, 1]);
|
|
var concatNalUnitsInAnnexB = (nalUnits) => {
|
|
const totalLength = nalUnits.reduce((a, b) => a + ANNEX_B_START_CODE.byteLength + b.byteLength, 0);
|
|
const result = new Uint8Array(totalLength);
|
|
let offset = 0;
|
|
for (const nalUnit of nalUnits) {
|
|
result.set(ANNEX_B_START_CODE, offset);
|
|
offset += ANNEX_B_START_CODE.byteLength;
|
|
result.set(nalUnit, offset);
|
|
offset += nalUnit.byteLength;
|
|
}
|
|
return result;
|
|
};
|
|
var concatNalUnitsInLengthPrefixed = (nalUnits, lengthSize) => {
|
|
const totalLength = nalUnits.reduce((a, b) => a + lengthSize + b.byteLength, 0);
|
|
const result = new Uint8Array(totalLength);
|
|
let offset = 0;
|
|
for (const nalUnit of nalUnits) {
|
|
const dataView = new DataView(result.buffer, result.byteOffset, result.byteLength);
|
|
switch (lengthSize) {
|
|
case 1:
|
|
dataView.setUint8(offset, nalUnit.byteLength);
|
|
break;
|
|
case 2:
|
|
dataView.setUint16(offset, nalUnit.byteLength, false);
|
|
break;
|
|
case 3:
|
|
setUint24(dataView, offset, nalUnit.byteLength, false);
|
|
break;
|
|
case 4:
|
|
dataView.setUint32(offset, nalUnit.byteLength, false);
|
|
break;
|
|
}
|
|
offset += lengthSize;
|
|
result.set(nalUnit, offset);
|
|
offset += nalUnit.byteLength;
|
|
}
|
|
return result;
|
|
};
|
|
var concatAvcNalUnits = (nalUnits, decoderConfig) => {
|
|
if (decoderConfig.description) {
|
|
const bytes2 = toUint8Array(decoderConfig.description);
|
|
const lengthSizeMinusOne = bytes2[4] & 3;
|
|
const lengthSize = lengthSizeMinusOne + 1;
|
|
return concatNalUnitsInLengthPrefixed(nalUnits, lengthSize);
|
|
} else {
|
|
return concatNalUnitsInAnnexB(nalUnits);
|
|
}
|
|
};
|
|
var extractAvcDecoderConfigurationRecord = (packetData) => {
|
|
try {
|
|
const spsUnits = [];
|
|
const ppsUnits = [];
|
|
const spsExtUnits = [];
|
|
for (const loc of iterateNalUnitsInAnnexB(packetData)) {
|
|
const nalUnit = packetData.subarray(loc.offset, loc.offset + loc.length);
|
|
const type = extractNalUnitTypeForAvc(nalUnit[0]);
|
|
if (type === 7 /* SPS */) {
|
|
spsUnits.push(nalUnit);
|
|
} else if (type === 8 /* PPS */) {
|
|
ppsUnits.push(nalUnit);
|
|
} else if (type === 13 /* SPS_EXT */) {
|
|
spsExtUnits.push(nalUnit);
|
|
}
|
|
}
|
|
if (spsUnits.length === 0) {
|
|
return null;
|
|
}
|
|
if (ppsUnits.length === 0) {
|
|
return null;
|
|
}
|
|
const spsData = spsUnits[0];
|
|
const spsInfo = parseAvcSps(spsData);
|
|
assert(spsInfo !== null);
|
|
const hasExtendedData = spsInfo.profileIdc === 100 || spsInfo.profileIdc === 110 || spsInfo.profileIdc === 122 || spsInfo.profileIdc === 144;
|
|
return {
|
|
configurationVersion: 1,
|
|
avcProfileIndication: spsInfo.profileIdc,
|
|
profileCompatibility: spsInfo.constraintFlags,
|
|
avcLevelIndication: spsInfo.levelIdc,
|
|
lengthSizeMinusOne: 3,
|
|
// Typically 4 bytes for length field
|
|
sequenceParameterSets: spsUnits,
|
|
pictureParameterSets: ppsUnits,
|
|
chromaFormat: hasExtendedData ? spsInfo.chromaFormatIdc : null,
|
|
bitDepthLumaMinus8: hasExtendedData ? spsInfo.bitDepthLumaMinus8 : null,
|
|
bitDepthChromaMinus8: hasExtendedData ? spsInfo.bitDepthChromaMinus8 : null,
|
|
sequenceParameterSetExt: hasExtendedData ? spsExtUnits : null
|
|
};
|
|
} catch (error) {
|
|
console.error("Error building AVC Decoder Configuration Record:", error);
|
|
return null;
|
|
}
|
|
};
|
|
var serializeAvcDecoderConfigurationRecord = (record) => {
|
|
const bytes2 = [];
|
|
bytes2.push(record.configurationVersion);
|
|
bytes2.push(record.avcProfileIndication);
|
|
bytes2.push(record.profileCompatibility);
|
|
bytes2.push(record.avcLevelIndication);
|
|
bytes2.push(252 | record.lengthSizeMinusOne & 3);
|
|
bytes2.push(224 | record.sequenceParameterSets.length & 31);
|
|
for (const sps of record.sequenceParameterSets) {
|
|
const length = sps.byteLength;
|
|
bytes2.push(length >> 8);
|
|
bytes2.push(length & 255);
|
|
for (let i = 0; i < length; i++) {
|
|
bytes2.push(sps[i]);
|
|
}
|
|
}
|
|
bytes2.push(record.pictureParameterSets.length);
|
|
for (const pps of record.pictureParameterSets) {
|
|
const length = pps.byteLength;
|
|
bytes2.push(length >> 8);
|
|
bytes2.push(length & 255);
|
|
for (let i = 0; i < length; i++) {
|
|
bytes2.push(pps[i]);
|
|
}
|
|
}
|
|
if (record.avcProfileIndication === 100 || record.avcProfileIndication === 110 || record.avcProfileIndication === 122 || record.avcProfileIndication === 144) {
|
|
assert(record.chromaFormat !== null);
|
|
assert(record.bitDepthLumaMinus8 !== null);
|
|
assert(record.bitDepthChromaMinus8 !== null);
|
|
assert(record.sequenceParameterSetExt !== null);
|
|
bytes2.push(252 | record.chromaFormat & 3);
|
|
bytes2.push(248 | record.bitDepthLumaMinus8 & 7);
|
|
bytes2.push(248 | record.bitDepthChromaMinus8 & 7);
|
|
bytes2.push(record.sequenceParameterSetExt.length);
|
|
for (const spsExt of record.sequenceParameterSetExt) {
|
|
const length = spsExt.byteLength;
|
|
bytes2.push(length >> 8);
|
|
bytes2.push(length & 255);
|
|
for (let i = 0; i < length; i++) {
|
|
bytes2.push(spsExt[i]);
|
|
}
|
|
}
|
|
}
|
|
return new Uint8Array(bytes2);
|
|
};
|
|
var deserializeAvcDecoderConfigurationRecord = (data) => {
|
|
try {
|
|
const view2 = toDataView(data);
|
|
let offset = 0;
|
|
const configurationVersion = view2.getUint8(offset++);
|
|
const avcProfileIndication = view2.getUint8(offset++);
|
|
const profileCompatibility = view2.getUint8(offset++);
|
|
const avcLevelIndication = view2.getUint8(offset++);
|
|
const lengthSizeMinusOne = view2.getUint8(offset++) & 3;
|
|
const numOfSequenceParameterSets = view2.getUint8(offset++) & 31;
|
|
const sequenceParameterSets = [];
|
|
for (let i = 0; i < numOfSequenceParameterSets; i++) {
|
|
const length = view2.getUint16(offset, false);
|
|
offset += 2;
|
|
sequenceParameterSets.push(data.subarray(offset, offset + length));
|
|
offset += length;
|
|
}
|
|
const numOfPictureParameterSets = view2.getUint8(offset++);
|
|
const pictureParameterSets = [];
|
|
for (let i = 0; i < numOfPictureParameterSets; i++) {
|
|
const length = view2.getUint16(offset, false);
|
|
offset += 2;
|
|
pictureParameterSets.push(data.subarray(offset, offset + length));
|
|
offset += length;
|
|
}
|
|
const record = {
|
|
configurationVersion,
|
|
avcProfileIndication,
|
|
profileCompatibility,
|
|
avcLevelIndication,
|
|
lengthSizeMinusOne,
|
|
sequenceParameterSets,
|
|
pictureParameterSets,
|
|
chromaFormat: null,
|
|
bitDepthLumaMinus8: null,
|
|
bitDepthChromaMinus8: null,
|
|
sequenceParameterSetExt: null
|
|
};
|
|
if ((avcProfileIndication === 100 || avcProfileIndication === 110 || avcProfileIndication === 122 || avcProfileIndication === 144) && offset + 4 <= data.length) {
|
|
const chromaFormat = view2.getUint8(offset++) & 3;
|
|
const bitDepthLumaMinus8 = view2.getUint8(offset++) & 7;
|
|
const bitDepthChromaMinus8 = view2.getUint8(offset++) & 7;
|
|
const numOfSequenceParameterSetExt = view2.getUint8(offset++);
|
|
record.chromaFormat = chromaFormat;
|
|
record.bitDepthLumaMinus8 = bitDepthLumaMinus8;
|
|
record.bitDepthChromaMinus8 = bitDepthChromaMinus8;
|
|
const sequenceParameterSetExt = [];
|
|
for (let i = 0; i < numOfSequenceParameterSetExt; i++) {
|
|
const length = view2.getUint16(offset, false);
|
|
offset += 2;
|
|
sequenceParameterSetExt.push(data.subarray(offset, offset + length));
|
|
offset += length;
|
|
}
|
|
record.sequenceParameterSetExt = sequenceParameterSetExt;
|
|
}
|
|
return record;
|
|
} catch (error) {
|
|
console.error("Error deserializing AVC Decoder Configuration Record:", error);
|
|
return null;
|
|
}
|
|
};
|
|
var parseAvcSps = (sps) => {
|
|
try {
|
|
const bitstream = new Bitstream(removeEmulationPreventionBytes(sps));
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(2);
|
|
const nalUnitType = bitstream.readBits(5);
|
|
if (nalUnitType !== 7) {
|
|
return null;
|
|
}
|
|
const profileIdc = bitstream.readAlignedByte();
|
|
const constraintFlags = bitstream.readAlignedByte();
|
|
const levelIdc = bitstream.readAlignedByte();
|
|
readExpGolomb(bitstream);
|
|
let chromaFormatIdc = 1;
|
|
let bitDepthLumaMinus8 = 0;
|
|
let bitDepthChromaMinus8 = 0;
|
|
let separateColourPlaneFlag = 0;
|
|
if (profileIdc === 100 || profileIdc === 110 || profileIdc === 122 || profileIdc === 244 || profileIdc === 44 || profileIdc === 83 || profileIdc === 86 || profileIdc === 118 || profileIdc === 128) {
|
|
chromaFormatIdc = readExpGolomb(bitstream);
|
|
if (chromaFormatIdc === 3) {
|
|
separateColourPlaneFlag = bitstream.readBits(1);
|
|
}
|
|
bitDepthLumaMinus8 = readExpGolomb(bitstream);
|
|
bitDepthChromaMinus8 = readExpGolomb(bitstream);
|
|
bitstream.skipBits(1);
|
|
const seqScalingMatrixPresentFlag = bitstream.readBits(1);
|
|
if (seqScalingMatrixPresentFlag) {
|
|
for (let i = 0; i < (chromaFormatIdc !== 3 ? 8 : 12); i++) {
|
|
const seqScalingListPresentFlag = bitstream.readBits(1);
|
|
if (seqScalingListPresentFlag) {
|
|
const sizeOfScalingList = i < 6 ? 16 : 64;
|
|
let lastScale = 8;
|
|
let nextScale = 8;
|
|
for (let j = 0; j < sizeOfScalingList; j++) {
|
|
if (nextScale !== 0) {
|
|
const deltaScale = readSignedExpGolomb(bitstream);
|
|
nextScale = (lastScale + deltaScale + 256) % 256;
|
|
}
|
|
lastScale = nextScale === 0 ? lastScale : nextScale;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
readExpGolomb(bitstream);
|
|
const picOrderCntType = readExpGolomb(bitstream);
|
|
if (picOrderCntType === 0) {
|
|
readExpGolomb(bitstream);
|
|
} else if (picOrderCntType === 1) {
|
|
bitstream.skipBits(1);
|
|
readSignedExpGolomb(bitstream);
|
|
readSignedExpGolomb(bitstream);
|
|
const numRefFramesInPicOrderCntCycle = readExpGolomb(bitstream);
|
|
for (let i = 0; i < numRefFramesInPicOrderCntCycle; i++) {
|
|
readSignedExpGolomb(bitstream);
|
|
}
|
|
}
|
|
readExpGolomb(bitstream);
|
|
bitstream.skipBits(1);
|
|
const picWidthInMbsMinus1 = readExpGolomb(bitstream);
|
|
const picHeightInMapUnitsMinus1 = readExpGolomb(bitstream);
|
|
const codedWidth = 16 * (picWidthInMbsMinus1 + 1);
|
|
const codedHeight = 16 * (picHeightInMapUnitsMinus1 + 1);
|
|
let displayWidth = codedWidth;
|
|
let displayHeight = codedHeight;
|
|
const frameMbsOnlyFlag = bitstream.readBits(1);
|
|
if (!frameMbsOnlyFlag) {
|
|
bitstream.skipBits(1);
|
|
}
|
|
bitstream.skipBits(1);
|
|
const frameCroppingFlag = bitstream.readBits(1);
|
|
if (frameCroppingFlag) {
|
|
const frameCropLeftOffset = readExpGolomb(bitstream);
|
|
const frameCropRightOffset = readExpGolomb(bitstream);
|
|
const frameCropTopOffset = readExpGolomb(bitstream);
|
|
const frameCropBottomOffset = readExpGolomb(bitstream);
|
|
let cropUnitX;
|
|
let cropUnitY;
|
|
const chromaArrayType = separateColourPlaneFlag === 0 ? chromaFormatIdc : 0;
|
|
if (chromaArrayType === 0) {
|
|
cropUnitX = 1;
|
|
cropUnitY = 2 - frameMbsOnlyFlag;
|
|
} else {
|
|
const subWidthC = chromaFormatIdc === 3 ? 1 : 2;
|
|
const subHeightC = chromaFormatIdc === 1 ? 2 : 1;
|
|
cropUnitX = subWidthC;
|
|
cropUnitY = subHeightC * (2 - frameMbsOnlyFlag);
|
|
}
|
|
displayWidth -= cropUnitX * (frameCropLeftOffset + frameCropRightOffset);
|
|
displayHeight -= cropUnitY * (frameCropTopOffset + frameCropBottomOffset);
|
|
}
|
|
let colourPrimaries = 2;
|
|
let transferCharacteristics = 2;
|
|
let matrixCoefficients = 2;
|
|
let fullRangeFlag = 0;
|
|
let numReorderFrames = null;
|
|
let maxDecFrameBuffering = null;
|
|
const vuiParametersPresentFlag = bitstream.readBits(1);
|
|
if (vuiParametersPresentFlag) {
|
|
const aspectRatioInfoPresentFlag = bitstream.readBits(1);
|
|
if (aspectRatioInfoPresentFlag) {
|
|
const aspectRatioIdc = bitstream.readBits(8);
|
|
if (aspectRatioIdc === 255) {
|
|
bitstream.skipBits(16);
|
|
bitstream.skipBits(16);
|
|
}
|
|
}
|
|
const overscanInfoPresentFlag = bitstream.readBits(1);
|
|
if (overscanInfoPresentFlag) {
|
|
bitstream.skipBits(1);
|
|
}
|
|
const videoSignalTypePresentFlag = bitstream.readBits(1);
|
|
if (videoSignalTypePresentFlag) {
|
|
bitstream.skipBits(3);
|
|
fullRangeFlag = bitstream.readBits(1);
|
|
const colourDescriptionPresentFlag = bitstream.readBits(1);
|
|
if (colourDescriptionPresentFlag) {
|
|
colourPrimaries = bitstream.readBits(8);
|
|
transferCharacteristics = bitstream.readBits(8);
|
|
matrixCoefficients = bitstream.readBits(8);
|
|
}
|
|
}
|
|
const chromaLocInfoPresentFlag = bitstream.readBits(1);
|
|
if (chromaLocInfoPresentFlag) {
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
}
|
|
const timingInfoPresentFlag = bitstream.readBits(1);
|
|
if (timingInfoPresentFlag) {
|
|
bitstream.skipBits(32);
|
|
bitstream.skipBits(32);
|
|
bitstream.skipBits(1);
|
|
}
|
|
const nalHrdParametersPresentFlag = bitstream.readBits(1);
|
|
if (nalHrdParametersPresentFlag) {
|
|
skipAvcHrdParameters(bitstream);
|
|
}
|
|
const vclHrdParametersPresentFlag = bitstream.readBits(1);
|
|
if (vclHrdParametersPresentFlag) {
|
|
skipAvcHrdParameters(bitstream);
|
|
}
|
|
if (nalHrdParametersPresentFlag || vclHrdParametersPresentFlag) {
|
|
bitstream.skipBits(1);
|
|
}
|
|
bitstream.skipBits(1);
|
|
const bitstreamRestrictionFlag = bitstream.readBits(1);
|
|
if (bitstreamRestrictionFlag) {
|
|
bitstream.skipBits(1);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
numReorderFrames = readExpGolomb(bitstream);
|
|
maxDecFrameBuffering = readExpGolomb(bitstream);
|
|
}
|
|
}
|
|
if (numReorderFrames === null) {
|
|
assert(maxDecFrameBuffering === null);
|
|
const constraintSet3Flag = constraintFlags & 16;
|
|
if ((profileIdc === 44 || profileIdc === 86 || profileIdc === 100 || profileIdc === 110 || profileIdc === 122 || profileIdc === 244) && constraintSet3Flag) {
|
|
numReorderFrames = 0;
|
|
maxDecFrameBuffering = 0;
|
|
} else {
|
|
const picWidthInMbs = picWidthInMbsMinus1 + 1;
|
|
const picHeightInMapUnits = picHeightInMapUnitsMinus1 + 1;
|
|
const frameHeightInMbs = (2 - frameMbsOnlyFlag) * picHeightInMapUnits;
|
|
const levelInfo = AVC_LEVEL_TABLE.find(
|
|
(x) => x.level >= levelIdc
|
|
) ?? last(AVC_LEVEL_TABLE);
|
|
const maxDpbFrames = Math.min(
|
|
Math.floor(levelInfo.maxDpbMbs / (picWidthInMbs * frameHeightInMbs)),
|
|
16
|
|
);
|
|
numReorderFrames = maxDpbFrames;
|
|
maxDecFrameBuffering = maxDpbFrames;
|
|
}
|
|
}
|
|
assert(maxDecFrameBuffering !== null);
|
|
return {
|
|
profileIdc,
|
|
constraintFlags,
|
|
levelIdc,
|
|
frameMbsOnlyFlag,
|
|
chromaFormatIdc,
|
|
bitDepthLumaMinus8,
|
|
bitDepthChromaMinus8,
|
|
codedWidth,
|
|
codedHeight,
|
|
displayWidth,
|
|
displayHeight,
|
|
colourPrimaries,
|
|
matrixCoefficients,
|
|
transferCharacteristics,
|
|
fullRangeFlag,
|
|
numReorderFrames,
|
|
maxDecFrameBuffering
|
|
};
|
|
} catch (error) {
|
|
console.error("Error parsing AVC SPS:", error);
|
|
return null;
|
|
}
|
|
};
|
|
var skipAvcHrdParameters = (bitstream) => {
|
|
const cpb_cnt_minus1 = readExpGolomb(bitstream);
|
|
bitstream.skipBits(4);
|
|
bitstream.skipBits(4);
|
|
for (let i = 0; i <= cpb_cnt_minus1; i++) {
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
bitstream.skipBits(1);
|
|
}
|
|
bitstream.skipBits(5);
|
|
bitstream.skipBits(5);
|
|
bitstream.skipBits(5);
|
|
bitstream.skipBits(5);
|
|
};
|
|
var iterateHevcNalUnits = (packetData, decoderConfig) => {
|
|
if (decoderConfig.description) {
|
|
const bytes2 = toUint8Array(decoderConfig.description);
|
|
const lengthSizeMinusOne = bytes2[21] & 3;
|
|
const lengthSize = lengthSizeMinusOne + 1;
|
|
return iterateNalUnitsInLengthPrefixed(packetData, lengthSize);
|
|
} else {
|
|
return iterateNalUnitsInAnnexB(packetData);
|
|
}
|
|
};
|
|
var extractNalUnitTypeForHevc = (byte) => {
|
|
return byte >> 1 & 63;
|
|
};
|
|
var parseHevcSps = (sps) => {
|
|
try {
|
|
const bitstream = new Bitstream(removeEmulationPreventionBytes(sps));
|
|
bitstream.skipBits(16);
|
|
bitstream.readBits(4);
|
|
const spsMaxSubLayersMinus1 = bitstream.readBits(3);
|
|
const spsTemporalIdNestingFlag = bitstream.readBits(1);
|
|
const {
|
|
general_profile_space,
|
|
general_tier_flag,
|
|
general_profile_idc,
|
|
general_profile_compatibility_flags,
|
|
general_constraint_indicator_flags,
|
|
general_level_idc
|
|
} = parseProfileTierLevel(bitstream, spsMaxSubLayersMinus1);
|
|
readExpGolomb(bitstream);
|
|
const chromaFormatIdc = readExpGolomb(bitstream);
|
|
let separateColourPlaneFlag = 0;
|
|
if (chromaFormatIdc === 3) {
|
|
separateColourPlaneFlag = bitstream.readBits(1);
|
|
}
|
|
const picWidthInLumaSamples = readExpGolomb(bitstream);
|
|
const picHeightInLumaSamples = readExpGolomb(bitstream);
|
|
let displayWidth = picWidthInLumaSamples;
|
|
let displayHeight = picHeightInLumaSamples;
|
|
if (bitstream.readBits(1)) {
|
|
const confWinLeftOffset = readExpGolomb(bitstream);
|
|
const confWinRightOffset = readExpGolomb(bitstream);
|
|
const confWinTopOffset = readExpGolomb(bitstream);
|
|
const confWinBottomOffset = readExpGolomb(bitstream);
|
|
let subWidthC = 1;
|
|
let subHeightC = 1;
|
|
const chromaArrayType = separateColourPlaneFlag === 0 ? chromaFormatIdc : 0;
|
|
if (chromaArrayType === 1) {
|
|
subWidthC = 2;
|
|
subHeightC = 2;
|
|
} else if (chromaArrayType === 2) {
|
|
subWidthC = 2;
|
|
subHeightC = 1;
|
|
}
|
|
displayWidth -= (confWinLeftOffset + confWinRightOffset) * subWidthC;
|
|
displayHeight -= (confWinTopOffset + confWinBottomOffset) * subHeightC;
|
|
}
|
|
const bitDepthLumaMinus8 = readExpGolomb(bitstream);
|
|
const bitDepthChromaMinus8 = readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
const spsSubLayerOrderingInfoPresentFlag = bitstream.readBits(1);
|
|
const startI = spsSubLayerOrderingInfoPresentFlag ? 0 : spsMaxSubLayersMinus1;
|
|
let spsMaxNumReorderPics = 0;
|
|
for (let i = startI; i <= spsMaxSubLayersMinus1; i++) {
|
|
readExpGolomb(bitstream);
|
|
spsMaxNumReorderPics = readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
}
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
if (bitstream.readBits(1)) {
|
|
if (bitstream.readBits(1)) {
|
|
skipScalingListData(bitstream);
|
|
}
|
|
}
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
if (bitstream.readBits(1)) {
|
|
bitstream.skipBits(4);
|
|
bitstream.skipBits(4);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
bitstream.skipBits(1);
|
|
}
|
|
const numShortTermRefPicSets = readExpGolomb(bitstream);
|
|
skipAllStRefPicSets(bitstream, numShortTermRefPicSets);
|
|
if (bitstream.readBits(1)) {
|
|
const numLongTermRefPicsSps = readExpGolomb(bitstream);
|
|
for (let i = 0; i < numLongTermRefPicsSps; i++) {
|
|
readExpGolomb(bitstream);
|
|
bitstream.skipBits(1);
|
|
}
|
|
}
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
let colourPrimaries = 2;
|
|
let transferCharacteristics = 2;
|
|
let matrixCoefficients = 2;
|
|
let fullRangeFlag = 0;
|
|
let minSpatialSegmentationIdc = 0;
|
|
if (bitstream.readBits(1)) {
|
|
const vui = parseHevcVui(bitstream, spsMaxSubLayersMinus1);
|
|
colourPrimaries = vui.colourPrimaries;
|
|
transferCharacteristics = vui.transferCharacteristics;
|
|
matrixCoefficients = vui.matrixCoefficients;
|
|
fullRangeFlag = vui.fullRangeFlag;
|
|
minSpatialSegmentationIdc = vui.minSpatialSegmentationIdc;
|
|
}
|
|
return {
|
|
displayWidth,
|
|
displayHeight,
|
|
colourPrimaries,
|
|
transferCharacteristics,
|
|
matrixCoefficients,
|
|
fullRangeFlag,
|
|
maxDecFrameBuffering: spsMaxNumReorderPics + 1,
|
|
spsMaxSubLayersMinus1,
|
|
spsTemporalIdNestingFlag,
|
|
generalProfileSpace: general_profile_space,
|
|
generalTierFlag: general_tier_flag,
|
|
generalProfileIdc: general_profile_idc,
|
|
generalProfileCompatibilityFlags: general_profile_compatibility_flags,
|
|
generalConstraintIndicatorFlags: general_constraint_indicator_flags,
|
|
generalLevelIdc: general_level_idc,
|
|
chromaFormatIdc,
|
|
bitDepthLumaMinus8,
|
|
bitDepthChromaMinus8,
|
|
minSpatialSegmentationIdc
|
|
};
|
|
} catch (error) {
|
|
console.error("Error parsing HEVC SPS:", error);
|
|
return null;
|
|
}
|
|
};
|
|
var extractHevcDecoderConfigurationRecord = (packetData) => {
|
|
try {
|
|
const vpsUnits = [];
|
|
const spsUnits = [];
|
|
const ppsUnits = [];
|
|
const seiUnits = [];
|
|
for (const loc of iterateNalUnitsInAnnexB(packetData)) {
|
|
const nalUnit = packetData.subarray(loc.offset, loc.offset + loc.length);
|
|
const type = extractNalUnitTypeForHevc(nalUnit[0]);
|
|
if (type === 32 /* VPS_NUT */) {
|
|
vpsUnits.push(nalUnit);
|
|
} else if (type === 33 /* SPS_NUT */) {
|
|
spsUnits.push(nalUnit);
|
|
} else if (type === 34 /* PPS_NUT */) {
|
|
ppsUnits.push(nalUnit);
|
|
} else if (type === 39 /* PREFIX_SEI_NUT */ || type === 40 /* SUFFIX_SEI_NUT */) {
|
|
seiUnits.push(nalUnit);
|
|
}
|
|
}
|
|
if (spsUnits.length === 0 || ppsUnits.length === 0) return null;
|
|
const spsInfo = parseHevcSps(spsUnits[0]);
|
|
if (!spsInfo) return null;
|
|
let parallelismType = 0;
|
|
if (ppsUnits.length > 0) {
|
|
const pps = ppsUnits[0];
|
|
const ppsBitstream = new Bitstream(removeEmulationPreventionBytes(pps));
|
|
ppsBitstream.skipBits(16);
|
|
readExpGolomb(ppsBitstream);
|
|
readExpGolomb(ppsBitstream);
|
|
ppsBitstream.skipBits(1);
|
|
ppsBitstream.skipBits(1);
|
|
ppsBitstream.skipBits(3);
|
|
ppsBitstream.skipBits(1);
|
|
ppsBitstream.skipBits(1);
|
|
readExpGolomb(ppsBitstream);
|
|
readExpGolomb(ppsBitstream);
|
|
readSignedExpGolomb(ppsBitstream);
|
|
ppsBitstream.skipBits(1);
|
|
ppsBitstream.skipBits(1);
|
|
if (ppsBitstream.readBits(1)) {
|
|
readExpGolomb(ppsBitstream);
|
|
}
|
|
readSignedExpGolomb(ppsBitstream);
|
|
readSignedExpGolomb(ppsBitstream);
|
|
ppsBitstream.skipBits(1);
|
|
ppsBitstream.skipBits(1);
|
|
ppsBitstream.skipBits(1);
|
|
ppsBitstream.skipBits(1);
|
|
const tiles_enabled_flag = ppsBitstream.readBits(1);
|
|
const entropy_coding_sync_enabled_flag = ppsBitstream.readBits(1);
|
|
if (!tiles_enabled_flag && !entropy_coding_sync_enabled_flag) parallelismType = 0;
|
|
else if (tiles_enabled_flag && !entropy_coding_sync_enabled_flag) parallelismType = 2;
|
|
else if (!tiles_enabled_flag && entropy_coding_sync_enabled_flag) parallelismType = 3;
|
|
else parallelismType = 0;
|
|
}
|
|
const arrays = [
|
|
...vpsUnits.length ? [
|
|
{
|
|
arrayCompleteness: 1,
|
|
nalUnitType: 32 /* VPS_NUT */,
|
|
nalUnits: vpsUnits
|
|
}
|
|
] : [],
|
|
...spsUnits.length ? [
|
|
{
|
|
arrayCompleteness: 1,
|
|
nalUnitType: 33 /* SPS_NUT */,
|
|
nalUnits: spsUnits
|
|
}
|
|
] : [],
|
|
...ppsUnits.length ? [
|
|
{
|
|
arrayCompleteness: 1,
|
|
nalUnitType: 34 /* PPS_NUT */,
|
|
nalUnits: ppsUnits
|
|
}
|
|
] : [],
|
|
...seiUnits.length ? [
|
|
{
|
|
arrayCompleteness: 1,
|
|
nalUnitType: extractNalUnitTypeForHevc(seiUnits[0][0]),
|
|
nalUnits: seiUnits
|
|
}
|
|
] : []
|
|
];
|
|
const record = {
|
|
configurationVersion: 1,
|
|
generalProfileSpace: spsInfo.generalProfileSpace,
|
|
generalTierFlag: spsInfo.generalTierFlag,
|
|
generalProfileIdc: spsInfo.generalProfileIdc,
|
|
generalProfileCompatibilityFlags: spsInfo.generalProfileCompatibilityFlags,
|
|
generalConstraintIndicatorFlags: spsInfo.generalConstraintIndicatorFlags,
|
|
generalLevelIdc: spsInfo.generalLevelIdc,
|
|
minSpatialSegmentationIdc: spsInfo.minSpatialSegmentationIdc,
|
|
parallelismType,
|
|
chromaFormatIdc: spsInfo.chromaFormatIdc,
|
|
bitDepthLumaMinus8: spsInfo.bitDepthLumaMinus8,
|
|
bitDepthChromaMinus8: spsInfo.bitDepthChromaMinus8,
|
|
avgFrameRate: 0,
|
|
constantFrameRate: 0,
|
|
numTemporalLayers: spsInfo.spsMaxSubLayersMinus1 + 1,
|
|
temporalIdNested: spsInfo.spsTemporalIdNestingFlag,
|
|
lengthSizeMinusOne: 3,
|
|
arrays
|
|
};
|
|
return record;
|
|
} catch (error) {
|
|
console.error("Error building HEVC Decoder Configuration Record:", error);
|
|
return null;
|
|
}
|
|
};
|
|
var parseProfileTierLevel = (bitstream, maxNumSubLayersMinus1) => {
|
|
const general_profile_space = bitstream.readBits(2);
|
|
const general_tier_flag = bitstream.readBits(1);
|
|
const general_profile_idc = bitstream.readBits(5);
|
|
let general_profile_compatibility_flags = 0;
|
|
for (let i = 0; i < 32; i++) {
|
|
general_profile_compatibility_flags = general_profile_compatibility_flags << 1 | bitstream.readBits(1);
|
|
}
|
|
const general_constraint_indicator_flags = new Uint8Array(6);
|
|
for (let i = 0; i < 6; i++) {
|
|
general_constraint_indicator_flags[i] = bitstream.readBits(8);
|
|
}
|
|
const general_level_idc = bitstream.readBits(8);
|
|
const sub_layer_profile_present_flag = [];
|
|
const sub_layer_level_present_flag = [];
|
|
for (let i = 0; i < maxNumSubLayersMinus1; i++) {
|
|
sub_layer_profile_present_flag.push(bitstream.readBits(1));
|
|
sub_layer_level_present_flag.push(bitstream.readBits(1));
|
|
}
|
|
if (maxNumSubLayersMinus1 > 0) {
|
|
for (let i = maxNumSubLayersMinus1; i < 8; i++) {
|
|
bitstream.skipBits(2);
|
|
}
|
|
}
|
|
for (let i = 0; i < maxNumSubLayersMinus1; i++) {
|
|
if (sub_layer_profile_present_flag[i]) bitstream.skipBits(88);
|
|
if (sub_layer_level_present_flag[i]) bitstream.skipBits(8);
|
|
}
|
|
return {
|
|
general_profile_space,
|
|
general_tier_flag,
|
|
general_profile_idc,
|
|
general_profile_compatibility_flags,
|
|
general_constraint_indicator_flags,
|
|
general_level_idc
|
|
};
|
|
};
|
|
var skipScalingListData = (bitstream) => {
|
|
for (let sizeId = 0; sizeId < 4; sizeId++) {
|
|
for (let matrixId = 0; matrixId < (sizeId === 3 ? 2 : 6); matrixId++) {
|
|
const scaling_list_pred_mode_flag = bitstream.readBits(1);
|
|
if (!scaling_list_pred_mode_flag) {
|
|
readExpGolomb(bitstream);
|
|
} else {
|
|
const coefNum = Math.min(64, 1 << 4 + (sizeId << 1));
|
|
if (sizeId > 1) {
|
|
readSignedExpGolomb(bitstream);
|
|
}
|
|
for (let i = 0; i < coefNum; i++) {
|
|
readSignedExpGolomb(bitstream);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
var skipAllStRefPicSets = (bitstream, num_short_term_ref_pic_sets) => {
|
|
const NumDeltaPocs = [];
|
|
for (let stRpsIdx = 0; stRpsIdx < num_short_term_ref_pic_sets; stRpsIdx++) {
|
|
NumDeltaPocs[stRpsIdx] = skipStRefPicSet(bitstream, stRpsIdx, num_short_term_ref_pic_sets, NumDeltaPocs);
|
|
}
|
|
};
|
|
var skipStRefPicSet = (bitstream, stRpsIdx, num_short_term_ref_pic_sets, NumDeltaPocs) => {
|
|
let NumDeltaPocsThis = 0;
|
|
let inter_ref_pic_set_prediction_flag = 0;
|
|
let RefRpsIdx = 0;
|
|
if (stRpsIdx !== 0) {
|
|
inter_ref_pic_set_prediction_flag = bitstream.readBits(1);
|
|
}
|
|
if (inter_ref_pic_set_prediction_flag) {
|
|
if (stRpsIdx === num_short_term_ref_pic_sets) {
|
|
const delta_idx_minus1 = readExpGolomb(bitstream);
|
|
RefRpsIdx = stRpsIdx - (delta_idx_minus1 + 1);
|
|
} else {
|
|
RefRpsIdx = stRpsIdx - 1;
|
|
}
|
|
bitstream.readBits(1);
|
|
readExpGolomb(bitstream);
|
|
const numDelta = NumDeltaPocs[RefRpsIdx] ?? 0;
|
|
for (let j = 0; j <= numDelta; j++) {
|
|
const used_by_curr_pic_flag = bitstream.readBits(1);
|
|
if (!used_by_curr_pic_flag) {
|
|
bitstream.readBits(1);
|
|
}
|
|
}
|
|
NumDeltaPocsThis = NumDeltaPocs[RefRpsIdx];
|
|
} else {
|
|
const num_negative_pics = readExpGolomb(bitstream);
|
|
const num_positive_pics = readExpGolomb(bitstream);
|
|
for (let i = 0; i < num_negative_pics; i++) {
|
|
readExpGolomb(bitstream);
|
|
bitstream.readBits(1);
|
|
}
|
|
for (let i = 0; i < num_positive_pics; i++) {
|
|
readExpGolomb(bitstream);
|
|
bitstream.readBits(1);
|
|
}
|
|
NumDeltaPocsThis = num_negative_pics + num_positive_pics;
|
|
}
|
|
return NumDeltaPocsThis;
|
|
};
|
|
var parseHevcVui = (bitstream, sps_max_sub_layers_minus1) => {
|
|
let colourPrimaries = 2;
|
|
let transferCharacteristics = 2;
|
|
let matrixCoefficients = 2;
|
|
let fullRangeFlag = 0;
|
|
let minSpatialSegmentationIdc = 0;
|
|
if (bitstream.readBits(1)) {
|
|
const aspect_ratio_idc = bitstream.readBits(8);
|
|
if (aspect_ratio_idc === 255) {
|
|
bitstream.readBits(16);
|
|
bitstream.readBits(16);
|
|
}
|
|
}
|
|
if (bitstream.readBits(1)) {
|
|
bitstream.readBits(1);
|
|
}
|
|
if (bitstream.readBits(1)) {
|
|
bitstream.readBits(3);
|
|
fullRangeFlag = bitstream.readBits(1);
|
|
if (bitstream.readBits(1)) {
|
|
colourPrimaries = bitstream.readBits(8);
|
|
transferCharacteristics = bitstream.readBits(8);
|
|
matrixCoefficients = bitstream.readBits(8);
|
|
}
|
|
}
|
|
if (bitstream.readBits(1)) {
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
}
|
|
bitstream.readBits(1);
|
|
bitstream.readBits(1);
|
|
bitstream.readBits(1);
|
|
if (bitstream.readBits(1)) {
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
}
|
|
if (bitstream.readBits(1)) {
|
|
bitstream.readBits(32);
|
|
bitstream.readBits(32);
|
|
if (bitstream.readBits(1)) {
|
|
readExpGolomb(bitstream);
|
|
}
|
|
if (bitstream.readBits(1)) {
|
|
skipHevcHrdParameters(bitstream, true, sps_max_sub_layers_minus1);
|
|
}
|
|
}
|
|
if (bitstream.readBits(1)) {
|
|
bitstream.readBits(1);
|
|
bitstream.readBits(1);
|
|
bitstream.readBits(1);
|
|
minSpatialSegmentationIdc = readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
}
|
|
return {
|
|
colourPrimaries,
|
|
transferCharacteristics,
|
|
matrixCoefficients,
|
|
fullRangeFlag,
|
|
minSpatialSegmentationIdc
|
|
};
|
|
};
|
|
var skipHevcHrdParameters = (bitstream, commonInfPresentFlag, maxNumSubLayersMinus1) => {
|
|
let nal_hrd_parameters_present_flag = false;
|
|
let vcl_hrd_parameters_present_flag = false;
|
|
let sub_pic_hrd_params_present_flag = false;
|
|
if (commonInfPresentFlag) {
|
|
nal_hrd_parameters_present_flag = bitstream.readBits(1) === 1;
|
|
vcl_hrd_parameters_present_flag = bitstream.readBits(1) === 1;
|
|
if (nal_hrd_parameters_present_flag || vcl_hrd_parameters_present_flag) {
|
|
sub_pic_hrd_params_present_flag = bitstream.readBits(1) === 1;
|
|
if (sub_pic_hrd_params_present_flag) {
|
|
bitstream.readBits(8);
|
|
bitstream.readBits(5);
|
|
bitstream.readBits(1);
|
|
bitstream.readBits(5);
|
|
}
|
|
bitstream.readBits(4);
|
|
bitstream.readBits(4);
|
|
if (sub_pic_hrd_params_present_flag) {
|
|
bitstream.readBits(4);
|
|
}
|
|
bitstream.readBits(5);
|
|
bitstream.readBits(5);
|
|
bitstream.readBits(5);
|
|
}
|
|
}
|
|
for (let i = 0; i <= maxNumSubLayersMinus1; i++) {
|
|
const fixed_pic_rate_general_flag = bitstream.readBits(1) === 1;
|
|
let fixed_pic_rate_within_cvs_flag = true;
|
|
if (!fixed_pic_rate_general_flag) {
|
|
fixed_pic_rate_within_cvs_flag = bitstream.readBits(1) === 1;
|
|
}
|
|
let low_delay_hrd_flag = false;
|
|
if (fixed_pic_rate_within_cvs_flag) {
|
|
readExpGolomb(bitstream);
|
|
} else {
|
|
low_delay_hrd_flag = bitstream.readBits(1) === 1;
|
|
}
|
|
let CpbCnt = 1;
|
|
if (!low_delay_hrd_flag) {
|
|
const cpb_cnt_minus1 = readExpGolomb(bitstream);
|
|
CpbCnt = cpb_cnt_minus1 + 1;
|
|
}
|
|
if (nal_hrd_parameters_present_flag) {
|
|
skipSubLayerHrdParameters(bitstream, CpbCnt, sub_pic_hrd_params_present_flag);
|
|
}
|
|
if (vcl_hrd_parameters_present_flag) {
|
|
skipSubLayerHrdParameters(bitstream, CpbCnt, sub_pic_hrd_params_present_flag);
|
|
}
|
|
}
|
|
};
|
|
var skipSubLayerHrdParameters = (bitstream, CpbCnt, sub_pic_hrd_params_present_flag) => {
|
|
for (let i = 0; i < CpbCnt; i++) {
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
if (sub_pic_hrd_params_present_flag) {
|
|
readExpGolomb(bitstream);
|
|
readExpGolomb(bitstream);
|
|
}
|
|
bitstream.readBits(1);
|
|
}
|
|
};
|
|
var serializeHevcDecoderConfigurationRecord = (record) => {
|
|
const bytes2 = [];
|
|
bytes2.push(record.configurationVersion);
|
|
bytes2.push(
|
|
(record.generalProfileSpace & 3) << 6 | (record.generalTierFlag & 1) << 5 | record.generalProfileIdc & 31
|
|
);
|
|
bytes2.push(record.generalProfileCompatibilityFlags >>> 24 & 255);
|
|
bytes2.push(record.generalProfileCompatibilityFlags >>> 16 & 255);
|
|
bytes2.push(record.generalProfileCompatibilityFlags >>> 8 & 255);
|
|
bytes2.push(record.generalProfileCompatibilityFlags & 255);
|
|
bytes2.push(...record.generalConstraintIndicatorFlags);
|
|
bytes2.push(record.generalLevelIdc & 255);
|
|
bytes2.push(240 | record.minSpatialSegmentationIdc >> 8 & 15);
|
|
bytes2.push(record.minSpatialSegmentationIdc & 255);
|
|
bytes2.push(252 | record.parallelismType & 3);
|
|
bytes2.push(252 | record.chromaFormatIdc & 3);
|
|
bytes2.push(248 | record.bitDepthLumaMinus8 & 7);
|
|
bytes2.push(248 | record.bitDepthChromaMinus8 & 7);
|
|
bytes2.push(record.avgFrameRate >> 8 & 255);
|
|
bytes2.push(record.avgFrameRate & 255);
|
|
bytes2.push(
|
|
(record.constantFrameRate & 3) << 6 | (record.numTemporalLayers & 7) << 3 | (record.temporalIdNested & 1) << 2 | record.lengthSizeMinusOne & 3
|
|
);
|
|
bytes2.push(record.arrays.length & 255);
|
|
for (const arr of record.arrays) {
|
|
bytes2.push(
|
|
(arr.arrayCompleteness & 1) << 7 | 0 << 6 | arr.nalUnitType & 63
|
|
);
|
|
bytes2.push(arr.nalUnits.length >> 8 & 255);
|
|
bytes2.push(arr.nalUnits.length & 255);
|
|
for (const nal of arr.nalUnits) {
|
|
bytes2.push(nal.length >> 8 & 255);
|
|
bytes2.push(nal.length & 255);
|
|
for (let i = 0; i < nal.length; i++) {
|
|
bytes2.push(nal[i]);
|
|
}
|
|
}
|
|
}
|
|
return new Uint8Array(bytes2);
|
|
};
|
|
var deserializeHevcDecoderConfigurationRecord = (data) => {
|
|
try {
|
|
const view2 = toDataView(data);
|
|
let offset = 0;
|
|
const configurationVersion = view2.getUint8(offset++);
|
|
const byte1 = view2.getUint8(offset++);
|
|
const generalProfileSpace = byte1 >> 6 & 3;
|
|
const generalTierFlag = byte1 >> 5 & 1;
|
|
const generalProfileIdc = byte1 & 31;
|
|
const generalProfileCompatibilityFlags = view2.getUint32(offset, false);
|
|
offset += 4;
|
|
const generalConstraintIndicatorFlags = data.subarray(offset, offset + 6);
|
|
offset += 6;
|
|
const generalLevelIdc = view2.getUint8(offset++);
|
|
const minSpatialSegmentationIdc = (view2.getUint8(offset++) & 15) << 8 | view2.getUint8(offset++);
|
|
const parallelismType = view2.getUint8(offset++) & 3;
|
|
const chromaFormatIdc = view2.getUint8(offset++) & 3;
|
|
const bitDepthLumaMinus8 = view2.getUint8(offset++) & 7;
|
|
const bitDepthChromaMinus8 = view2.getUint8(offset++) & 7;
|
|
const avgFrameRate = view2.getUint16(offset, false);
|
|
offset += 2;
|
|
const byte21 = view2.getUint8(offset++);
|
|
const constantFrameRate = byte21 >> 6 & 3;
|
|
const numTemporalLayers = byte21 >> 3 & 7;
|
|
const temporalIdNested = byte21 >> 2 & 1;
|
|
const lengthSizeMinusOne = byte21 & 3;
|
|
const numOfArrays = view2.getUint8(offset++);
|
|
const arrays = [];
|
|
for (let i = 0; i < numOfArrays; i++) {
|
|
const arrByte = view2.getUint8(offset++);
|
|
const arrayCompleteness = arrByte >> 7 & 1;
|
|
const nalUnitType = arrByte & 63;
|
|
const numNalus = view2.getUint16(offset, false);
|
|
offset += 2;
|
|
const nalUnits = [];
|
|
for (let j = 0; j < numNalus; j++) {
|
|
const nalUnitLength = view2.getUint16(offset, false);
|
|
offset += 2;
|
|
nalUnits.push(data.subarray(offset, offset + nalUnitLength));
|
|
offset += nalUnitLength;
|
|
}
|
|
arrays.push({
|
|
arrayCompleteness,
|
|
nalUnitType,
|
|
nalUnits
|
|
});
|
|
}
|
|
return {
|
|
configurationVersion,
|
|
generalProfileSpace,
|
|
generalTierFlag,
|
|
generalProfileIdc,
|
|
generalProfileCompatibilityFlags,
|
|
generalConstraintIndicatorFlags,
|
|
generalLevelIdc,
|
|
minSpatialSegmentationIdc,
|
|
parallelismType,
|
|
chromaFormatIdc,
|
|
bitDepthLumaMinus8,
|
|
bitDepthChromaMinus8,
|
|
avgFrameRate,
|
|
constantFrameRate,
|
|
numTemporalLayers,
|
|
temporalIdNested,
|
|
lengthSizeMinusOne,
|
|
arrays
|
|
};
|
|
} catch (error) {
|
|
console.error("Error deserializing HEVC Decoder Configuration Record:", error);
|
|
return null;
|
|
}
|
|
};
|
|
var extractVp9CodecInfoFromPacket = (packet) => {
|
|
const bitstream = new Bitstream(packet);
|
|
const frameMarker = bitstream.readBits(2);
|
|
if (frameMarker !== 2) {
|
|
return null;
|
|
}
|
|
const profileLowBit = bitstream.readBits(1);
|
|
const profileHighBit = bitstream.readBits(1);
|
|
const profile = (profileHighBit << 1) + profileLowBit;
|
|
if (profile === 3) {
|
|
bitstream.skipBits(1);
|
|
}
|
|
const showExistingFrame = bitstream.readBits(1);
|
|
if (showExistingFrame === 1) {
|
|
return null;
|
|
}
|
|
const frameType = bitstream.readBits(1);
|
|
if (frameType !== 0) {
|
|
return null;
|
|
}
|
|
bitstream.skipBits(2);
|
|
const syncCode = bitstream.readBits(24);
|
|
if (syncCode !== 4817730) {
|
|
return null;
|
|
}
|
|
let bitDepth = 8;
|
|
if (profile >= 2) {
|
|
const tenOrTwelveBit = bitstream.readBits(1);
|
|
bitDepth = tenOrTwelveBit ? 12 : 10;
|
|
}
|
|
const colorSpace = bitstream.readBits(3);
|
|
let chromaSubsampling = 0;
|
|
let videoFullRangeFlag = 0;
|
|
if (colorSpace !== 7) {
|
|
const colorRange = bitstream.readBits(1);
|
|
videoFullRangeFlag = colorRange;
|
|
if (profile === 1 || profile === 3) {
|
|
const subsamplingX = bitstream.readBits(1);
|
|
const subsamplingY = bitstream.readBits(1);
|
|
chromaSubsampling = !subsamplingX && !subsamplingY ? 3 : subsamplingX && !subsamplingY ? 2 : 1;
|
|
bitstream.skipBits(1);
|
|
} else {
|
|
chromaSubsampling = 1;
|
|
}
|
|
} else {
|
|
chromaSubsampling = 3;
|
|
videoFullRangeFlag = 1;
|
|
}
|
|
const widthMinusOne = bitstream.readBits(16);
|
|
const heightMinusOne = bitstream.readBits(16);
|
|
const width = widthMinusOne + 1;
|
|
const height = heightMinusOne + 1;
|
|
const pictureSize = width * height;
|
|
let level = last(VP9_LEVEL_TABLE).level;
|
|
for (const entry of VP9_LEVEL_TABLE) {
|
|
if (pictureSize <= entry.maxPictureSize) {
|
|
level = entry.level;
|
|
break;
|
|
}
|
|
}
|
|
const matrixCoefficients = colorSpace === 7 ? 0 : colorSpace === 2 ? 1 : colorSpace === 1 ? 6 : 2;
|
|
const colourPrimaries = colorSpace === 2 ? 1 : colorSpace === 1 ? 6 : 2;
|
|
const transferCharacteristics = colorSpace === 2 ? 1 : colorSpace === 1 ? 6 : 2;
|
|
return {
|
|
profile,
|
|
level,
|
|
bitDepth,
|
|
chromaSubsampling,
|
|
videoFullRangeFlag,
|
|
colourPrimaries,
|
|
transferCharacteristics,
|
|
matrixCoefficients
|
|
};
|
|
};
|
|
var iterateAv1PacketObus = function* (packet) {
|
|
const bitstream = new Bitstream(packet);
|
|
const readLeb128 = () => {
|
|
let value = 0;
|
|
for (let i = 0; i < 8; i++) {
|
|
const byte = bitstream.readAlignedByte();
|
|
value |= (byte & 127) << i * 7;
|
|
if (!(byte & 128)) {
|
|
break;
|
|
}
|
|
if (i === 7 && byte & 128) {
|
|
return null;
|
|
}
|
|
}
|
|
if (value >= 2 ** 32 - 1) {
|
|
return null;
|
|
}
|
|
return value;
|
|
};
|
|
while (bitstream.getBitsLeft() >= 8) {
|
|
bitstream.skipBits(1);
|
|
const obuType = bitstream.readBits(4);
|
|
const obuExtension = bitstream.readBits(1);
|
|
const obuHasSizeField = bitstream.readBits(1);
|
|
bitstream.skipBits(1);
|
|
if (obuExtension) {
|
|
bitstream.skipBits(8);
|
|
}
|
|
let obuSize;
|
|
if (obuHasSizeField) {
|
|
const obuSizeValue = readLeb128();
|
|
if (obuSizeValue === null) return;
|
|
obuSize = obuSizeValue;
|
|
} else {
|
|
obuSize = Math.floor(bitstream.getBitsLeft() / 8);
|
|
}
|
|
assert(bitstream.pos % 8 === 0);
|
|
yield {
|
|
type: obuType,
|
|
data: packet.subarray(bitstream.pos / 8, bitstream.pos / 8 + obuSize)
|
|
};
|
|
bitstream.skipBits(obuSize * 8);
|
|
}
|
|
};
|
|
var extractAv1CodecInfoFromPacket = (packet) => {
|
|
for (const { type, data } of iterateAv1PacketObus(packet)) {
|
|
if (type !== 1) {
|
|
continue;
|
|
}
|
|
const bitstream = new Bitstream(data);
|
|
const seqProfile = bitstream.readBits(3);
|
|
const stillPicture = bitstream.readBits(1);
|
|
const reducedStillPictureHeader = bitstream.readBits(1);
|
|
let seqLevel = 0;
|
|
let seqTier = 0;
|
|
let bufferDelayLengthMinus1 = 0;
|
|
if (reducedStillPictureHeader) {
|
|
seqLevel = bitstream.readBits(5);
|
|
} else {
|
|
const timingInfoPresentFlag = bitstream.readBits(1);
|
|
if (timingInfoPresentFlag) {
|
|
bitstream.skipBits(32);
|
|
bitstream.skipBits(32);
|
|
const equalPictureInterval = bitstream.readBits(1);
|
|
if (equalPictureInterval) {
|
|
return null;
|
|
}
|
|
}
|
|
const decoderModelInfoPresentFlag = bitstream.readBits(1);
|
|
if (decoderModelInfoPresentFlag) {
|
|
bufferDelayLengthMinus1 = bitstream.readBits(5);
|
|
bitstream.skipBits(32);
|
|
bitstream.skipBits(5);
|
|
bitstream.skipBits(5);
|
|
}
|
|
const operatingPointsCntMinus1 = bitstream.readBits(5);
|
|
for (let i = 0; i <= operatingPointsCntMinus1; i++) {
|
|
bitstream.skipBits(12);
|
|
const seqLevelIdx = bitstream.readBits(5);
|
|
if (i === 0) {
|
|
seqLevel = seqLevelIdx;
|
|
}
|
|
if (seqLevelIdx > 7) {
|
|
const seqTierTemp = bitstream.readBits(1);
|
|
if (i === 0) {
|
|
seqTier = seqTierTemp;
|
|
}
|
|
}
|
|
if (decoderModelInfoPresentFlag) {
|
|
const decoderModelPresentForThisOp = bitstream.readBits(1);
|
|
if (decoderModelPresentForThisOp) {
|
|
const n = bufferDelayLengthMinus1 + 1;
|
|
bitstream.skipBits(n);
|
|
bitstream.skipBits(n);
|
|
bitstream.skipBits(1);
|
|
}
|
|
}
|
|
const initialDisplayDelayPresentFlag = bitstream.readBits(1);
|
|
if (initialDisplayDelayPresentFlag) {
|
|
bitstream.skipBits(4);
|
|
}
|
|
}
|
|
}
|
|
const frameWidthBitsMinus1 = bitstream.readBits(4);
|
|
const frameHeightBitsMinus1 = bitstream.readBits(4);
|
|
const n1 = frameWidthBitsMinus1 + 1;
|
|
bitstream.skipBits(n1);
|
|
const n2 = frameHeightBitsMinus1 + 1;
|
|
bitstream.skipBits(n2);
|
|
let frameIdNumbersPresentFlag = 0;
|
|
if (reducedStillPictureHeader) {
|
|
frameIdNumbersPresentFlag = 0;
|
|
} else {
|
|
frameIdNumbersPresentFlag = bitstream.readBits(1);
|
|
}
|
|
if (frameIdNumbersPresentFlag) {
|
|
bitstream.skipBits(4);
|
|
bitstream.skipBits(3);
|
|
}
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
if (!reducedStillPictureHeader) {
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
const enableOrderHint = bitstream.readBits(1);
|
|
if (enableOrderHint) {
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
}
|
|
const seqChooseScreenContentTools = bitstream.readBits(1);
|
|
let seqForceScreenContentTools = 0;
|
|
if (seqChooseScreenContentTools) {
|
|
seqForceScreenContentTools = 2;
|
|
} else {
|
|
seqForceScreenContentTools = bitstream.readBits(1);
|
|
}
|
|
if (seqForceScreenContentTools > 0) {
|
|
const seqChooseIntegerMv = bitstream.readBits(1);
|
|
if (!seqChooseIntegerMv) {
|
|
bitstream.skipBits(1);
|
|
}
|
|
}
|
|
if (enableOrderHint) {
|
|
bitstream.skipBits(3);
|
|
}
|
|
}
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
const highBitdepth = bitstream.readBits(1);
|
|
let bitDepth = 8;
|
|
if (seqProfile === 2 && highBitdepth) {
|
|
const twelveBit = bitstream.readBits(1);
|
|
bitDepth = twelveBit ? 12 : 10;
|
|
} else if (seqProfile <= 2) {
|
|
bitDepth = highBitdepth ? 10 : 8;
|
|
}
|
|
let monochrome = 0;
|
|
if (seqProfile !== 1) {
|
|
monochrome = bitstream.readBits(1);
|
|
}
|
|
let chromaSubsamplingX = 1;
|
|
let chromaSubsamplingY = 1;
|
|
let chromaSamplePosition = 0;
|
|
if (!monochrome) {
|
|
if (seqProfile === 0) {
|
|
chromaSubsamplingX = 1;
|
|
chromaSubsamplingY = 1;
|
|
} else if (seqProfile === 1) {
|
|
chromaSubsamplingX = 0;
|
|
chromaSubsamplingY = 0;
|
|
} else {
|
|
if (bitDepth === 12) {
|
|
chromaSubsamplingX = bitstream.readBits(1);
|
|
if (chromaSubsamplingX) {
|
|
chromaSubsamplingY = bitstream.readBits(1);
|
|
}
|
|
}
|
|
}
|
|
if (chromaSubsamplingX && chromaSubsamplingY) {
|
|
chromaSamplePosition = bitstream.readBits(2);
|
|
}
|
|
}
|
|
return {
|
|
profile: seqProfile,
|
|
level: seqLevel,
|
|
tier: seqTier,
|
|
bitDepth,
|
|
monochrome,
|
|
chromaSubsamplingX,
|
|
chromaSubsamplingY,
|
|
chromaSamplePosition
|
|
};
|
|
}
|
|
return null;
|
|
};
|
|
var parseOpusIdentificationHeader = (bytes2) => {
|
|
const view2 = toDataView(bytes2);
|
|
const outputChannelCount = view2.getUint8(9);
|
|
const preSkip = view2.getUint16(10, true);
|
|
const inputSampleRate = view2.getUint32(12, true);
|
|
const outputGain = view2.getInt16(16, true);
|
|
const channelMappingFamily = view2.getUint8(18);
|
|
let channelMappingTable = null;
|
|
if (channelMappingFamily) {
|
|
channelMappingTable = bytes2.subarray(19, 19 + 2 + outputChannelCount);
|
|
}
|
|
return {
|
|
outputChannelCount,
|
|
preSkip,
|
|
inputSampleRate,
|
|
outputGain,
|
|
channelMappingFamily,
|
|
channelMappingTable
|
|
};
|
|
};
|
|
var OPUS_FRAME_DURATION_TABLE = [
|
|
480,
|
|
960,
|
|
1920,
|
|
2880,
|
|
480,
|
|
960,
|
|
1920,
|
|
2880,
|
|
480,
|
|
960,
|
|
1920,
|
|
2880,
|
|
480,
|
|
960,
|
|
480,
|
|
960,
|
|
120,
|
|
240,
|
|
480,
|
|
960,
|
|
120,
|
|
240,
|
|
480,
|
|
960,
|
|
120,
|
|
240,
|
|
480,
|
|
960,
|
|
120,
|
|
240,
|
|
480,
|
|
960
|
|
];
|
|
var parseOpusTocByte = (packet) => {
|
|
const config = packet[0] >> 3;
|
|
return {
|
|
durationInSamples: OPUS_FRAME_DURATION_TABLE[config]
|
|
};
|
|
};
|
|
var parseModesFromVorbisSetupPacket = (setupHeader) => {
|
|
if (setupHeader.length < 7) {
|
|
throw new Error("Setup header is too short.");
|
|
}
|
|
if (setupHeader[0] !== 5) {
|
|
throw new Error("Wrong packet type in Setup header.");
|
|
}
|
|
const signature = String.fromCharCode(...setupHeader.slice(1, 7));
|
|
if (signature !== "vorbis") {
|
|
throw new Error("Invalid packet signature in Setup header.");
|
|
}
|
|
const bufSize = setupHeader.length;
|
|
const revBuffer = new Uint8Array(bufSize);
|
|
for (let i = 0; i < bufSize; i++) {
|
|
revBuffer[i] = setupHeader[bufSize - 1 - i];
|
|
}
|
|
const bitstream = new Bitstream(revBuffer);
|
|
let gotFramingBit = 0;
|
|
while (bitstream.getBitsLeft() > 97) {
|
|
if (bitstream.readBits(1) === 1) {
|
|
gotFramingBit = bitstream.pos;
|
|
break;
|
|
}
|
|
}
|
|
if (gotFramingBit === 0) {
|
|
throw new Error("Invalid Setup header: framing bit not found.");
|
|
}
|
|
let modeCount = 0;
|
|
let gotModeHeader = false;
|
|
let lastModeCount = 0;
|
|
while (bitstream.getBitsLeft() >= 97) {
|
|
const tempPos = bitstream.pos;
|
|
const a = bitstream.readBits(8);
|
|
const b = bitstream.readBits(16);
|
|
const c = bitstream.readBits(16);
|
|
if (a > 63 || b !== 0 || c !== 0) {
|
|
bitstream.pos = tempPos;
|
|
break;
|
|
}
|
|
bitstream.skipBits(1);
|
|
modeCount++;
|
|
if (modeCount > 64) {
|
|
break;
|
|
}
|
|
const bsClone = bitstream.clone();
|
|
const candidate = bsClone.readBits(6) + 1;
|
|
if (candidate === modeCount) {
|
|
gotModeHeader = true;
|
|
lastModeCount = modeCount;
|
|
}
|
|
}
|
|
if (!gotModeHeader) {
|
|
throw new Error("Invalid Setup header: mode header not found.");
|
|
}
|
|
if (lastModeCount > 63) {
|
|
throw new Error(`Unsupported mode count: ${lastModeCount}.`);
|
|
}
|
|
const finalModeCount = lastModeCount;
|
|
bitstream.pos = 0;
|
|
bitstream.skipBits(gotFramingBit);
|
|
const modeBlockflags = Array(finalModeCount).fill(0);
|
|
for (let i = finalModeCount - 1; i >= 0; i--) {
|
|
bitstream.skipBits(40);
|
|
modeBlockflags[i] = bitstream.readBits(1);
|
|
}
|
|
return { modeBlockflags };
|
|
};
|
|
var determineVideoPacketType = (codec, decoderConfig, packetData) => {
|
|
switch (codec) {
|
|
case "avc":
|
|
{
|
|
for (const loc of iterateAvcNalUnits(packetData, decoderConfig)) {
|
|
const nalTypeByte = packetData[loc.offset];
|
|
const type = extractNalUnitTypeForAvc(nalTypeByte);
|
|
if (type >= 1 /* NON_IDR_SLICE */ && type <= 4 /* SLICE_DPC */) {
|
|
return "delta";
|
|
}
|
|
if (type === 5 /* IDR */) {
|
|
return "key";
|
|
}
|
|
if (type === 6 /* SEI */ && (!isChromium() || getChromiumVersion() >= 144)) {
|
|
const nalUnit = packetData.subarray(loc.offset, loc.offset + loc.length);
|
|
const bytes2 = removeEmulationPreventionBytes(nalUnit);
|
|
let pos = 1;
|
|
do {
|
|
let payloadType = 0;
|
|
while (true) {
|
|
const nextByte = bytes2[pos++];
|
|
if (nextByte === void 0) break;
|
|
payloadType += nextByte;
|
|
if (nextByte < 255) {
|
|
break;
|
|
}
|
|
}
|
|
let payloadSize = 0;
|
|
while (true) {
|
|
const nextByte = bytes2[pos++];
|
|
if (nextByte === void 0) break;
|
|
payloadSize += nextByte;
|
|
if (nextByte < 255) {
|
|
break;
|
|
}
|
|
}
|
|
const PAYLOAD_TYPE_RECOVERY_POINT = 6;
|
|
if (payloadType === PAYLOAD_TYPE_RECOVERY_POINT) {
|
|
const bitstream = new Bitstream(bytes2);
|
|
bitstream.pos = 8 * pos;
|
|
const recoveryFrameCount = readExpGolomb(bitstream);
|
|
const exactMatchFlag = bitstream.readBits(1);
|
|
if (recoveryFrameCount === 0 && exactMatchFlag === 1) {
|
|
return "key";
|
|
}
|
|
}
|
|
pos += payloadSize;
|
|
} while (pos < bytes2.length - 1);
|
|
}
|
|
}
|
|
return "delta";
|
|
}
|
|
;
|
|
case "hevc":
|
|
{
|
|
for (const loc of iterateHevcNalUnits(packetData, decoderConfig)) {
|
|
const type = extractNalUnitTypeForHevc(packetData[loc.offset]);
|
|
if (type < 16 /* BLA_W_LP */) {
|
|
return "delta";
|
|
}
|
|
if (type <= 23 /* RSV_IRAP_VCL23 */) {
|
|
return "key";
|
|
}
|
|
}
|
|
return "delta";
|
|
}
|
|
;
|
|
case "vp8":
|
|
{
|
|
const frameType = packetData[0] & 1;
|
|
return frameType === 0 ? "key" : "delta";
|
|
}
|
|
;
|
|
case "vp9":
|
|
{
|
|
const bitstream = new Bitstream(packetData);
|
|
if (bitstream.readBits(2) !== 2) {
|
|
return null;
|
|
}
|
|
;
|
|
const profileLowBit = bitstream.readBits(1);
|
|
const profileHighBit = bitstream.readBits(1);
|
|
const profile = (profileHighBit << 1) + profileLowBit;
|
|
if (profile === 3) {
|
|
bitstream.skipBits(1);
|
|
}
|
|
const showExistingFrame = bitstream.readBits(1);
|
|
if (showExistingFrame) {
|
|
return null;
|
|
}
|
|
const frameType = bitstream.readBits(1);
|
|
return frameType === 0 ? "key" : "delta";
|
|
}
|
|
;
|
|
case "av1":
|
|
{
|
|
let reducedStillPictureHeader = false;
|
|
for (const { type, data } of iterateAv1PacketObus(packetData)) {
|
|
if (type === 1) {
|
|
const bitstream = new Bitstream(data);
|
|
bitstream.skipBits(4);
|
|
reducedStillPictureHeader = !!bitstream.readBits(1);
|
|
} else if (type === 3 || type === 6 || type === 7) {
|
|
if (reducedStillPictureHeader) {
|
|
return "key";
|
|
}
|
|
const bitstream = new Bitstream(data);
|
|
const showExistingFrame = bitstream.readBits(1);
|
|
if (showExistingFrame) {
|
|
return null;
|
|
}
|
|
const frameType = bitstream.readBits(2);
|
|
return frameType === 0 ? "key" : "delta";
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
;
|
|
default:
|
|
{
|
|
assertNever(codec);
|
|
assert(false);
|
|
}
|
|
;
|
|
}
|
|
};
|
|
var readVorbisComments = (bytes2, metadataTags) => {
|
|
const commentView = toDataView(bytes2);
|
|
let commentPos = 0;
|
|
const vendorStringLength = commentView.getUint32(commentPos, true);
|
|
commentPos += 4;
|
|
const vendorString = textDecoder.decode(
|
|
bytes2.subarray(commentPos, commentPos + vendorStringLength)
|
|
);
|
|
commentPos += vendorStringLength;
|
|
if (vendorStringLength > 0) {
|
|
metadataTags.raw ??= {};
|
|
metadataTags.raw["vendor"] ??= vendorString;
|
|
}
|
|
const listLength = commentView.getUint32(commentPos, true);
|
|
commentPos += 4;
|
|
for (let i = 0; i < listLength; i++) {
|
|
const stringLength = commentView.getUint32(commentPos, true);
|
|
commentPos += 4;
|
|
const string = textDecoder.decode(
|
|
bytes2.subarray(commentPos, commentPos + stringLength)
|
|
);
|
|
commentPos += stringLength;
|
|
const separatorIndex = string.indexOf("=");
|
|
if (separatorIndex === -1) {
|
|
continue;
|
|
}
|
|
const key = string.slice(0, separatorIndex).toUpperCase();
|
|
const value = string.slice(separatorIndex + 1);
|
|
metadataTags.raw ??= {};
|
|
metadataTags.raw[key] ??= value;
|
|
switch (key) {
|
|
case "TITLE":
|
|
{
|
|
metadataTags.title ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "DESCRIPTION":
|
|
{
|
|
metadataTags.description ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "ARTIST":
|
|
{
|
|
metadataTags.artist ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "ALBUM":
|
|
{
|
|
metadataTags.album ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "ALBUMARTIST":
|
|
{
|
|
metadataTags.albumArtist ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "COMMENT":
|
|
{
|
|
metadataTags.comment ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "LYRICS":
|
|
{
|
|
metadataTags.lyrics ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "TRACKNUMBER":
|
|
{
|
|
const parts = value.split("/");
|
|
const trackNum = Number.parseInt(parts[0], 10);
|
|
const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);
|
|
if (Number.isInteger(trackNum) && trackNum > 0) {
|
|
metadataTags.trackNumber ??= trackNum;
|
|
}
|
|
if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {
|
|
metadataTags.tracksTotal ??= tracksTotal;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "TRACKTOTAL":
|
|
{
|
|
const tracksTotal = Number.parseInt(value, 10);
|
|
if (Number.isInteger(tracksTotal) && tracksTotal > 0) {
|
|
metadataTags.tracksTotal ??= tracksTotal;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "DISCNUMBER":
|
|
{
|
|
const parts = value.split("/");
|
|
const discNum = Number.parseInt(parts[0], 10);
|
|
const discsTotal = parts[1] && Number.parseInt(parts[1], 10);
|
|
if (Number.isInteger(discNum) && discNum > 0) {
|
|
metadataTags.discNumber ??= discNum;
|
|
}
|
|
if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) {
|
|
metadataTags.discsTotal ??= discsTotal;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "DISCTOTAL":
|
|
{
|
|
const discsTotal = Number.parseInt(value, 10);
|
|
if (Number.isInteger(discsTotal) && discsTotal > 0) {
|
|
metadataTags.discsTotal ??= discsTotal;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "DATE":
|
|
{
|
|
const date = new Date(value);
|
|
if (!Number.isNaN(date.getTime())) {
|
|
metadataTags.date ??= date;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "GENRE":
|
|
{
|
|
metadataTags.genre ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "METADATA_BLOCK_PICTURE":
|
|
{
|
|
const decoded = base64ToBytes(value);
|
|
const view2 = toDataView(decoded);
|
|
const pictureType = view2.getUint32(0, false);
|
|
const mediaTypeLength = view2.getUint32(4, false);
|
|
const mediaType = String.fromCharCode(...decoded.subarray(8, 8 + mediaTypeLength));
|
|
const descriptionLength = view2.getUint32(8 + mediaTypeLength, false);
|
|
const description = textDecoder.decode(decoded.subarray(
|
|
12 + mediaTypeLength,
|
|
12 + mediaTypeLength + descriptionLength
|
|
));
|
|
const dataLength = view2.getUint32(mediaTypeLength + descriptionLength + 28);
|
|
const data = decoded.subarray(
|
|
mediaTypeLength + descriptionLength + 32,
|
|
mediaTypeLength + descriptionLength + 32 + dataLength
|
|
);
|
|
metadataTags.images ??= [];
|
|
metadataTags.images.push({
|
|
data,
|
|
mimeType: mediaType,
|
|
kind: pictureType === 3 ? "coverFront" : pictureType === 4 ? "coverBack" : "unknown",
|
|
name: void 0,
|
|
description: description || void 0
|
|
});
|
|
}
|
|
;
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
var createVorbisComments = (headerBytes, tags, writeImages) => {
|
|
const commentHeaderParts = [
|
|
headerBytes
|
|
];
|
|
const vendorString = "Mediabunny";
|
|
const encodedVendorString = textEncoder.encode(vendorString);
|
|
let currentBuffer = new Uint8Array(4 + encodedVendorString.length);
|
|
let currentView = new DataView(currentBuffer.buffer);
|
|
currentView.setUint32(0, encodedVendorString.length, true);
|
|
currentBuffer.set(encodedVendorString, 4);
|
|
commentHeaderParts.push(currentBuffer);
|
|
const writtenTags = /* @__PURE__ */ new Set();
|
|
const addCommentTag = (key, value) => {
|
|
const joined = `${key}=${value}`;
|
|
const encoded = textEncoder.encode(joined);
|
|
currentBuffer = new Uint8Array(4 + encoded.length);
|
|
currentView = new DataView(currentBuffer.buffer);
|
|
currentView.setUint32(0, encoded.length, true);
|
|
currentBuffer.set(encoded, 4);
|
|
commentHeaderParts.push(currentBuffer);
|
|
writtenTags.add(key);
|
|
};
|
|
for (const { key, value } of keyValueIterator(tags)) {
|
|
switch (key) {
|
|
case "title":
|
|
{
|
|
addCommentTag("TITLE", value);
|
|
}
|
|
;
|
|
break;
|
|
case "description":
|
|
{
|
|
addCommentTag("DESCRIPTION", value);
|
|
}
|
|
;
|
|
break;
|
|
case "artist":
|
|
{
|
|
addCommentTag("ARTIST", value);
|
|
}
|
|
;
|
|
break;
|
|
case "album":
|
|
{
|
|
addCommentTag("ALBUM", value);
|
|
}
|
|
;
|
|
break;
|
|
case "albumArtist":
|
|
{
|
|
addCommentTag("ALBUMARTIST", value);
|
|
}
|
|
;
|
|
break;
|
|
case "genre":
|
|
{
|
|
addCommentTag("GENRE", value);
|
|
}
|
|
;
|
|
break;
|
|
case "date":
|
|
{
|
|
const rawVersion = tags.raw?.["DATE"] ?? tags.raw?.["date"];
|
|
if (rawVersion && typeof rawVersion === "string") {
|
|
addCommentTag("DATE", rawVersion);
|
|
} else {
|
|
addCommentTag("DATE", value.toISOString().slice(0, 10));
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "comment":
|
|
{
|
|
addCommentTag("COMMENT", value);
|
|
}
|
|
;
|
|
break;
|
|
case "lyrics":
|
|
{
|
|
addCommentTag("LYRICS", value);
|
|
}
|
|
;
|
|
break;
|
|
case "trackNumber":
|
|
{
|
|
addCommentTag("TRACKNUMBER", value.toString());
|
|
}
|
|
;
|
|
break;
|
|
case "tracksTotal":
|
|
{
|
|
addCommentTag("TRACKTOTAL", value.toString());
|
|
}
|
|
;
|
|
break;
|
|
case "discNumber":
|
|
{
|
|
addCommentTag("DISCNUMBER", value.toString());
|
|
}
|
|
;
|
|
break;
|
|
case "discsTotal":
|
|
{
|
|
addCommentTag("DISCTOTAL", value.toString());
|
|
}
|
|
;
|
|
break;
|
|
case "images":
|
|
{
|
|
if (!writeImages) {
|
|
break;
|
|
}
|
|
for (const image of value) {
|
|
const pictureType = image.kind === "coverFront" ? 3 : image.kind === "coverBack" ? 4 : 0;
|
|
const encodedMediaType = new Uint8Array(image.mimeType.length);
|
|
for (let i = 0; i < image.mimeType.length; i++) {
|
|
encodedMediaType[i] = image.mimeType.charCodeAt(i);
|
|
}
|
|
const encodedDescription = textEncoder.encode(image.description ?? "");
|
|
const buffer = new Uint8Array(
|
|
4 + 4 + encodedMediaType.length + 4 + encodedDescription.length + 16 + 4 + image.data.length
|
|
// Picture data
|
|
);
|
|
const view2 = toDataView(buffer);
|
|
view2.setUint32(0, pictureType, false);
|
|
view2.setUint32(4, encodedMediaType.length, false);
|
|
buffer.set(encodedMediaType, 8);
|
|
view2.setUint32(8 + encodedMediaType.length, encodedDescription.length, false);
|
|
buffer.set(encodedDescription, 12 + encodedMediaType.length);
|
|
view2.setUint32(
|
|
28 + encodedMediaType.length + encodedDescription.length,
|
|
image.data.length,
|
|
false
|
|
);
|
|
buffer.set(
|
|
image.data,
|
|
32 + encodedMediaType.length + encodedDescription.length
|
|
);
|
|
const encoded = bytesToBase64(buffer);
|
|
addCommentTag("METADATA_BLOCK_PICTURE", encoded);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "raw":
|
|
{
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
assertNever(key);
|
|
}
|
|
}
|
|
if (tags.raw) {
|
|
for (const key in tags.raw) {
|
|
const value = tags.raw[key] ?? tags.raw[key.toLowerCase()];
|
|
if (key === "vendor" || value == null || writtenTags.has(key)) {
|
|
continue;
|
|
}
|
|
if (typeof value === "string") {
|
|
addCommentTag(key, value);
|
|
}
|
|
}
|
|
}
|
|
const listLengthBuffer = new Uint8Array(4);
|
|
toDataView(listLengthBuffer).setUint32(0, writtenTags.size, true);
|
|
commentHeaderParts.splice(2, 0, listLengthBuffer);
|
|
const commentHeaderLength = commentHeaderParts.reduce((a, b) => a + b.length, 0);
|
|
const commentHeader = new Uint8Array(commentHeaderLength);
|
|
let pos = 0;
|
|
for (const part of commentHeaderParts) {
|
|
commentHeader.set(part, pos);
|
|
pos += part.length;
|
|
}
|
|
return commentHeader;
|
|
};
|
|
var AC3_ACMOD_CHANNEL_COUNTS = [2, 1, 2, 3, 3, 4, 4, 5];
|
|
var parseAc3SyncFrame = (data) => {
|
|
if (data.length < 7) {
|
|
return null;
|
|
}
|
|
if (data[0] !== 11 || data[1] !== 119) {
|
|
return null;
|
|
}
|
|
const bitstream = new Bitstream(data);
|
|
bitstream.skipBits(16);
|
|
bitstream.skipBits(16);
|
|
const fscod = bitstream.readBits(2);
|
|
if (fscod === 3) {
|
|
return null;
|
|
}
|
|
const frmsizecod = bitstream.readBits(6);
|
|
const bsid = bitstream.readBits(5);
|
|
if (bsid > 8) {
|
|
return null;
|
|
}
|
|
const bsmod = bitstream.readBits(3);
|
|
const acmod = bitstream.readBits(3);
|
|
if ((acmod & 1) !== 0 && acmod !== 1) {
|
|
bitstream.skipBits(2);
|
|
}
|
|
if ((acmod & 4) !== 0) {
|
|
bitstream.skipBits(2);
|
|
}
|
|
if (acmod === 2) {
|
|
bitstream.skipBits(2);
|
|
}
|
|
const lfeon = bitstream.readBits(1);
|
|
const bitRateCode = Math.floor(frmsizecod / 2);
|
|
return { fscod, bsid, bsmod, acmod, lfeon, bitRateCode };
|
|
};
|
|
var AC3_FRAME_SIZES = [
|
|
// frmsizecod, [48kHz, 44.1kHz, 32kHz] in bytes
|
|
64 * 2,
|
|
69 * 2,
|
|
96 * 2,
|
|
64 * 2,
|
|
70 * 2,
|
|
96 * 2,
|
|
80 * 2,
|
|
87 * 2,
|
|
120 * 2,
|
|
80 * 2,
|
|
88 * 2,
|
|
120 * 2,
|
|
96 * 2,
|
|
104 * 2,
|
|
144 * 2,
|
|
96 * 2,
|
|
105 * 2,
|
|
144 * 2,
|
|
112 * 2,
|
|
121 * 2,
|
|
168 * 2,
|
|
112 * 2,
|
|
122 * 2,
|
|
168 * 2,
|
|
128 * 2,
|
|
139 * 2,
|
|
192 * 2,
|
|
128 * 2,
|
|
140 * 2,
|
|
192 * 2,
|
|
160 * 2,
|
|
174 * 2,
|
|
240 * 2,
|
|
160 * 2,
|
|
175 * 2,
|
|
240 * 2,
|
|
192 * 2,
|
|
208 * 2,
|
|
288 * 2,
|
|
192 * 2,
|
|
209 * 2,
|
|
288 * 2,
|
|
224 * 2,
|
|
243 * 2,
|
|
336 * 2,
|
|
224 * 2,
|
|
244 * 2,
|
|
336 * 2,
|
|
256 * 2,
|
|
278 * 2,
|
|
384 * 2,
|
|
256 * 2,
|
|
279 * 2,
|
|
384 * 2,
|
|
320 * 2,
|
|
348 * 2,
|
|
480 * 2,
|
|
320 * 2,
|
|
349 * 2,
|
|
480 * 2,
|
|
384 * 2,
|
|
417 * 2,
|
|
576 * 2,
|
|
384 * 2,
|
|
418 * 2,
|
|
576 * 2,
|
|
448 * 2,
|
|
487 * 2,
|
|
672 * 2,
|
|
448 * 2,
|
|
488 * 2,
|
|
672 * 2,
|
|
512 * 2,
|
|
557 * 2,
|
|
768 * 2,
|
|
512 * 2,
|
|
558 * 2,
|
|
768 * 2,
|
|
640 * 2,
|
|
696 * 2,
|
|
960 * 2,
|
|
640 * 2,
|
|
697 * 2,
|
|
960 * 2,
|
|
768 * 2,
|
|
835 * 2,
|
|
1152 * 2,
|
|
768 * 2,
|
|
836 * 2,
|
|
1152 * 2,
|
|
896 * 2,
|
|
975 * 2,
|
|
1344 * 2,
|
|
896 * 2,
|
|
976 * 2,
|
|
1344 * 2,
|
|
1024 * 2,
|
|
1114 * 2,
|
|
1536 * 2,
|
|
1024 * 2,
|
|
1115 * 2,
|
|
1536 * 2,
|
|
1152 * 2,
|
|
1253 * 2,
|
|
1728 * 2,
|
|
1152 * 2,
|
|
1254 * 2,
|
|
1728 * 2,
|
|
1280 * 2,
|
|
1393 * 2,
|
|
1920 * 2,
|
|
1280 * 2,
|
|
1394 * 2,
|
|
1920 * 2
|
|
];
|
|
var AC3_SAMPLES_PER_FRAME = 1536;
|
|
var AC3_REGISTRATION_DESCRIPTOR = new Uint8Array([5, 4, 65, 67, 45, 51]);
|
|
var EAC3_REGISTRATION_DESCRIPTOR = new Uint8Array([5, 4, 69, 65, 67, 51]);
|
|
var EAC3_NUMBLKS_TABLE = [1, 2, 3, 6];
|
|
var parseEac3SyncFrame = (data) => {
|
|
if (data.length < 6) {
|
|
return null;
|
|
}
|
|
if (data[0] !== 11 || data[1] !== 119) {
|
|
return null;
|
|
}
|
|
const bitstream = new Bitstream(data);
|
|
bitstream.skipBits(16);
|
|
const strmtyp = bitstream.readBits(2);
|
|
bitstream.skipBits(3);
|
|
if (strmtyp !== 0 && strmtyp !== 2) {
|
|
return null;
|
|
}
|
|
const frmsiz = bitstream.readBits(11);
|
|
const fscod = bitstream.readBits(2);
|
|
let fscod2 = 0;
|
|
let numblkscod;
|
|
if (fscod === 3) {
|
|
fscod2 = bitstream.readBits(2);
|
|
numblkscod = 3;
|
|
} else {
|
|
numblkscod = bitstream.readBits(2);
|
|
}
|
|
const acmod = bitstream.readBits(3);
|
|
const lfeon = bitstream.readBits(1);
|
|
const bsid = bitstream.readBits(5);
|
|
if (bsid < 11 || bsid > 16) {
|
|
return null;
|
|
}
|
|
const numblks = EAC3_NUMBLKS_TABLE[numblkscod];
|
|
let fs;
|
|
if (fscod < 3) {
|
|
fs = AC3_SAMPLE_RATES[fscod] / 1e3;
|
|
} else {
|
|
fs = EAC3_REDUCED_SAMPLE_RATES[fscod2] / 1e3;
|
|
}
|
|
const dataRate = Math.round((frmsiz + 1) * fs / (numblks * 16));
|
|
const bsmod = 0;
|
|
const numDepSub = 0;
|
|
const chanLoc = 0;
|
|
const substream = {
|
|
fscod,
|
|
fscod2,
|
|
bsid,
|
|
bsmod,
|
|
acmod,
|
|
lfeon,
|
|
numDepSub,
|
|
chanLoc
|
|
};
|
|
return {
|
|
dataRate,
|
|
substreams: [substream]
|
|
};
|
|
};
|
|
var parseEac3Config = (data) => {
|
|
if (data.length < 2) {
|
|
return null;
|
|
}
|
|
const bitstream = new Bitstream(data);
|
|
const dataRate = bitstream.readBits(13);
|
|
const numIndSub = bitstream.readBits(3);
|
|
const substreams = [];
|
|
for (let i = 0; i <= numIndSub; i++) {
|
|
if (Math.ceil(bitstream.pos / 8) + 3 > data.length) {
|
|
break;
|
|
}
|
|
const fscod = bitstream.readBits(2);
|
|
const bsid = bitstream.readBits(5);
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
const bsmod = bitstream.readBits(3);
|
|
const acmod = bitstream.readBits(3);
|
|
const lfeon = bitstream.readBits(1);
|
|
bitstream.skipBits(3);
|
|
const numDepSub = bitstream.readBits(4);
|
|
let chanLoc = 0;
|
|
if (numDepSub > 0) {
|
|
chanLoc = bitstream.readBits(9);
|
|
} else {
|
|
bitstream.skipBits(1);
|
|
}
|
|
substreams.push({
|
|
fscod,
|
|
fscod2: null,
|
|
bsid,
|
|
bsmod,
|
|
acmod,
|
|
lfeon,
|
|
numDepSub,
|
|
chanLoc
|
|
});
|
|
}
|
|
if (substreams.length === 0) {
|
|
return null;
|
|
}
|
|
return { dataRate, substreams };
|
|
};
|
|
var getEac3SampleRate = (config) => {
|
|
const sub = config.substreams[0];
|
|
assert(sub);
|
|
if (sub.fscod < 3) {
|
|
return AC3_SAMPLE_RATES[sub.fscod];
|
|
} else if (sub.fscod2 !== null && sub.fscod2 < 3) {
|
|
return EAC3_REDUCED_SAMPLE_RATES[sub.fscod2];
|
|
}
|
|
return null;
|
|
};
|
|
var getEac3ChannelCount = (config) => {
|
|
const sub = config.substreams[0];
|
|
assert(sub);
|
|
let channels = AC3_ACMOD_CHANNEL_COUNTS[sub.acmod] + sub.lfeon;
|
|
if (sub.numDepSub > 0) {
|
|
const CHAN_LOC_COUNTS = [2, 2, 1, 1, 2, 2, 2, 1, 1];
|
|
for (let bit = 0; bit < 9; bit++) {
|
|
if (sub.chanLoc & 1 << 8 - bit) {
|
|
channels += CHAN_LOC_COUNTS[bit];
|
|
}
|
|
}
|
|
}
|
|
return channels;
|
|
};
|
|
|
|
// src/demuxer.ts
|
|
var Demuxer = class {
|
|
constructor(input) {
|
|
this.input = input;
|
|
}
|
|
};
|
|
|
|
// src/custom-coder.ts
|
|
var CustomVideoDecoder = class {
|
|
/** Returns true if and only if the decoder can decode the given codec configuration. */
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
static supports(codec, config) {
|
|
return false;
|
|
}
|
|
};
|
|
var CustomAudioDecoder = class {
|
|
/** Returns true if and only if the decoder can decode the given codec configuration. */
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
static supports(codec, config) {
|
|
return false;
|
|
}
|
|
};
|
|
var CustomVideoEncoder = class {
|
|
/** Returns true if and only if the encoder can encode the given codec configuration. */
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
static supports(codec, config) {
|
|
return false;
|
|
}
|
|
};
|
|
var CustomAudioEncoder = class {
|
|
/** Returns true if and only if the encoder can encode the given codec configuration. */
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
static supports(codec, config) {
|
|
return false;
|
|
}
|
|
};
|
|
var customVideoDecoders = [];
|
|
var customAudioDecoders = [];
|
|
var customVideoEncoders = [];
|
|
var customAudioEncoders = [];
|
|
var registerDecoder = (decoder) => {
|
|
if (decoder.prototype instanceof CustomVideoDecoder) {
|
|
const casted = decoder;
|
|
if (customVideoDecoders.includes(casted)) {
|
|
console.warn("Video decoder already registered.");
|
|
return;
|
|
}
|
|
customVideoDecoders.push(casted);
|
|
} else if (decoder.prototype instanceof CustomAudioDecoder) {
|
|
const casted = decoder;
|
|
if (customAudioDecoders.includes(casted)) {
|
|
console.warn("Audio decoder already registered.");
|
|
return;
|
|
}
|
|
customAudioDecoders.push(casted);
|
|
} else {
|
|
throw new TypeError("Decoder must be a CustomVideoDecoder or CustomAudioDecoder.");
|
|
}
|
|
};
|
|
var registerEncoder = (encoder) => {
|
|
if (encoder.prototype instanceof CustomVideoEncoder) {
|
|
const casted = encoder;
|
|
if (customVideoEncoders.includes(casted)) {
|
|
console.warn("Video encoder already registered.");
|
|
return;
|
|
}
|
|
customVideoEncoders.push(casted);
|
|
} else if (encoder.prototype instanceof CustomAudioEncoder) {
|
|
const casted = encoder;
|
|
if (customAudioEncoders.includes(casted)) {
|
|
console.warn("Audio encoder already registered.");
|
|
return;
|
|
}
|
|
customAudioEncoders.push(casted);
|
|
} else {
|
|
throw new TypeError("Encoder must be a CustomVideoEncoder or CustomAudioEncoder.");
|
|
}
|
|
};
|
|
|
|
// src/packet.ts
|
|
var PLACEHOLDER_DATA = /* @__PURE__ */ new Uint8Array(0);
|
|
var EncodedPacket = class _EncodedPacket {
|
|
/** Creates a new {@link EncodedPacket} from raw bytes and timing information. */
|
|
constructor(data, type, timestamp, duration, sequenceNumber = -1, byteLength, sideData) {
|
|
this.data = data;
|
|
this.type = type;
|
|
this.timestamp = timestamp;
|
|
this.duration = duration;
|
|
this.sequenceNumber = sequenceNumber;
|
|
if (data === PLACEHOLDER_DATA && byteLength === void 0) {
|
|
throw new Error(
|
|
"Internal error: byteLength must be explicitly provided when constructing metadata-only packets."
|
|
);
|
|
}
|
|
if (byteLength === void 0) {
|
|
byteLength = data.byteLength;
|
|
}
|
|
if (!(data instanceof Uint8Array)) {
|
|
throw new TypeError("data must be a Uint8Array.");
|
|
}
|
|
if (type !== "key" && type !== "delta") {
|
|
throw new TypeError('type must be either "key" or "delta".');
|
|
}
|
|
if (!Number.isFinite(timestamp)) {
|
|
throw new TypeError("timestamp must be a number.");
|
|
}
|
|
if (!Number.isFinite(duration) || duration < 0) {
|
|
throw new TypeError("duration must be a non-negative number.");
|
|
}
|
|
if (!Number.isFinite(sequenceNumber)) {
|
|
throw new TypeError("sequenceNumber must be a number.");
|
|
}
|
|
if (!Number.isInteger(byteLength) || byteLength < 0) {
|
|
throw new TypeError("byteLength must be a non-negative integer.");
|
|
}
|
|
if (sideData !== void 0 && (typeof sideData !== "object" || !sideData)) {
|
|
throw new TypeError("sideData, when provided, must be an object.");
|
|
}
|
|
if (sideData?.alpha !== void 0 && !(sideData.alpha instanceof Uint8Array)) {
|
|
throw new TypeError("sideData.alpha, when provided, must be a Uint8Array.");
|
|
}
|
|
if (sideData?.alphaByteLength !== void 0 && (!Number.isInteger(sideData.alphaByteLength) || sideData.alphaByteLength < 0)) {
|
|
throw new TypeError("sideData.alphaByteLength, when provided, must be a non-negative integer.");
|
|
}
|
|
this.byteLength = byteLength;
|
|
this.sideData = sideData ?? {};
|
|
if (this.sideData.alpha && this.sideData.alphaByteLength === void 0) {
|
|
this.sideData.alphaByteLength = this.sideData.alpha.byteLength;
|
|
}
|
|
}
|
|
/**
|
|
* If this packet is a metadata-only packet. Metadata-only packets don't contain their packet data. They are the
|
|
* result of retrieving packets with {@link PacketRetrievalOptions.metadataOnly} set to `true`.
|
|
*/
|
|
get isMetadataOnly() {
|
|
return this.data === PLACEHOLDER_DATA;
|
|
}
|
|
/** The timestamp of this packet in microseconds. */
|
|
get microsecondTimestamp() {
|
|
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.timestamp);
|
|
}
|
|
/** The duration of this packet in microseconds. */
|
|
get microsecondDuration() {
|
|
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration);
|
|
}
|
|
/** Converts this packet to an
|
|
* [`EncodedVideoChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedVideoChunk) for use with the
|
|
* WebCodecs API. */
|
|
toEncodedVideoChunk() {
|
|
if (this.isMetadataOnly) {
|
|
throw new TypeError("Metadata-only packets cannot be converted to a video chunk.");
|
|
}
|
|
if (typeof EncodedVideoChunk === "undefined") {
|
|
throw new Error("Your browser does not support EncodedVideoChunk.");
|
|
}
|
|
return new EncodedVideoChunk({
|
|
data: this.data,
|
|
type: this.type,
|
|
timestamp: this.microsecondTimestamp,
|
|
duration: this.microsecondDuration
|
|
});
|
|
}
|
|
/**
|
|
* Converts this packet to an
|
|
* [`EncodedVideoChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedVideoChunk) for use with the
|
|
* WebCodecs API, using the alpha side data instead of the color data. Throws if no alpha side data is defined.
|
|
*/
|
|
alphaToEncodedVideoChunk(type = this.type) {
|
|
if (!this.sideData.alpha) {
|
|
throw new TypeError("This packet does not contain alpha side data.");
|
|
}
|
|
if (this.isMetadataOnly) {
|
|
throw new TypeError("Metadata-only packets cannot be converted to a video chunk.");
|
|
}
|
|
if (typeof EncodedVideoChunk === "undefined") {
|
|
throw new Error("Your browser does not support EncodedVideoChunk.");
|
|
}
|
|
return new EncodedVideoChunk({
|
|
data: this.sideData.alpha,
|
|
type,
|
|
timestamp: this.microsecondTimestamp,
|
|
duration: this.microsecondDuration
|
|
});
|
|
}
|
|
/** Converts this packet to an
|
|
* [`EncodedAudioChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedAudioChunk) for use with the
|
|
* WebCodecs API. */
|
|
toEncodedAudioChunk() {
|
|
if (this.isMetadataOnly) {
|
|
throw new TypeError("Metadata-only packets cannot be converted to an audio chunk.");
|
|
}
|
|
if (typeof EncodedAudioChunk === "undefined") {
|
|
throw new Error("Your browser does not support EncodedAudioChunk.");
|
|
}
|
|
return new EncodedAudioChunk({
|
|
data: this.data,
|
|
type: this.type,
|
|
timestamp: this.microsecondTimestamp,
|
|
duration: this.microsecondDuration
|
|
});
|
|
}
|
|
/**
|
|
* Creates an {@link EncodedPacket} from an
|
|
* [`EncodedVideoChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedVideoChunk) or
|
|
* [`EncodedAudioChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedAudioChunk). This method is useful
|
|
* for converting chunks from the WebCodecs API to `EncodedPacket` instances.
|
|
*/
|
|
static fromEncodedChunk(chunk, sideData) {
|
|
if (!(chunk instanceof EncodedVideoChunk || chunk instanceof EncodedAudioChunk)) {
|
|
throw new TypeError("chunk must be an EncodedVideoChunk or EncodedAudioChunk.");
|
|
}
|
|
const data = new Uint8Array(chunk.byteLength);
|
|
chunk.copyTo(data);
|
|
return new _EncodedPacket(
|
|
data,
|
|
chunk.type,
|
|
chunk.timestamp / 1e6,
|
|
(chunk.duration ?? 0) / 1e6,
|
|
void 0,
|
|
void 0,
|
|
sideData
|
|
);
|
|
}
|
|
/** Clones this packet while optionally modifying the new packet's data. */
|
|
clone(options) {
|
|
if (options !== void 0 && (typeof options !== "object" || options === null)) {
|
|
throw new TypeError("options, when provided, must be an object.");
|
|
}
|
|
if (options?.data !== void 0 && !(options.data instanceof Uint8Array)) {
|
|
throw new TypeError("options.data, when provided, must be a Uint8Array.");
|
|
}
|
|
if (options?.type !== void 0 && options.type !== "key" && options.type !== "delta") {
|
|
throw new TypeError('options.type, when provided, must be either "key" or "delta".');
|
|
}
|
|
if (options?.timestamp !== void 0 && !Number.isFinite(options.timestamp)) {
|
|
throw new TypeError("options.timestamp, when provided, must be a number.");
|
|
}
|
|
if (options?.duration !== void 0 && !Number.isFinite(options.duration)) {
|
|
throw new TypeError("options.duration, when provided, must be a number.");
|
|
}
|
|
if (options?.sequenceNumber !== void 0 && !Number.isFinite(options.sequenceNumber)) {
|
|
throw new TypeError("options.sequenceNumber, when provided, must be a number.");
|
|
}
|
|
if (options?.sideData !== void 0 && (typeof options.sideData !== "object" || options.sideData === null)) {
|
|
throw new TypeError("options.sideData, when provided, must be an object.");
|
|
}
|
|
return new _EncodedPacket(
|
|
options?.data ?? this.data,
|
|
options?.type ?? this.type,
|
|
options?.timestamp ?? this.timestamp,
|
|
options?.duration ?? this.duration,
|
|
options?.sequenceNumber ?? this.sequenceNumber,
|
|
this.byteLength,
|
|
options?.sideData ?? this.sideData
|
|
);
|
|
}
|
|
};
|
|
|
|
// src/pcm.ts
|
|
var toUlaw = (s16) => {
|
|
const MULAW_MAX = 8191;
|
|
const MULAW_BIAS = 33;
|
|
let number = s16;
|
|
let mask = 4096;
|
|
let sign = 0;
|
|
let position = 12;
|
|
let lsb = 0;
|
|
if (number < 0) {
|
|
number = -number;
|
|
sign = 128;
|
|
}
|
|
number += MULAW_BIAS;
|
|
if (number > MULAW_MAX) {
|
|
number = MULAW_MAX;
|
|
}
|
|
while ((number & mask) !== mask && position >= 5) {
|
|
mask >>= 1;
|
|
position--;
|
|
}
|
|
lsb = number >> position - 4 & 15;
|
|
return ~(sign | position - 5 << 4 | lsb) & 255;
|
|
};
|
|
var fromUlaw = (u82) => {
|
|
const MULAW_BIAS = 33;
|
|
let sign = 0;
|
|
let position = 0;
|
|
let number = ~u82;
|
|
if (number & 128) {
|
|
number &= ~(1 << 7);
|
|
sign = -1;
|
|
}
|
|
position = ((number & 240) >> 4) + 5;
|
|
const decoded = (1 << position | (number & 15) << position - 4 | 1 << position - 5) - MULAW_BIAS;
|
|
return sign === 0 ? decoded : -decoded;
|
|
};
|
|
var toAlaw = (s16) => {
|
|
const ALAW_MAX = 4095;
|
|
let mask = 2048;
|
|
let sign = 0;
|
|
let position = 11;
|
|
let lsb = 0;
|
|
let number = s16;
|
|
if (number < 0) {
|
|
number = -number;
|
|
sign = 128;
|
|
}
|
|
if (number > ALAW_MAX) {
|
|
number = ALAW_MAX;
|
|
}
|
|
while ((number & mask) !== mask && position >= 5) {
|
|
mask >>= 1;
|
|
position--;
|
|
}
|
|
lsb = number >> (position === 4 ? 1 : position - 4) & 15;
|
|
return (sign | position - 4 << 4 | lsb) ^ 85;
|
|
};
|
|
var fromAlaw = (u82) => {
|
|
let sign = 0;
|
|
let position = 0;
|
|
let number = u82 ^ 85;
|
|
if (number & 128) {
|
|
number &= ~(1 << 7);
|
|
sign = -1;
|
|
}
|
|
position = ((number & 240) >> 4) + 4;
|
|
let decoded = 0;
|
|
if (position !== 4) {
|
|
decoded = 1 << position | (number & 15) << position - 4 | 1 << position - 5;
|
|
} else {
|
|
decoded = number << 1 | 1;
|
|
}
|
|
return sign === 0 ? decoded : -decoded;
|
|
};
|
|
|
|
// src/sample.ts
|
|
polyfillSymbolDispose();
|
|
var lastVideoGcErrorLog = -Infinity;
|
|
var lastAudioGcErrorLog = -Infinity;
|
|
var finalizationRegistry = null;
|
|
if (typeof FinalizationRegistry !== "undefined") {
|
|
finalizationRegistry = new FinalizationRegistry((value) => {
|
|
const now = Date.now();
|
|
if (value.type === "video") {
|
|
if (now - lastVideoGcErrorLog >= 1e3) {
|
|
console.error(
|
|
`A VideoSample was garbage collected without first being closed. For proper resource management, make sure to call close() on all your VideoSamples as soon as you're done using them.`
|
|
);
|
|
lastVideoGcErrorLog = now;
|
|
}
|
|
if (typeof VideoFrame !== "undefined" && value.data instanceof VideoFrame) {
|
|
value.data.close();
|
|
}
|
|
} else {
|
|
if (now - lastAudioGcErrorLog >= 1e3) {
|
|
console.error(
|
|
`An AudioSample was garbage collected without first being closed. For proper resource management, make sure to call close() on all your AudioSamples as soon as you're done using them.`
|
|
);
|
|
lastAudioGcErrorLog = now;
|
|
}
|
|
if (typeof AudioData !== "undefined" && value.data instanceof AudioData) {
|
|
value.data.close();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
var VIDEO_SAMPLE_PIXEL_FORMATS = [
|
|
// 4:2:0 Y, U, V
|
|
"I420",
|
|
"I420P10",
|
|
"I420P12",
|
|
// 4:2:0 Y, U, V, A
|
|
"I420A",
|
|
"I420AP10",
|
|
"I420AP12",
|
|
// 4:2:2 Y, U, V
|
|
"I422",
|
|
"I422P10",
|
|
"I422P12",
|
|
// 4:2:2 Y, U, V, A
|
|
"I422A",
|
|
"I422AP10",
|
|
"I422AP12",
|
|
// 4:4:4 Y, U, V
|
|
"I444",
|
|
"I444P10",
|
|
"I444P12",
|
|
// 4:4:4 Y, U, V, A
|
|
"I444A",
|
|
"I444AP10",
|
|
"I444AP12",
|
|
// 4:2:0 Y, UV
|
|
"NV12",
|
|
// 4:4:4 RGBA
|
|
"RGBA",
|
|
// 4:4:4 RGBX (opaque)
|
|
"RGBX",
|
|
// 4:4:4 BGRA
|
|
"BGRA",
|
|
// 4:4:4 BGRX (opaque)
|
|
"BGRX"
|
|
];
|
|
var VIDEO_SAMPLE_PIXEL_FORMATS_SET = new Set(VIDEO_SAMPLE_PIXEL_FORMATS);
|
|
var VideoSample = class _VideoSample {
|
|
constructor(data, init) {
|
|
/** @internal */
|
|
this._closed = false;
|
|
if (data instanceof ArrayBuffer || typeof SharedArrayBuffer !== "undefined" && data instanceof SharedArrayBuffer || ArrayBuffer.isView(data)) {
|
|
if (!init || typeof init !== "object") {
|
|
throw new TypeError("init must be an object.");
|
|
}
|
|
if (init.format === void 0 || !VIDEO_SAMPLE_PIXEL_FORMATS_SET.has(init.format)) {
|
|
throw new TypeError("init.format must be one of: " + VIDEO_SAMPLE_PIXEL_FORMATS.join(", "));
|
|
}
|
|
if (!Number.isInteger(init.codedWidth) || init.codedWidth <= 0) {
|
|
throw new TypeError("init.codedWidth must be a positive integer.");
|
|
}
|
|
if (!Number.isInteger(init.codedHeight) || init.codedHeight <= 0) {
|
|
throw new TypeError("init.codedHeight must be a positive integer.");
|
|
}
|
|
if (init.rotation !== void 0 && ![0, 90, 180, 270].includes(init.rotation)) {
|
|
throw new TypeError("init.rotation, when provided, must be 0, 90, 180, or 270.");
|
|
}
|
|
if (!Number.isFinite(init.timestamp)) {
|
|
throw new TypeError("init.timestamp must be a number.");
|
|
}
|
|
if (init.duration !== void 0 && (!Number.isFinite(init.duration) || init.duration < 0)) {
|
|
throw new TypeError("init.duration, when provided, must be a non-negative number.");
|
|
}
|
|
this._data = toUint8Array(data).slice();
|
|
this._layout = init.layout ?? createDefaultPlaneLayout(init.format, init.codedWidth, init.codedHeight);
|
|
this.format = init.format;
|
|
this.codedWidth = init.codedWidth;
|
|
this.codedHeight = init.codedHeight;
|
|
this.rotation = init.rotation ?? 0;
|
|
this.timestamp = init.timestamp;
|
|
this.duration = init.duration ?? 0;
|
|
this.colorSpace = new VideoSampleColorSpace(init.colorSpace);
|
|
} else if (typeof VideoFrame !== "undefined" && data instanceof VideoFrame) {
|
|
if (init?.rotation !== void 0 && ![0, 90, 180, 270].includes(init.rotation)) {
|
|
throw new TypeError("init.rotation, when provided, must be 0, 90, 180, or 270.");
|
|
}
|
|
if (init?.timestamp !== void 0 && !Number.isFinite(init?.timestamp)) {
|
|
throw new TypeError("init.timestamp, when provided, must be a number.");
|
|
}
|
|
if (init?.duration !== void 0 && (!Number.isFinite(init.duration) || init.duration < 0)) {
|
|
throw new TypeError("init.duration, when provided, must be a non-negative number.");
|
|
}
|
|
this._data = data;
|
|
this._layout = null;
|
|
this.format = data.format;
|
|
this.codedWidth = data.displayWidth;
|
|
this.codedHeight = data.displayHeight;
|
|
this.rotation = init?.rotation ?? 0;
|
|
this.timestamp = init?.timestamp ?? data.timestamp / 1e6;
|
|
this.duration = init?.duration ?? (data.duration ?? 0) / 1e6;
|
|
this.colorSpace = new VideoSampleColorSpace(data.colorSpace);
|
|
} else if (typeof HTMLImageElement !== "undefined" && data instanceof HTMLImageElement || typeof SVGImageElement !== "undefined" && data instanceof SVGImageElement || typeof ImageBitmap !== "undefined" && data instanceof ImageBitmap || typeof HTMLVideoElement !== "undefined" && data instanceof HTMLVideoElement || typeof HTMLCanvasElement !== "undefined" && data instanceof HTMLCanvasElement || typeof OffscreenCanvas !== "undefined" && data instanceof OffscreenCanvas) {
|
|
if (!init || typeof init !== "object") {
|
|
throw new TypeError("init must be an object.");
|
|
}
|
|
if (init.rotation !== void 0 && ![0, 90, 180, 270].includes(init.rotation)) {
|
|
throw new TypeError("init.rotation, when provided, must be 0, 90, 180, or 270.");
|
|
}
|
|
if (!Number.isFinite(init.timestamp)) {
|
|
throw new TypeError("init.timestamp must be a number.");
|
|
}
|
|
if (init.duration !== void 0 && (!Number.isFinite(init.duration) || init.duration < 0)) {
|
|
throw new TypeError("init.duration, when provided, must be a non-negative number.");
|
|
}
|
|
if (typeof VideoFrame !== "undefined") {
|
|
return new _VideoSample(
|
|
new VideoFrame(data, {
|
|
timestamp: Math.trunc(init.timestamp * SECOND_TO_MICROSECOND_FACTOR),
|
|
// Drag 0 to undefined
|
|
duration: Math.trunc((init.duration ?? 0) * SECOND_TO_MICROSECOND_FACTOR) || void 0
|
|
}),
|
|
init
|
|
);
|
|
}
|
|
let width = 0;
|
|
let height = 0;
|
|
if ("naturalWidth" in data) {
|
|
width = data.naturalWidth;
|
|
height = data.naturalHeight;
|
|
} else if ("videoWidth" in data) {
|
|
width = data.videoWidth;
|
|
height = data.videoHeight;
|
|
} else if ("width" in data) {
|
|
width = Number(data.width);
|
|
height = Number(data.height);
|
|
}
|
|
if (!width || !height) {
|
|
throw new TypeError("Could not determine dimensions.");
|
|
}
|
|
const canvas = new OffscreenCanvas(width, height);
|
|
const context = canvas.getContext("2d", {
|
|
alpha: isFirefox(),
|
|
// Firefox has VideoFrame glitches with opaque canvases
|
|
willReadFrequently: true
|
|
});
|
|
assert(context);
|
|
context.drawImage(data, 0, 0);
|
|
this._data = canvas;
|
|
this._layout = null;
|
|
this.format = "RGBX";
|
|
this.codedWidth = width;
|
|
this.codedHeight = height;
|
|
this.rotation = init.rotation ?? 0;
|
|
this.timestamp = init.timestamp;
|
|
this.duration = init.duration ?? 0;
|
|
this.colorSpace = new VideoSampleColorSpace({
|
|
matrix: "rgb",
|
|
primaries: "bt709",
|
|
transfer: "iec61966-2-1",
|
|
fullRange: true
|
|
});
|
|
} else {
|
|
throw new TypeError("Invalid data type: Must be a BufferSource or CanvasImageSource.");
|
|
}
|
|
finalizationRegistry?.register(this, { type: "video", data: this._data }, this);
|
|
}
|
|
/** The width of the frame in pixels after rotation. */
|
|
get displayWidth() {
|
|
return this.rotation % 180 === 0 ? this.codedWidth : this.codedHeight;
|
|
}
|
|
/** The height of the frame in pixels after rotation. */
|
|
get displayHeight() {
|
|
return this.rotation % 180 === 0 ? this.codedHeight : this.codedWidth;
|
|
}
|
|
/** The presentation timestamp of the frame in microseconds. */
|
|
get microsecondTimestamp() {
|
|
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.timestamp);
|
|
}
|
|
/** The duration of the frame in microseconds. */
|
|
get microsecondDuration() {
|
|
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration);
|
|
}
|
|
/**
|
|
* Whether this sample uses a pixel format that can hold transparency data. Note that this doesn't necessarily mean
|
|
* that the sample is transparent.
|
|
*/
|
|
get hasAlpha() {
|
|
return this.format && this.format.includes("A");
|
|
}
|
|
/** Clones this video sample. */
|
|
clone() {
|
|
if (this._closed) {
|
|
throw new Error("VideoSample is closed.");
|
|
}
|
|
assert(this._data !== null);
|
|
if (isVideoFrame(this._data)) {
|
|
return new _VideoSample(this._data.clone(), {
|
|
timestamp: this.timestamp,
|
|
duration: this.duration,
|
|
rotation: this.rotation
|
|
});
|
|
} else if (this._data instanceof Uint8Array) {
|
|
assert(this._layout);
|
|
return new _VideoSample(this._data, {
|
|
format: this.format,
|
|
layout: this._layout,
|
|
codedWidth: this.codedWidth,
|
|
codedHeight: this.codedHeight,
|
|
timestamp: this.timestamp,
|
|
duration: this.duration,
|
|
colorSpace: this.colorSpace,
|
|
rotation: this.rotation
|
|
});
|
|
} else {
|
|
return new _VideoSample(this._data, {
|
|
format: this.format,
|
|
codedWidth: this.codedWidth,
|
|
codedHeight: this.codedHeight,
|
|
timestamp: this.timestamp,
|
|
duration: this.duration,
|
|
colorSpace: this.colorSpace,
|
|
rotation: this.rotation
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Closes this video sample, releasing held resources. Video samples should be closed as soon as they are not
|
|
* needed anymore.
|
|
*/
|
|
close() {
|
|
if (this._closed) {
|
|
return;
|
|
}
|
|
finalizationRegistry?.unregister(this);
|
|
if (isVideoFrame(this._data)) {
|
|
this._data.close();
|
|
} else {
|
|
this._data = null;
|
|
}
|
|
this._closed = true;
|
|
}
|
|
/**
|
|
* Returns the number of bytes required to hold this video sample's pixel data. Throws if `format` is `null`.
|
|
*/
|
|
allocationSize(options = {}) {
|
|
validateVideoFrameCopyToOptions(options);
|
|
if (this._closed) {
|
|
throw new Error("VideoSample is closed.");
|
|
}
|
|
if (this.format === null) {
|
|
throw new Error("Cannot get allocation size when format is null. Sorry!");
|
|
}
|
|
assert(this._data !== null);
|
|
if (!isVideoFrame(this._data)) {
|
|
if (options.colorSpace || options.format && options.format !== this.format || options.layout || options.rect) {
|
|
const videoFrame = this.toVideoFrame();
|
|
const size = videoFrame.allocationSize(options);
|
|
videoFrame.close();
|
|
return size;
|
|
}
|
|
}
|
|
if (isVideoFrame(this._data)) {
|
|
return this._data.allocationSize(options);
|
|
} else if (this._data instanceof Uint8Array) {
|
|
return this._data.byteLength;
|
|
} else {
|
|
return this.codedWidth * this.codedHeight * 4;
|
|
}
|
|
}
|
|
/**
|
|
* Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView. Throws if `format` is `null`.
|
|
* @returns The byte layout of the planes of the copied data.
|
|
*/
|
|
async copyTo(destination, options = {}) {
|
|
if (!isAllowSharedBufferSource(destination)) {
|
|
throw new TypeError("destination must be an ArrayBuffer or an ArrayBuffer view.");
|
|
}
|
|
validateVideoFrameCopyToOptions(options);
|
|
if (this._closed) {
|
|
throw new Error("VideoSample is closed.");
|
|
}
|
|
if (this.format === null) {
|
|
throw new Error("Cannot copy video sample data when format is null. Sorry!");
|
|
}
|
|
assert(this._data !== null);
|
|
if (!isVideoFrame(this._data)) {
|
|
if (options.colorSpace || options.format && options.format !== this.format || options.layout || options.rect) {
|
|
const videoFrame = this.toVideoFrame();
|
|
const layout = await videoFrame.copyTo(destination, options);
|
|
videoFrame.close();
|
|
return layout;
|
|
}
|
|
}
|
|
if (isVideoFrame(this._data)) {
|
|
return this._data.copyTo(destination, options);
|
|
} else if (this._data instanceof Uint8Array) {
|
|
assert(this._layout);
|
|
const dest = toUint8Array(destination);
|
|
dest.set(this._data);
|
|
return this._layout;
|
|
} else {
|
|
const canvas = this._data;
|
|
const context = canvas.getContext("2d");
|
|
assert(context);
|
|
const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight);
|
|
const dest = toUint8Array(destination);
|
|
dest.set(imageData.data);
|
|
return [{
|
|
offset: 0,
|
|
stride: 4 * this.codedWidth
|
|
}];
|
|
}
|
|
}
|
|
/**
|
|
* Converts this video sample to a VideoFrame for use with the WebCodecs API. The VideoFrame returned by this
|
|
* method *must* be closed separately from this video sample.
|
|
*/
|
|
toVideoFrame() {
|
|
if (this._closed) {
|
|
throw new Error("VideoSample is closed.");
|
|
}
|
|
assert(this._data !== null);
|
|
if (isVideoFrame(this._data)) {
|
|
return new VideoFrame(this._data, {
|
|
timestamp: this.microsecondTimestamp,
|
|
duration: this.microsecondDuration || void 0
|
|
// Drag 0 duration to undefined, glitches some codecs
|
|
});
|
|
} else if (this._data instanceof Uint8Array) {
|
|
return new VideoFrame(this._data, {
|
|
format: this.format,
|
|
codedWidth: this.codedWidth,
|
|
codedHeight: this.codedHeight,
|
|
timestamp: this.microsecondTimestamp,
|
|
duration: this.microsecondDuration || void 0,
|
|
colorSpace: this.colorSpace
|
|
});
|
|
} else {
|
|
return new VideoFrame(this._data, {
|
|
timestamp: this.microsecondTimestamp,
|
|
duration: this.microsecondDuration || void 0
|
|
});
|
|
}
|
|
}
|
|
draw(context, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) {
|
|
let sx = 0;
|
|
let sy = 0;
|
|
let sWidth = this.displayWidth;
|
|
let sHeight = this.displayHeight;
|
|
let dx = 0;
|
|
let dy = 0;
|
|
let dWidth = this.displayWidth;
|
|
let dHeight = this.displayHeight;
|
|
if (arg5 !== void 0) {
|
|
sx = arg1;
|
|
sy = arg2;
|
|
sWidth = arg3;
|
|
sHeight = arg4;
|
|
dx = arg5;
|
|
dy = arg6;
|
|
if (arg7 !== void 0) {
|
|
dWidth = arg7;
|
|
dHeight = arg8;
|
|
} else {
|
|
dWidth = sWidth;
|
|
dHeight = sHeight;
|
|
}
|
|
} else {
|
|
dx = arg1;
|
|
dy = arg2;
|
|
if (arg3 !== void 0) {
|
|
dWidth = arg3;
|
|
dHeight = arg4;
|
|
}
|
|
}
|
|
if (!(typeof CanvasRenderingContext2D !== "undefined" && context instanceof CanvasRenderingContext2D || typeof OffscreenCanvasRenderingContext2D !== "undefined" && context instanceof OffscreenCanvasRenderingContext2D)) {
|
|
throw new TypeError("context must be a CanvasRenderingContext2D or OffscreenCanvasRenderingContext2D.");
|
|
}
|
|
if (!Number.isFinite(sx)) {
|
|
throw new TypeError("sx must be a number.");
|
|
}
|
|
if (!Number.isFinite(sy)) {
|
|
throw new TypeError("sy must be a number.");
|
|
}
|
|
if (!Number.isFinite(sWidth) || sWidth < 0) {
|
|
throw new TypeError("sWidth must be a non-negative number.");
|
|
}
|
|
if (!Number.isFinite(sHeight) || sHeight < 0) {
|
|
throw new TypeError("sHeight must be a non-negative number.");
|
|
}
|
|
if (!Number.isFinite(dx)) {
|
|
throw new TypeError("dx must be a number.");
|
|
}
|
|
if (!Number.isFinite(dy)) {
|
|
throw new TypeError("dy must be a number.");
|
|
}
|
|
if (!Number.isFinite(dWidth) || dWidth < 0) {
|
|
throw new TypeError("dWidth must be a non-negative number.");
|
|
}
|
|
if (!Number.isFinite(dHeight) || dHeight < 0) {
|
|
throw new TypeError("dHeight must be a non-negative number.");
|
|
}
|
|
if (this._closed) {
|
|
throw new Error("VideoSample is closed.");
|
|
}
|
|
({ sx, sy, sWidth, sHeight } = this._rotateSourceRegion(sx, sy, sWidth, sHeight, this.rotation));
|
|
const source = this.toCanvasImageSource();
|
|
context.save();
|
|
const centerX = dx + dWidth / 2;
|
|
const centerY = dy + dHeight / 2;
|
|
context.translate(centerX, centerY);
|
|
context.rotate(this.rotation * Math.PI / 180);
|
|
const aspectRatioChange = this.rotation % 180 === 0 ? 1 : dWidth / dHeight;
|
|
context.scale(1 / aspectRatioChange, aspectRatioChange);
|
|
context.drawImage(
|
|
source,
|
|
sx,
|
|
sy,
|
|
sWidth,
|
|
sHeight,
|
|
-dWidth / 2,
|
|
-dHeight / 2,
|
|
dWidth,
|
|
dHeight
|
|
);
|
|
context.restore();
|
|
}
|
|
/**
|
|
* Draws the sample in the middle of the canvas corresponding to the context with the specified fit behavior.
|
|
*/
|
|
drawWithFit(context, options) {
|
|
if (!(typeof CanvasRenderingContext2D !== "undefined" && context instanceof CanvasRenderingContext2D || typeof OffscreenCanvasRenderingContext2D !== "undefined" && context instanceof OffscreenCanvasRenderingContext2D)) {
|
|
throw new TypeError("context must be a CanvasRenderingContext2D or OffscreenCanvasRenderingContext2D.");
|
|
}
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (!["fill", "contain", "cover"].includes(options.fit)) {
|
|
throw new TypeError("options.fit must be 'fill', 'contain', or 'cover'.");
|
|
}
|
|
if (options.rotation !== void 0 && ![0, 90, 180, 270].includes(options.rotation)) {
|
|
throw new TypeError("options.rotation, when provided, must be 0, 90, 180, or 270.");
|
|
}
|
|
if (options.crop !== void 0) {
|
|
validateCropRectangle(options.crop, "options.");
|
|
}
|
|
const canvasWidth = context.canvas.width;
|
|
const canvasHeight = context.canvas.height;
|
|
const rotation = options.rotation ?? this.rotation;
|
|
const [rotatedWidth, rotatedHeight] = rotation % 180 === 0 ? [this.codedWidth, this.codedHeight] : [this.codedHeight, this.codedWidth];
|
|
if (options.crop) {
|
|
clampCropRectangle(options.crop, rotatedWidth, rotatedHeight);
|
|
}
|
|
let dx;
|
|
let dy;
|
|
let newWidth;
|
|
let newHeight;
|
|
const { sx, sy, sWidth, sHeight } = this._rotateSourceRegion(
|
|
options.crop?.left ?? 0,
|
|
options.crop?.top ?? 0,
|
|
options.crop?.width ?? rotatedWidth,
|
|
options.crop?.height ?? rotatedHeight,
|
|
rotation
|
|
);
|
|
if (options.fit === "fill") {
|
|
dx = 0;
|
|
dy = 0;
|
|
newWidth = canvasWidth;
|
|
newHeight = canvasHeight;
|
|
} else {
|
|
const [sampleWidth, sampleHeight] = options.crop ? [options.crop.width, options.crop.height] : [rotatedWidth, rotatedHeight];
|
|
const scale = options.fit === "contain" ? Math.min(canvasWidth / sampleWidth, canvasHeight / sampleHeight) : Math.max(canvasWidth / sampleWidth, canvasHeight / sampleHeight);
|
|
newWidth = sampleWidth * scale;
|
|
newHeight = sampleHeight * scale;
|
|
dx = (canvasWidth - newWidth) / 2;
|
|
dy = (canvasHeight - newHeight) / 2;
|
|
}
|
|
context.save();
|
|
const aspectRatioChange = rotation % 180 === 0 ? 1 : newWidth / newHeight;
|
|
context.translate(canvasWidth / 2, canvasHeight / 2);
|
|
context.rotate(rotation * Math.PI / 180);
|
|
context.scale(1 / aspectRatioChange, aspectRatioChange);
|
|
context.translate(-canvasWidth / 2, -canvasHeight / 2);
|
|
context.drawImage(this.toCanvasImageSource(), sx, sy, sWidth, sHeight, dx, dy, newWidth, newHeight);
|
|
context.restore();
|
|
}
|
|
/** @internal */
|
|
_rotateSourceRegion(sx, sy, sWidth, sHeight, rotation) {
|
|
if (rotation === 90) {
|
|
[sx, sy, sWidth, sHeight] = [
|
|
sy,
|
|
this.codedHeight - sx - sWidth,
|
|
sHeight,
|
|
sWidth
|
|
];
|
|
} else if (rotation === 180) {
|
|
[sx, sy] = [
|
|
this.codedWidth - sx - sWidth,
|
|
this.codedHeight - sy - sHeight
|
|
];
|
|
} else if (rotation === 270) {
|
|
[sx, sy, sWidth, sHeight] = [
|
|
this.codedWidth - sy - sHeight,
|
|
sx,
|
|
sHeight,
|
|
sWidth
|
|
];
|
|
}
|
|
return { sx, sy, sWidth, sHeight };
|
|
}
|
|
/**
|
|
* Converts this video sample to a
|
|
* [`CanvasImageSource`](https://udn.realityripple.com/docs/Web/API/CanvasImageSource) for drawing to a canvas.
|
|
*
|
|
* You must use the value returned by this method immediately, as any VideoFrame created internally will
|
|
* automatically be closed in the next microtask.
|
|
*/
|
|
toCanvasImageSource() {
|
|
if (this._closed) {
|
|
throw new Error("VideoSample is closed.");
|
|
}
|
|
assert(this._data !== null);
|
|
if (this._data instanceof Uint8Array) {
|
|
const videoFrame = this.toVideoFrame();
|
|
queueMicrotask(() => videoFrame.close());
|
|
return videoFrame;
|
|
} else {
|
|
return this._data;
|
|
}
|
|
}
|
|
/** Sets the rotation metadata of this video sample. */
|
|
setRotation(newRotation) {
|
|
if (![0, 90, 180, 270].includes(newRotation)) {
|
|
throw new TypeError("newRotation must be 0, 90, 180, or 270.");
|
|
}
|
|
this.rotation = newRotation;
|
|
}
|
|
/** Sets the presentation timestamp of this video sample, in seconds. */
|
|
setTimestamp(newTimestamp) {
|
|
if (!Number.isFinite(newTimestamp)) {
|
|
throw new TypeError("newTimestamp must be a number.");
|
|
}
|
|
this.timestamp = newTimestamp;
|
|
}
|
|
/** Sets the duration of this video sample, in seconds. */
|
|
setDuration(newDuration) {
|
|
if (!Number.isFinite(newDuration) || newDuration < 0) {
|
|
throw new TypeError("newDuration must be a non-negative number.");
|
|
}
|
|
this.duration = newDuration;
|
|
}
|
|
/** Calls `.close()`. */
|
|
[Symbol.dispose]() {
|
|
this.close();
|
|
}
|
|
};
|
|
var VideoSampleColorSpace = class {
|
|
/** Creates a new VideoSampleColorSpace. */
|
|
constructor(init) {
|
|
this.primaries = init?.primaries ?? null;
|
|
this.transfer = init?.transfer ?? null;
|
|
this.matrix = init?.matrix ?? null;
|
|
this.fullRange = init?.fullRange ?? null;
|
|
}
|
|
/** Serializes the color space to a JSON object. */
|
|
toJSON() {
|
|
return {
|
|
primaries: this.primaries,
|
|
transfer: this.transfer,
|
|
matrix: this.matrix,
|
|
fullRange: this.fullRange
|
|
};
|
|
}
|
|
};
|
|
var isVideoFrame = (x) => {
|
|
return typeof VideoFrame !== "undefined" && x instanceof VideoFrame;
|
|
};
|
|
var clampCropRectangle = (crop, outerWidth, outerHeight) => {
|
|
crop.left = Math.min(crop.left, outerWidth);
|
|
crop.top = Math.min(crop.top, outerHeight);
|
|
crop.width = Math.min(crop.width, outerWidth - crop.left);
|
|
crop.height = Math.min(crop.height, outerHeight - crop.top);
|
|
assert(crop.width >= 0);
|
|
assert(crop.height >= 0);
|
|
};
|
|
var validateCropRectangle = (crop, prefix) => {
|
|
if (!crop || typeof crop !== "object") {
|
|
throw new TypeError(prefix + "crop, when provided, must be an object.");
|
|
}
|
|
if (!Number.isInteger(crop.left) || crop.left < 0) {
|
|
throw new TypeError(prefix + "crop.left must be a non-negative integer.");
|
|
}
|
|
if (!Number.isInteger(crop.top) || crop.top < 0) {
|
|
throw new TypeError(prefix + "crop.top must be a non-negative integer.");
|
|
}
|
|
if (!Number.isInteger(crop.width) || crop.width < 0) {
|
|
throw new TypeError(prefix + "crop.width must be a non-negative integer.");
|
|
}
|
|
if (!Number.isInteger(crop.height) || crop.height < 0) {
|
|
throw new TypeError(prefix + "crop.height must be a non-negative integer.");
|
|
}
|
|
};
|
|
var validateVideoFrameCopyToOptions = (options) => {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.colorSpace !== void 0 && !["display-p3", "srgb"].includes(options.colorSpace)) {
|
|
throw new TypeError("options.colorSpace, when provided, must be 'display-p3' or 'srgb'.");
|
|
}
|
|
if (options.format !== void 0 && typeof options.format !== "string") {
|
|
throw new TypeError("options.format, when provided, must be a string.");
|
|
}
|
|
if (options.layout !== void 0) {
|
|
if (!Array.isArray(options.layout)) {
|
|
throw new TypeError("options.layout, when provided, must be an array.");
|
|
}
|
|
for (const plane of options.layout) {
|
|
if (!plane || typeof plane !== "object") {
|
|
throw new TypeError("Each entry in options.layout must be an object.");
|
|
}
|
|
if (!Number.isInteger(plane.offset) || plane.offset < 0) {
|
|
throw new TypeError("plane.offset must be a non-negative integer.");
|
|
}
|
|
if (!Number.isInteger(plane.stride) || plane.stride < 0) {
|
|
throw new TypeError("plane.stride must be a non-negative integer.");
|
|
}
|
|
}
|
|
}
|
|
if (options.rect !== void 0) {
|
|
if (!options.rect || typeof options.rect !== "object") {
|
|
throw new TypeError("options.rect, when provided, must be an object.");
|
|
}
|
|
if (options.rect.x !== void 0 && (!Number.isInteger(options.rect.x) || options.rect.x < 0)) {
|
|
throw new TypeError("options.rect.x, when provided, must be a non-negative integer.");
|
|
}
|
|
if (options.rect.y !== void 0 && (!Number.isInteger(options.rect.y) || options.rect.y < 0)) {
|
|
throw new TypeError("options.rect.y, when provided, must be a non-negative integer.");
|
|
}
|
|
if (options.rect.width !== void 0 && (!Number.isInteger(options.rect.width) || options.rect.width < 0)) {
|
|
throw new TypeError("options.rect.width, when provided, must be a non-negative integer.");
|
|
}
|
|
if (options.rect.height !== void 0 && (!Number.isInteger(options.rect.height) || options.rect.height < 0)) {
|
|
throw new TypeError("options.rect.height, when provided, must be a non-negative integer.");
|
|
}
|
|
}
|
|
};
|
|
var createDefaultPlaneLayout = (format, codedWidth, codedHeight) => {
|
|
const planes = getPlaneConfigs(format);
|
|
const layouts = [];
|
|
let currentOffset = 0;
|
|
for (const plane of planes) {
|
|
const planeWidth = Math.ceil(codedWidth / plane.widthDivisor);
|
|
const planeHeight = Math.ceil(codedHeight / plane.heightDivisor);
|
|
const stride = planeWidth * plane.sampleBytes;
|
|
const planeSize = stride * planeHeight;
|
|
layouts.push({
|
|
offset: currentOffset,
|
|
stride
|
|
});
|
|
currentOffset += planeSize;
|
|
}
|
|
return layouts;
|
|
};
|
|
var getPlaneConfigs = (format) => {
|
|
const yuv = (yBytes, uvBytes, subX, subY, hasAlpha) => {
|
|
const configs = [
|
|
{ sampleBytes: yBytes, widthDivisor: 1, heightDivisor: 1 },
|
|
{ sampleBytes: uvBytes, widthDivisor: subX, heightDivisor: subY },
|
|
{ sampleBytes: uvBytes, widthDivisor: subX, heightDivisor: subY }
|
|
];
|
|
if (hasAlpha) {
|
|
configs.push({ sampleBytes: yBytes, widthDivisor: 1, heightDivisor: 1 });
|
|
}
|
|
return configs;
|
|
};
|
|
switch (format) {
|
|
case "I420":
|
|
return yuv(1, 1, 2, 2, false);
|
|
case "I420P10":
|
|
case "I420P12":
|
|
return yuv(2, 2, 2, 2, false);
|
|
case "I420A":
|
|
return yuv(1, 1, 2, 2, true);
|
|
case "I420AP10":
|
|
case "I420AP12":
|
|
return yuv(2, 2, 2, 2, true);
|
|
case "I422":
|
|
return yuv(1, 1, 2, 1, false);
|
|
case "I422P10":
|
|
case "I422P12":
|
|
return yuv(2, 2, 2, 1, false);
|
|
case "I422A":
|
|
return yuv(1, 1, 2, 1, true);
|
|
case "I422AP10":
|
|
case "I422AP12":
|
|
return yuv(2, 2, 2, 1, true);
|
|
case "I444":
|
|
return yuv(1, 1, 1, 1, false);
|
|
case "I444P10":
|
|
case "I444P12":
|
|
return yuv(2, 2, 1, 1, false);
|
|
case "I444A":
|
|
return yuv(1, 1, 1, 1, true);
|
|
case "I444AP10":
|
|
case "I444AP12":
|
|
return yuv(2, 2, 1, 1, true);
|
|
case "NV12":
|
|
return [
|
|
{ sampleBytes: 1, widthDivisor: 1, heightDivisor: 1 },
|
|
{ sampleBytes: 2, widthDivisor: 2, heightDivisor: 2 }
|
|
// Interleaved U and V
|
|
];
|
|
case "RGBA":
|
|
case "RGBX":
|
|
case "BGRA":
|
|
case "BGRX":
|
|
return [
|
|
{ sampleBytes: 4, widthDivisor: 1, heightDivisor: 1 }
|
|
];
|
|
default:
|
|
assertNever(format);
|
|
assert(false);
|
|
}
|
|
};
|
|
var AUDIO_SAMPLE_FORMATS = /* @__PURE__ */ new Set(
|
|
["f32", "f32-planar", "s16", "s16-planar", "s32", "s32-planar", "u8", "u8-planar"]
|
|
);
|
|
var AudioSample = class _AudioSample {
|
|
/**
|
|
* Creates a new {@link AudioSample}, either from an existing
|
|
* [`AudioData`](https://developer.mozilla.org/en-US/docs/Web/API/AudioData) or from raw bytes specified in
|
|
* {@link AudioSampleInit}.
|
|
*/
|
|
constructor(init) {
|
|
/** @internal */
|
|
this._closed = false;
|
|
if (isAudioData(init)) {
|
|
if (init.format === null) {
|
|
throw new TypeError("AudioData with null format is not supported.");
|
|
}
|
|
this._data = init;
|
|
this.format = init.format;
|
|
this.sampleRate = init.sampleRate;
|
|
this.numberOfFrames = init.numberOfFrames;
|
|
this.numberOfChannels = init.numberOfChannels;
|
|
this.timestamp = init.timestamp / 1e6;
|
|
this.duration = init.numberOfFrames / init.sampleRate;
|
|
} else {
|
|
if (!init || typeof init !== "object") {
|
|
throw new TypeError("Invalid AudioDataInit: must be an object.");
|
|
}
|
|
if (!AUDIO_SAMPLE_FORMATS.has(init.format)) {
|
|
throw new TypeError("Invalid AudioDataInit: invalid format.");
|
|
}
|
|
if (!Number.isFinite(init.sampleRate) || init.sampleRate <= 0) {
|
|
throw new TypeError("Invalid AudioDataInit: sampleRate must be > 0.");
|
|
}
|
|
if (!Number.isInteger(init.numberOfChannels) || init.numberOfChannels === 0) {
|
|
throw new TypeError("Invalid AudioDataInit: numberOfChannels must be an integer > 0.");
|
|
}
|
|
if (!Number.isFinite(init?.timestamp)) {
|
|
throw new TypeError("init.timestamp must be a number.");
|
|
}
|
|
const numberOfFrames = init.data.byteLength / (getBytesPerSample(init.format) * init.numberOfChannels);
|
|
if (!Number.isInteger(numberOfFrames)) {
|
|
throw new TypeError("Invalid AudioDataInit: data size is not a multiple of frame size.");
|
|
}
|
|
this.format = init.format;
|
|
this.sampleRate = init.sampleRate;
|
|
this.numberOfFrames = numberOfFrames;
|
|
this.numberOfChannels = init.numberOfChannels;
|
|
this.timestamp = init.timestamp;
|
|
this.duration = numberOfFrames / init.sampleRate;
|
|
let dataBuffer;
|
|
if (init.data instanceof ArrayBuffer) {
|
|
dataBuffer = new Uint8Array(init.data);
|
|
} else if (ArrayBuffer.isView(init.data)) {
|
|
dataBuffer = new Uint8Array(init.data.buffer, init.data.byteOffset, init.data.byteLength);
|
|
} else {
|
|
throw new TypeError("Invalid AudioDataInit: data is not a BufferSource.");
|
|
}
|
|
const expectedSize = this.numberOfFrames * this.numberOfChannels * getBytesPerSample(this.format);
|
|
if (dataBuffer.byteLength < expectedSize) {
|
|
throw new TypeError("Invalid AudioDataInit: insufficient data size.");
|
|
}
|
|
this._data = dataBuffer;
|
|
}
|
|
finalizationRegistry?.register(this, { type: "audio", data: this._data }, this);
|
|
}
|
|
/** The presentation timestamp of the sample in microseconds. */
|
|
get microsecondTimestamp() {
|
|
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.timestamp);
|
|
}
|
|
/** The duration of the sample in microseconds. */
|
|
get microsecondDuration() {
|
|
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration);
|
|
}
|
|
/** Returns the number of bytes required to hold the audio sample's data as specified by the given options. */
|
|
allocationSize(options) {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (!Number.isInteger(options.planeIndex) || options.planeIndex < 0) {
|
|
throw new TypeError("planeIndex must be a non-negative integer.");
|
|
}
|
|
if (options.format !== void 0 && !AUDIO_SAMPLE_FORMATS.has(options.format)) {
|
|
throw new TypeError("Invalid format.");
|
|
}
|
|
if (options.frameOffset !== void 0 && (!Number.isInteger(options.frameOffset) || options.frameOffset < 0)) {
|
|
throw new TypeError("frameOffset must be a non-negative integer.");
|
|
}
|
|
if (options.frameCount !== void 0 && (!Number.isInteger(options.frameCount) || options.frameCount < 0)) {
|
|
throw new TypeError("frameCount must be a non-negative integer.");
|
|
}
|
|
if (this._closed) {
|
|
throw new Error("AudioSample is closed.");
|
|
}
|
|
const destFormat = options.format ?? this.format;
|
|
const frameOffset = options.frameOffset ?? 0;
|
|
if (frameOffset >= this.numberOfFrames) {
|
|
throw new RangeError("frameOffset out of range");
|
|
}
|
|
const copyFrameCount = options.frameCount !== void 0 ? options.frameCount : this.numberOfFrames - frameOffset;
|
|
if (copyFrameCount > this.numberOfFrames - frameOffset) {
|
|
throw new RangeError("frameCount out of range");
|
|
}
|
|
const bytesPerSample = getBytesPerSample(destFormat);
|
|
const isPlanar = formatIsPlanar(destFormat);
|
|
if (isPlanar && options.planeIndex >= this.numberOfChannels) {
|
|
throw new RangeError("planeIndex out of range");
|
|
}
|
|
if (!isPlanar && options.planeIndex !== 0) {
|
|
throw new RangeError("planeIndex out of range");
|
|
}
|
|
const elementCount = isPlanar ? copyFrameCount : copyFrameCount * this.numberOfChannels;
|
|
return elementCount * bytesPerSample;
|
|
}
|
|
/** Copies the audio sample's data to an ArrayBuffer or ArrayBufferView as specified by the given options. */
|
|
copyTo(destination, options) {
|
|
if (!isAllowSharedBufferSource(destination)) {
|
|
throw new TypeError("destination must be an ArrayBuffer or an ArrayBuffer view.");
|
|
}
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (!Number.isInteger(options.planeIndex) || options.planeIndex < 0) {
|
|
throw new TypeError("planeIndex must be a non-negative integer.");
|
|
}
|
|
if (options.format !== void 0 && !AUDIO_SAMPLE_FORMATS.has(options.format)) {
|
|
throw new TypeError("Invalid format.");
|
|
}
|
|
if (options.frameOffset !== void 0 && (!Number.isInteger(options.frameOffset) || options.frameOffset < 0)) {
|
|
throw new TypeError("frameOffset must be a non-negative integer.");
|
|
}
|
|
if (options.frameCount !== void 0 && (!Number.isInteger(options.frameCount) || options.frameCount < 0)) {
|
|
throw new TypeError("frameCount must be a non-negative integer.");
|
|
}
|
|
if (this._closed) {
|
|
throw new Error("AudioSample is closed.");
|
|
}
|
|
const { planeIndex, format, frameCount: optFrameCount, frameOffset: optFrameOffset } = options;
|
|
const srcFormat = this.format;
|
|
const destFormat = format ?? this.format;
|
|
if (!destFormat) throw new Error("Destination format not determined");
|
|
const numFrames = this.numberOfFrames;
|
|
const numChannels = this.numberOfChannels;
|
|
const frameOffset = optFrameOffset ?? 0;
|
|
if (frameOffset >= numFrames) {
|
|
throw new RangeError("frameOffset out of range");
|
|
}
|
|
const copyFrameCount = optFrameCount !== void 0 ? optFrameCount : numFrames - frameOffset;
|
|
if (copyFrameCount > numFrames - frameOffset) {
|
|
throw new RangeError("frameCount out of range");
|
|
}
|
|
const destBytesPerSample = getBytesPerSample(destFormat);
|
|
const destIsPlanar = formatIsPlanar(destFormat);
|
|
if (destIsPlanar && planeIndex >= numChannels) {
|
|
throw new RangeError("planeIndex out of range");
|
|
}
|
|
if (!destIsPlanar && planeIndex !== 0) {
|
|
throw new RangeError("planeIndex out of range");
|
|
}
|
|
const destElementCount = destIsPlanar ? copyFrameCount : copyFrameCount * numChannels;
|
|
const requiredSize = destElementCount * destBytesPerSample;
|
|
if (destination.byteLength < requiredSize) {
|
|
throw new RangeError("Destination buffer is too small");
|
|
}
|
|
const destView = toDataView(destination);
|
|
const writeFn = getWriteFunction(destFormat);
|
|
if (isAudioData(this._data)) {
|
|
if (isWebKit() && numChannels > 2 && destFormat !== srcFormat) {
|
|
doAudioDataCopyToWebKitWorkaround(
|
|
this._data,
|
|
destView,
|
|
srcFormat,
|
|
destFormat,
|
|
numChannels,
|
|
planeIndex,
|
|
frameOffset,
|
|
copyFrameCount
|
|
);
|
|
} else {
|
|
this._data.copyTo(destination, {
|
|
planeIndex,
|
|
frameOffset,
|
|
frameCount: copyFrameCount,
|
|
format: destFormat
|
|
});
|
|
}
|
|
} else {
|
|
const uint8Data = this._data;
|
|
const srcView = toDataView(uint8Data);
|
|
const readFn = getReadFunction(srcFormat);
|
|
const srcBytesPerSample = getBytesPerSample(srcFormat);
|
|
const srcIsPlanar = formatIsPlanar(srcFormat);
|
|
for (let i = 0; i < copyFrameCount; i++) {
|
|
if (destIsPlanar) {
|
|
const destOffset = i * destBytesPerSample;
|
|
let srcOffset;
|
|
if (srcIsPlanar) {
|
|
srcOffset = (planeIndex * numFrames + (i + frameOffset)) * srcBytesPerSample;
|
|
} else {
|
|
srcOffset = ((i + frameOffset) * numChannels + planeIndex) * srcBytesPerSample;
|
|
}
|
|
const normalized = readFn(srcView, srcOffset);
|
|
writeFn(destView, destOffset, normalized);
|
|
} else {
|
|
for (let ch = 0; ch < numChannels; ch++) {
|
|
const destIndex = i * numChannels + ch;
|
|
const destOffset = destIndex * destBytesPerSample;
|
|
let srcOffset;
|
|
if (srcIsPlanar) {
|
|
srcOffset = (ch * numFrames + (i + frameOffset)) * srcBytesPerSample;
|
|
} else {
|
|
srcOffset = ((i + frameOffset) * numChannels + ch) * srcBytesPerSample;
|
|
}
|
|
const normalized = readFn(srcView, srcOffset);
|
|
writeFn(destView, destOffset, normalized);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/** Clones this audio sample. */
|
|
clone() {
|
|
if (this._closed) {
|
|
throw new Error("AudioSample is closed.");
|
|
}
|
|
if (isAudioData(this._data)) {
|
|
const sample = new _AudioSample(this._data.clone());
|
|
sample.setTimestamp(this.timestamp);
|
|
return sample;
|
|
} else {
|
|
return new _AudioSample({
|
|
format: this.format,
|
|
sampleRate: this.sampleRate,
|
|
numberOfFrames: this.numberOfFrames,
|
|
numberOfChannels: this.numberOfChannels,
|
|
timestamp: this.timestamp,
|
|
data: this._data
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Closes this audio sample, releasing held resources. Audio samples should be closed as soon as they are not
|
|
* needed anymore.
|
|
*/
|
|
close() {
|
|
if (this._closed) {
|
|
return;
|
|
}
|
|
finalizationRegistry?.unregister(this);
|
|
if (isAudioData(this._data)) {
|
|
this._data.close();
|
|
} else {
|
|
this._data = new Uint8Array(0);
|
|
}
|
|
this._closed = true;
|
|
}
|
|
/**
|
|
* Converts this audio sample to an AudioData for use with the WebCodecs API. The AudioData returned by this
|
|
* method *must* be closed separately from this audio sample.
|
|
*/
|
|
toAudioData() {
|
|
if (this._closed) {
|
|
throw new Error("AudioSample is closed.");
|
|
}
|
|
if (isAudioData(this._data)) {
|
|
if (this._data.timestamp === this.microsecondTimestamp) {
|
|
return this._data.clone();
|
|
} else {
|
|
if (formatIsPlanar(this.format)) {
|
|
const size = this.allocationSize({ planeIndex: 0, format: this.format });
|
|
const data = new ArrayBuffer(size * this.numberOfChannels);
|
|
for (let i = 0; i < this.numberOfChannels; i++) {
|
|
this.copyTo(new Uint8Array(data, i * size, size), { planeIndex: i, format: this.format });
|
|
}
|
|
return new AudioData({
|
|
format: this.format,
|
|
sampleRate: this.sampleRate,
|
|
numberOfFrames: this.numberOfFrames,
|
|
numberOfChannels: this.numberOfChannels,
|
|
timestamp: this.microsecondTimestamp,
|
|
data
|
|
});
|
|
} else {
|
|
const data = new ArrayBuffer(this.allocationSize({ planeIndex: 0, format: this.format }));
|
|
this.copyTo(data, { planeIndex: 0, format: this.format });
|
|
return new AudioData({
|
|
format: this.format,
|
|
sampleRate: this.sampleRate,
|
|
numberOfFrames: this.numberOfFrames,
|
|
numberOfChannels: this.numberOfChannels,
|
|
timestamp: this.microsecondTimestamp,
|
|
data
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
return new AudioData({
|
|
format: this.format,
|
|
sampleRate: this.sampleRate,
|
|
numberOfFrames: this.numberOfFrames,
|
|
numberOfChannels: this.numberOfChannels,
|
|
timestamp: this.microsecondTimestamp,
|
|
data: this._data.buffer instanceof ArrayBuffer ? this._data.buffer : this._data.slice()
|
|
// In the case of SharedArrayBuffer, convert to ArrayBuffer
|
|
});
|
|
}
|
|
}
|
|
/** Convert this audio sample to an AudioBuffer for use with the Web Audio API. */
|
|
toAudioBuffer() {
|
|
if (this._closed) {
|
|
throw new Error("AudioSample is closed.");
|
|
}
|
|
const audioBuffer = new AudioBuffer({
|
|
numberOfChannels: this.numberOfChannels,
|
|
length: this.numberOfFrames,
|
|
sampleRate: this.sampleRate
|
|
});
|
|
const dataBytes = new Float32Array(this.allocationSize({ planeIndex: 0, format: "f32-planar" }) / 4);
|
|
for (let i = 0; i < this.numberOfChannels; i++) {
|
|
this.copyTo(dataBytes, { planeIndex: i, format: "f32-planar" });
|
|
audioBuffer.copyToChannel(dataBytes, i);
|
|
}
|
|
return audioBuffer;
|
|
}
|
|
/** Sets the presentation timestamp of this audio sample, in seconds. */
|
|
setTimestamp(newTimestamp) {
|
|
if (!Number.isFinite(newTimestamp)) {
|
|
throw new TypeError("newTimestamp must be a number.");
|
|
}
|
|
this.timestamp = newTimestamp;
|
|
}
|
|
/** Calls `.close()`. */
|
|
[Symbol.dispose]() {
|
|
this.close();
|
|
}
|
|
/** @internal */
|
|
static *_fromAudioBuffer(audioBuffer, timestamp) {
|
|
if (!(audioBuffer instanceof AudioBuffer)) {
|
|
throw new TypeError("audioBuffer must be an AudioBuffer.");
|
|
}
|
|
const MAX_FLOAT_COUNT = 48e3 * 5;
|
|
const numberOfChannels = audioBuffer.numberOfChannels;
|
|
const sampleRate = audioBuffer.sampleRate;
|
|
const totalFrames = audioBuffer.length;
|
|
const maxFramesPerChunk = Math.floor(MAX_FLOAT_COUNT / numberOfChannels);
|
|
let currentRelativeFrame = 0;
|
|
let remainingFrames = totalFrames;
|
|
while (remainingFrames > 0) {
|
|
const framesToCopy = Math.min(maxFramesPerChunk, remainingFrames);
|
|
const chunkData = new Float32Array(numberOfChannels * framesToCopy);
|
|
for (let channel = 0; channel < numberOfChannels; channel++) {
|
|
audioBuffer.copyFromChannel(
|
|
chunkData.subarray(channel * framesToCopy, (channel + 1) * framesToCopy),
|
|
channel,
|
|
currentRelativeFrame
|
|
);
|
|
}
|
|
yield new _AudioSample({
|
|
format: "f32-planar",
|
|
sampleRate,
|
|
numberOfFrames: framesToCopy,
|
|
numberOfChannels,
|
|
timestamp: timestamp + currentRelativeFrame / sampleRate,
|
|
data: chunkData
|
|
});
|
|
currentRelativeFrame += framesToCopy;
|
|
remainingFrames -= framesToCopy;
|
|
}
|
|
}
|
|
/**
|
|
* Creates AudioSamples from an AudioBuffer, starting at the given timestamp in seconds. Typically creates exactly
|
|
* one sample, but may create multiple if the AudioBuffer is exceedingly large.
|
|
*/
|
|
static fromAudioBuffer(audioBuffer, timestamp) {
|
|
if (!(audioBuffer instanceof AudioBuffer)) {
|
|
throw new TypeError("audioBuffer must be an AudioBuffer.");
|
|
}
|
|
const MAX_FLOAT_COUNT = 48e3 * 5;
|
|
const numberOfChannels = audioBuffer.numberOfChannels;
|
|
const sampleRate = audioBuffer.sampleRate;
|
|
const totalFrames = audioBuffer.length;
|
|
const maxFramesPerChunk = Math.floor(MAX_FLOAT_COUNT / numberOfChannels);
|
|
let currentRelativeFrame = 0;
|
|
let remainingFrames = totalFrames;
|
|
const result = [];
|
|
while (remainingFrames > 0) {
|
|
const framesToCopy = Math.min(maxFramesPerChunk, remainingFrames);
|
|
const chunkData = new Float32Array(numberOfChannels * framesToCopy);
|
|
for (let channel = 0; channel < numberOfChannels; channel++) {
|
|
audioBuffer.copyFromChannel(
|
|
chunkData.subarray(channel * framesToCopy, (channel + 1) * framesToCopy),
|
|
channel,
|
|
currentRelativeFrame
|
|
);
|
|
}
|
|
const audioSample = new _AudioSample({
|
|
format: "f32-planar",
|
|
sampleRate,
|
|
numberOfFrames: framesToCopy,
|
|
numberOfChannels,
|
|
timestamp: timestamp + currentRelativeFrame / sampleRate,
|
|
data: chunkData
|
|
});
|
|
result.push(audioSample);
|
|
currentRelativeFrame += framesToCopy;
|
|
remainingFrames -= framesToCopy;
|
|
}
|
|
return result;
|
|
}
|
|
};
|
|
var getBytesPerSample = (format) => {
|
|
switch (format) {
|
|
case "u8":
|
|
case "u8-planar":
|
|
return 1;
|
|
case "s16":
|
|
case "s16-planar":
|
|
return 2;
|
|
case "s32":
|
|
case "s32-planar":
|
|
return 4;
|
|
case "f32":
|
|
case "f32-planar":
|
|
return 4;
|
|
default:
|
|
throw new Error("Unknown AudioSampleFormat");
|
|
}
|
|
};
|
|
var formatIsPlanar = (format) => {
|
|
switch (format) {
|
|
case "u8-planar":
|
|
case "s16-planar":
|
|
case "s32-planar":
|
|
case "f32-planar":
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
var getReadFunction = (format) => {
|
|
switch (format) {
|
|
case "u8":
|
|
case "u8-planar":
|
|
return (view2, offset) => (view2.getUint8(offset) - 128) / 128;
|
|
case "s16":
|
|
case "s16-planar":
|
|
return (view2, offset) => view2.getInt16(offset, true) / 32768;
|
|
case "s32":
|
|
case "s32-planar":
|
|
return (view2, offset) => view2.getInt32(offset, true) / 2147483648;
|
|
case "f32":
|
|
case "f32-planar":
|
|
return (view2, offset) => view2.getFloat32(offset, true);
|
|
}
|
|
};
|
|
var getWriteFunction = (format) => {
|
|
switch (format) {
|
|
case "u8":
|
|
case "u8-planar":
|
|
return (view2, offset, value) => view2.setUint8(offset, clamp((value + 1) * 127.5, 0, 255));
|
|
case "s16":
|
|
case "s16-planar":
|
|
return (view2, offset, value) => view2.setInt16(offset, clamp(Math.round(value * 32767), -32768, 32767), true);
|
|
case "s32":
|
|
case "s32-planar":
|
|
return (view2, offset, value) => view2.setInt32(offset, clamp(Math.round(value * 2147483647), -2147483648, 2147483647), true);
|
|
case "f32":
|
|
case "f32-planar":
|
|
return (view2, offset, value) => view2.setFloat32(offset, value, true);
|
|
}
|
|
};
|
|
var isAudioData = (x) => {
|
|
return typeof AudioData !== "undefined" && x instanceof AudioData;
|
|
};
|
|
var doAudioDataCopyToWebKitWorkaround = (audioData, destView, srcFormat, destFormat, numChannels, planeIndex, frameOffset, copyFrameCount) => {
|
|
const readFn = getReadFunction(srcFormat);
|
|
const writeFn = getWriteFunction(destFormat);
|
|
const srcBytesPerSample = getBytesPerSample(srcFormat);
|
|
const destBytesPerSample = getBytesPerSample(destFormat);
|
|
const srcIsPlanar = formatIsPlanar(srcFormat);
|
|
const destIsPlanar = formatIsPlanar(destFormat);
|
|
if (destIsPlanar) {
|
|
if (srcIsPlanar) {
|
|
const data = new ArrayBuffer(copyFrameCount * srcBytesPerSample);
|
|
const dataView = toDataView(data);
|
|
audioData.copyTo(data, {
|
|
planeIndex,
|
|
frameOffset,
|
|
frameCount: copyFrameCount,
|
|
format: srcFormat
|
|
});
|
|
for (let i = 0; i < copyFrameCount; i++) {
|
|
const srcOffset = i * srcBytesPerSample;
|
|
const destOffset = i * destBytesPerSample;
|
|
const sample = readFn(dataView, srcOffset);
|
|
writeFn(destView, destOffset, sample);
|
|
}
|
|
} else {
|
|
const data = new ArrayBuffer(copyFrameCount * numChannels * srcBytesPerSample);
|
|
const dataView = toDataView(data);
|
|
audioData.copyTo(data, {
|
|
planeIndex: 0,
|
|
frameOffset,
|
|
frameCount: copyFrameCount,
|
|
format: srcFormat
|
|
});
|
|
for (let i = 0; i < copyFrameCount; i++) {
|
|
const srcOffset = (i * numChannels + planeIndex) * srcBytesPerSample;
|
|
const destOffset = i * destBytesPerSample;
|
|
const sample = readFn(dataView, srcOffset);
|
|
writeFn(destView, destOffset, sample);
|
|
}
|
|
}
|
|
} else {
|
|
if (srcIsPlanar) {
|
|
const planeSize = copyFrameCount * srcBytesPerSample;
|
|
const data = new ArrayBuffer(planeSize);
|
|
const dataView = toDataView(data);
|
|
for (let ch = 0; ch < numChannels; ch++) {
|
|
audioData.copyTo(data, {
|
|
planeIndex: ch,
|
|
frameOffset,
|
|
frameCount: copyFrameCount,
|
|
format: srcFormat
|
|
});
|
|
for (let i = 0; i < copyFrameCount; i++) {
|
|
const srcOffset = i * srcBytesPerSample;
|
|
const destOffset = (i * numChannels + ch) * destBytesPerSample;
|
|
const sample = readFn(dataView, srcOffset);
|
|
writeFn(destView, destOffset, sample);
|
|
}
|
|
}
|
|
} else {
|
|
const data = new ArrayBuffer(copyFrameCount * numChannels * srcBytesPerSample);
|
|
const dataView = toDataView(data);
|
|
audioData.copyTo(data, {
|
|
planeIndex: 0,
|
|
frameOffset,
|
|
frameCount: copyFrameCount,
|
|
format: srcFormat
|
|
});
|
|
for (let i = 0; i < copyFrameCount; i++) {
|
|
for (let ch = 0; ch < numChannels; ch++) {
|
|
const idx = i * numChannels + ch;
|
|
const srcOffset = idx * srcBytesPerSample;
|
|
const destOffset = idx * destBytesPerSample;
|
|
const sample = readFn(dataView, srcOffset);
|
|
writeFn(destView, destOffset, sample);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// src/media-sink.ts
|
|
var validatePacketRetrievalOptions = (options) => {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.metadataOnly !== void 0 && typeof options.metadataOnly !== "boolean") {
|
|
throw new TypeError("options.metadataOnly, when defined, must be a boolean.");
|
|
}
|
|
if (options.verifyKeyPackets !== void 0 && typeof options.verifyKeyPackets !== "boolean") {
|
|
throw new TypeError("options.verifyKeyPackets, when defined, must be a boolean.");
|
|
}
|
|
if (options.verifyKeyPackets && options.metadataOnly) {
|
|
throw new TypeError("options.verifyKeyPackets and options.metadataOnly cannot be enabled together.");
|
|
}
|
|
};
|
|
var validateTimestamp = (timestamp) => {
|
|
if (!isNumber(timestamp)) {
|
|
throw new TypeError("timestamp must be a number.");
|
|
}
|
|
};
|
|
var maybeFixPacketType = (track, promise, options) => {
|
|
if (options.verifyKeyPackets) {
|
|
return promise.then(async (packet) => {
|
|
if (!packet || packet.type === "delta") {
|
|
return packet;
|
|
}
|
|
const determinedType = await track.determinePacketType(packet);
|
|
if (determinedType) {
|
|
packet.type = determinedType;
|
|
}
|
|
return packet;
|
|
});
|
|
} else {
|
|
return promise;
|
|
}
|
|
};
|
|
var EncodedPacketSink = class {
|
|
/** Creates a new {@link EncodedPacketSink} for the given {@link InputTrack}. */
|
|
constructor(track) {
|
|
if (!(track instanceof InputTrack)) {
|
|
throw new TypeError("track must be an InputTrack.");
|
|
}
|
|
this._track = track;
|
|
}
|
|
/**
|
|
* Retrieves the track's first packet (in decode order), or null if it has no packets. The first packet is very
|
|
* likely to be a key packet.
|
|
*/
|
|
getFirstPacket(options = {}) {
|
|
validatePacketRetrievalOptions(options);
|
|
if (this._track.input._disposed) {
|
|
throw new InputDisposedError();
|
|
}
|
|
return maybeFixPacketType(this._track, this._track._backing.getFirstPacket(options), options);
|
|
}
|
|
/**
|
|
* Retrieves the packet corresponding to the given timestamp, in seconds. More specifically, returns the last packet
|
|
* (in presentation order) with a start timestamp less than or equal to the given timestamp. This method can be
|
|
* used to retrieve a track's last packet using `getPacket(Infinity)`. The method returns null if the timestamp
|
|
* is before the first packet in the track.
|
|
*
|
|
* @param timestamp - The timestamp used for retrieval, in seconds.
|
|
*/
|
|
getPacket(timestamp, options = {}) {
|
|
validateTimestamp(timestamp);
|
|
validatePacketRetrievalOptions(options);
|
|
if (this._track.input._disposed) {
|
|
throw new InputDisposedError();
|
|
}
|
|
return maybeFixPacketType(this._track, this._track._backing.getPacket(timestamp, options), options);
|
|
}
|
|
/**
|
|
* Retrieves the packet following the given packet (in decode order), or null if the given packet is the
|
|
* last packet.
|
|
*/
|
|
getNextPacket(packet, options = {}) {
|
|
if (!(packet instanceof EncodedPacket)) {
|
|
throw new TypeError("packet must be an EncodedPacket.");
|
|
}
|
|
validatePacketRetrievalOptions(options);
|
|
if (this._track.input._disposed) {
|
|
throw new InputDisposedError();
|
|
}
|
|
return maybeFixPacketType(this._track, this._track._backing.getNextPacket(packet, options), options);
|
|
}
|
|
/**
|
|
* Retrieves the key packet corresponding to the given timestamp, in seconds. More specifically, returns the last
|
|
* key packet (in presentation order) with a start timestamp less than or equal to the given timestamp. A key packet
|
|
* is a packet that doesn't require previous packets to be decoded. This method can be used to retrieve a track's
|
|
* last key packet using `getKeyPacket(Infinity)`. The method returns null if the timestamp is before the first
|
|
* key packet in the track.
|
|
*
|
|
* To ensure that the returned packet is guaranteed to be a real key frame, enable `options.verifyKeyPackets`.
|
|
*
|
|
* @param timestamp - The timestamp used for retrieval, in seconds.
|
|
*/
|
|
async getKeyPacket(timestamp, options = {}) {
|
|
validateTimestamp(timestamp);
|
|
validatePacketRetrievalOptions(options);
|
|
if (this._track.input._disposed) {
|
|
throw new InputDisposedError();
|
|
}
|
|
if (!options.verifyKeyPackets) {
|
|
return this._track._backing.getKeyPacket(timestamp, options);
|
|
}
|
|
const packet = await this._track._backing.getKeyPacket(timestamp, options);
|
|
if (!packet) {
|
|
return packet;
|
|
}
|
|
assert(packet.type === "key");
|
|
const determinedType = await this._track.determinePacketType(packet);
|
|
if (determinedType === "delta") {
|
|
return this.getKeyPacket(packet.timestamp - 1 / this._track.timeResolution, options);
|
|
}
|
|
return packet;
|
|
}
|
|
/**
|
|
* Retrieves the key packet following the given packet (in decode order), or null if the given packet is the last
|
|
* key packet.
|
|
*
|
|
* To ensure that the returned packet is guaranteed to be a real key frame, enable `options.verifyKeyPackets`.
|
|
*/
|
|
async getNextKeyPacket(packet, options = {}) {
|
|
if (!(packet instanceof EncodedPacket)) {
|
|
throw new TypeError("packet must be an EncodedPacket.");
|
|
}
|
|
validatePacketRetrievalOptions(options);
|
|
if (this._track.input._disposed) {
|
|
throw new InputDisposedError();
|
|
}
|
|
if (!options.verifyKeyPackets) {
|
|
return this._track._backing.getNextKeyPacket(packet, options);
|
|
}
|
|
const nextPacket = await this._track._backing.getNextKeyPacket(packet, options);
|
|
if (!nextPacket) {
|
|
return nextPacket;
|
|
}
|
|
assert(nextPacket.type === "key");
|
|
const determinedType = await this._track.determinePacketType(nextPacket);
|
|
if (determinedType === "delta") {
|
|
return this.getNextKeyPacket(nextPacket, options);
|
|
}
|
|
return nextPacket;
|
|
}
|
|
/**
|
|
* Creates an async iterator that yields the packets in this track in decode order. To enable fast iteration, this
|
|
* method will intelligently preload packets based on the speed of the consumer.
|
|
*
|
|
* @param startPacket - (optional) The packet from which iteration should begin. This packet will also be yielded.
|
|
* @param endTimestamp - (optional) The timestamp at which iteration should end. This packet will _not_ be yielded.
|
|
*/
|
|
packets(startPacket, endPacket, options = {}) {
|
|
if (startPacket !== void 0 && !(startPacket instanceof EncodedPacket)) {
|
|
throw new TypeError("startPacket must be an EncodedPacket.");
|
|
}
|
|
if (startPacket !== void 0 && startPacket.isMetadataOnly && !options?.metadataOnly) {
|
|
throw new TypeError("startPacket can only be metadata-only if options.metadataOnly is enabled.");
|
|
}
|
|
if (endPacket !== void 0 && !(endPacket instanceof EncodedPacket)) {
|
|
throw new TypeError("endPacket must be an EncodedPacket.");
|
|
}
|
|
validatePacketRetrievalOptions(options);
|
|
if (this._track.input._disposed) {
|
|
throw new InputDisposedError();
|
|
}
|
|
const packetQueue = [];
|
|
let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();
|
|
let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();
|
|
let ended = false;
|
|
let terminated = false;
|
|
let outOfBandError = null;
|
|
const timestamps = [];
|
|
const maxQueueSize = () => Math.max(2, timestamps.length);
|
|
(async () => {
|
|
let packet = startPacket ?? await this.getFirstPacket(options);
|
|
while (packet && !terminated && !this._track.input._disposed) {
|
|
if (endPacket && packet.sequenceNumber >= endPacket?.sequenceNumber) {
|
|
break;
|
|
}
|
|
if (packetQueue.length > maxQueueSize()) {
|
|
({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());
|
|
await queueDequeue;
|
|
continue;
|
|
}
|
|
packetQueue.push(packet);
|
|
onQueueNotEmpty();
|
|
({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());
|
|
packet = await this.getNextPacket(packet, options);
|
|
}
|
|
ended = true;
|
|
onQueueNotEmpty();
|
|
})().catch((error) => {
|
|
if (!outOfBandError) {
|
|
outOfBandError = error;
|
|
onQueueNotEmpty();
|
|
}
|
|
});
|
|
const track = this._track;
|
|
return {
|
|
async next() {
|
|
while (true) {
|
|
if (track.input._disposed) {
|
|
throw new InputDisposedError();
|
|
} else if (terminated) {
|
|
return { value: void 0, done: true };
|
|
} else if (outOfBandError) {
|
|
throw outOfBandError;
|
|
} else if (packetQueue.length > 0) {
|
|
const value = packetQueue.shift();
|
|
const now = performance.now();
|
|
timestamps.push(now);
|
|
while (timestamps.length > 0 && now - timestamps[0] >= 1e3) {
|
|
timestamps.shift();
|
|
}
|
|
onQueueDequeue();
|
|
return { value, done: false };
|
|
} else if (ended) {
|
|
return { value: void 0, done: true };
|
|
} else {
|
|
await queueNotEmpty;
|
|
}
|
|
}
|
|
},
|
|
async return() {
|
|
terminated = true;
|
|
onQueueDequeue();
|
|
onQueueNotEmpty();
|
|
return { value: void 0, done: true };
|
|
},
|
|
async throw(error) {
|
|
throw error;
|
|
},
|
|
[Symbol.asyncIterator]() {
|
|
return this;
|
|
}
|
|
};
|
|
}
|
|
};
|
|
var DecoderWrapper = class {
|
|
constructor(onSample, onError) {
|
|
this.onSample = onSample;
|
|
this.onError = onError;
|
|
}
|
|
};
|
|
var BaseMediaSampleSink = class {
|
|
/** @internal */
|
|
mediaSamplesInRange(startTimestamp = 0, endTimestamp = Infinity) {
|
|
validateTimestamp(startTimestamp);
|
|
validateTimestamp(endTimestamp);
|
|
const sampleQueue = [];
|
|
let firstSampleQueued = false;
|
|
let lastSample = null;
|
|
let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();
|
|
let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();
|
|
let decoderIsFlushed = false;
|
|
let ended = false;
|
|
let terminated = false;
|
|
let outOfBandError = null;
|
|
(async () => {
|
|
const decoder = await this._createDecoder((sample) => {
|
|
onQueueDequeue();
|
|
if (sample.timestamp >= endTimestamp) {
|
|
ended = true;
|
|
}
|
|
if (ended) {
|
|
sample.close();
|
|
return;
|
|
}
|
|
if (lastSample) {
|
|
if (sample.timestamp > startTimestamp) {
|
|
sampleQueue.push(lastSample);
|
|
firstSampleQueued = true;
|
|
} else {
|
|
lastSample.close();
|
|
}
|
|
}
|
|
if (sample.timestamp >= startTimestamp) {
|
|
sampleQueue.push(sample);
|
|
firstSampleQueued = true;
|
|
}
|
|
lastSample = firstSampleQueued ? null : sample;
|
|
if (sampleQueue.length > 0) {
|
|
onQueueNotEmpty();
|
|
({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());
|
|
}
|
|
}, (error) => {
|
|
if (!outOfBandError) {
|
|
outOfBandError = error;
|
|
onQueueNotEmpty();
|
|
}
|
|
});
|
|
const packetSink = this._createPacketSink();
|
|
const keyPacket = await packetSink.getKeyPacket(startTimestamp, { verifyKeyPackets: true }) ?? await packetSink.getFirstPacket();
|
|
let currentPacket = keyPacket;
|
|
const endPacket = void 0;
|
|
const packets = packetSink.packets(keyPacket ?? void 0, endPacket);
|
|
await packets.next();
|
|
while (currentPacket && !ended && !this._track.input._disposed) {
|
|
const maxQueueSize = computeMaxQueueSize(sampleQueue.length);
|
|
if (sampleQueue.length + decoder.getDecodeQueueSize() > maxQueueSize) {
|
|
({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());
|
|
await queueDequeue;
|
|
continue;
|
|
}
|
|
decoder.decode(currentPacket);
|
|
const packetResult = await packets.next();
|
|
if (packetResult.done) {
|
|
break;
|
|
}
|
|
currentPacket = packetResult.value;
|
|
}
|
|
await packets.return();
|
|
if (!terminated && !this._track.input._disposed) {
|
|
await decoder.flush();
|
|
}
|
|
decoder.close();
|
|
if (!firstSampleQueued && lastSample) {
|
|
sampleQueue.push(lastSample);
|
|
}
|
|
decoderIsFlushed = true;
|
|
onQueueNotEmpty();
|
|
})().catch((error) => {
|
|
if (!outOfBandError) {
|
|
outOfBandError = error;
|
|
onQueueNotEmpty();
|
|
}
|
|
});
|
|
const track = this._track;
|
|
const closeSamples = () => {
|
|
lastSample?.close();
|
|
for (const sample of sampleQueue) {
|
|
sample.close();
|
|
}
|
|
};
|
|
return {
|
|
async next() {
|
|
while (true) {
|
|
if (track.input._disposed) {
|
|
closeSamples();
|
|
throw new InputDisposedError();
|
|
} else if (terminated) {
|
|
return { value: void 0, done: true };
|
|
} else if (outOfBandError) {
|
|
closeSamples();
|
|
throw outOfBandError;
|
|
} else if (sampleQueue.length > 0) {
|
|
const value = sampleQueue.shift();
|
|
onQueueDequeue();
|
|
return { value, done: false };
|
|
} else if (!decoderIsFlushed) {
|
|
await queueNotEmpty;
|
|
} else {
|
|
return { value: void 0, done: true };
|
|
}
|
|
}
|
|
},
|
|
async return() {
|
|
terminated = true;
|
|
ended = true;
|
|
onQueueDequeue();
|
|
onQueueNotEmpty();
|
|
closeSamples();
|
|
return { value: void 0, done: true };
|
|
},
|
|
async throw(error) {
|
|
throw error;
|
|
},
|
|
[Symbol.asyncIterator]() {
|
|
return this;
|
|
}
|
|
};
|
|
}
|
|
/** @internal */
|
|
mediaSamplesAtTimestamps(timestamps) {
|
|
validateAnyIterable(timestamps);
|
|
const timestampIterator = toAsyncIterator(timestamps);
|
|
const timestampsOfInterest = [];
|
|
const sampleQueue = [];
|
|
let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();
|
|
let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();
|
|
let decoderIsFlushed = false;
|
|
let terminated = false;
|
|
let outOfBandError = null;
|
|
const pushToQueue = (sample) => {
|
|
sampleQueue.push(sample);
|
|
onQueueNotEmpty();
|
|
({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());
|
|
};
|
|
(async () => {
|
|
const decoder = await this._createDecoder((sample) => {
|
|
onQueueDequeue();
|
|
if (terminated) {
|
|
sample.close();
|
|
return;
|
|
}
|
|
let sampleUses = 0;
|
|
while (timestampsOfInterest.length > 0 && sample.timestamp - timestampsOfInterest[0] > -1e-10) {
|
|
sampleUses++;
|
|
timestampsOfInterest.shift();
|
|
}
|
|
if (sampleUses > 0) {
|
|
for (let i = 0; i < sampleUses; i++) {
|
|
pushToQueue(i < sampleUses - 1 ? sample.clone() : sample);
|
|
}
|
|
} else {
|
|
sample.close();
|
|
}
|
|
}, (error) => {
|
|
if (!outOfBandError) {
|
|
outOfBandError = error;
|
|
onQueueNotEmpty();
|
|
}
|
|
});
|
|
const packetSink = this._createPacketSink();
|
|
let lastPacket = null;
|
|
let lastKeyPacket = null;
|
|
let maxSequenceNumber = -1;
|
|
const decodePackets = async () => {
|
|
assert(lastKeyPacket);
|
|
let currentPacket = lastKeyPacket;
|
|
decoder.decode(currentPacket);
|
|
while (currentPacket.sequenceNumber < maxSequenceNumber) {
|
|
const maxQueueSize = computeMaxQueueSize(sampleQueue.length);
|
|
while (sampleQueue.length + decoder.getDecodeQueueSize() > maxQueueSize && !terminated) {
|
|
({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());
|
|
await queueDequeue;
|
|
}
|
|
if (terminated) {
|
|
break;
|
|
}
|
|
const nextPacket = await packetSink.getNextPacket(currentPacket);
|
|
assert(nextPacket);
|
|
decoder.decode(nextPacket);
|
|
currentPacket = nextPacket;
|
|
}
|
|
maxSequenceNumber = -1;
|
|
};
|
|
const flushDecoder = async () => {
|
|
await decoder.flush();
|
|
for (let i = 0; i < timestampsOfInterest.length; i++) {
|
|
pushToQueue(null);
|
|
}
|
|
timestampsOfInterest.length = 0;
|
|
};
|
|
for await (const timestamp of timestampIterator) {
|
|
validateTimestamp(timestamp);
|
|
if (terminated || this._track.input._disposed) {
|
|
break;
|
|
}
|
|
const targetPacket = await packetSink.getPacket(timestamp);
|
|
const keyPacket = targetPacket && await packetSink.getKeyPacket(timestamp, { verifyKeyPackets: true });
|
|
if (!keyPacket) {
|
|
if (maxSequenceNumber !== -1) {
|
|
await decodePackets();
|
|
await flushDecoder();
|
|
}
|
|
pushToQueue(null);
|
|
lastPacket = null;
|
|
continue;
|
|
}
|
|
if (lastPacket && (keyPacket.sequenceNumber !== lastKeyPacket.sequenceNumber || targetPacket.timestamp < lastPacket.timestamp)) {
|
|
await decodePackets();
|
|
await flushDecoder();
|
|
}
|
|
timestampsOfInterest.push(targetPacket.timestamp);
|
|
maxSequenceNumber = Math.max(targetPacket.sequenceNumber, maxSequenceNumber);
|
|
lastPacket = targetPacket;
|
|
lastKeyPacket = keyPacket;
|
|
}
|
|
if (!terminated && !this._track.input._disposed) {
|
|
if (maxSequenceNumber !== -1) {
|
|
await decodePackets();
|
|
}
|
|
await flushDecoder();
|
|
}
|
|
decoder.close();
|
|
decoderIsFlushed = true;
|
|
onQueueNotEmpty();
|
|
})().catch((error) => {
|
|
if (!outOfBandError) {
|
|
outOfBandError = error;
|
|
onQueueNotEmpty();
|
|
}
|
|
});
|
|
const track = this._track;
|
|
const closeSamples = () => {
|
|
for (const sample of sampleQueue) {
|
|
sample?.close();
|
|
}
|
|
};
|
|
return {
|
|
async next() {
|
|
while (true) {
|
|
if (track.input._disposed) {
|
|
closeSamples();
|
|
throw new InputDisposedError();
|
|
} else if (terminated) {
|
|
return { value: void 0, done: true };
|
|
} else if (outOfBandError) {
|
|
closeSamples();
|
|
throw outOfBandError;
|
|
} else if (sampleQueue.length > 0) {
|
|
const value = sampleQueue.shift();
|
|
assert(value !== void 0);
|
|
onQueueDequeue();
|
|
return { value, done: false };
|
|
} else if (!decoderIsFlushed) {
|
|
await queueNotEmpty;
|
|
} else {
|
|
return { value: void 0, done: true };
|
|
}
|
|
}
|
|
},
|
|
async return() {
|
|
terminated = true;
|
|
onQueueDequeue();
|
|
onQueueNotEmpty();
|
|
closeSamples();
|
|
return { value: void 0, done: true };
|
|
},
|
|
async throw(error) {
|
|
throw error;
|
|
},
|
|
[Symbol.asyncIterator]() {
|
|
return this;
|
|
}
|
|
};
|
|
}
|
|
};
|
|
var computeMaxQueueSize = (decodedSampleQueueSize) => {
|
|
return decodedSampleQueueSize === 0 ? 40 : 8;
|
|
};
|
|
var VideoDecoderWrapper = class extends DecoderWrapper {
|
|
// For HEVC stuff
|
|
constructor(onSample, onError, codec, decoderConfig, rotation, timeResolution) {
|
|
super(onSample, onError);
|
|
this.codec = codec;
|
|
this.decoderConfig = decoderConfig;
|
|
this.rotation = rotation;
|
|
this.timeResolution = timeResolution;
|
|
this.decoder = null;
|
|
this.customDecoder = null;
|
|
this.customDecoderCallSerializer = new CallSerializer();
|
|
this.customDecoderQueueSize = 0;
|
|
this.inputTimestamps = [];
|
|
// Timestamps input into the decoder, sorted.
|
|
this.sampleQueue = [];
|
|
// Safari-specific thing, check usage.
|
|
this.currentPacketIndex = 0;
|
|
this.raslSkipped = false;
|
|
// For HEVC stuff
|
|
// Alpha stuff
|
|
this.alphaDecoder = null;
|
|
this.alphaHadKeyframe = false;
|
|
this.colorQueue = [];
|
|
this.alphaQueue = [];
|
|
this.merger = null;
|
|
this.mergerCreationFailed = false;
|
|
this.decodedAlphaChunkCount = 0;
|
|
this.alphaDecoderQueueSize = 0;
|
|
/** Each value is the number of decoded alpha chunks at which a null alpha frame should be added. */
|
|
this.nullAlphaFrameQueue = [];
|
|
this.currentAlphaPacketIndex = 0;
|
|
this.alphaRaslSkipped = false;
|
|
const MatchingCustomDecoder = customVideoDecoders.find((x) => x.supports(codec, decoderConfig));
|
|
if (MatchingCustomDecoder) {
|
|
this.customDecoder = new MatchingCustomDecoder();
|
|
this.customDecoder.codec = codec;
|
|
this.customDecoder.config = decoderConfig;
|
|
this.customDecoder.onSample = (sample) => {
|
|
if (!(sample instanceof VideoSample)) {
|
|
throw new TypeError("The argument passed to onSample must be a VideoSample.");
|
|
}
|
|
this.finalizeAndEmitSample(sample);
|
|
};
|
|
void this.customDecoderCallSerializer.call(() => this.customDecoder.init());
|
|
} else {
|
|
const colorHandler = (frame) => {
|
|
if (this.alphaQueue.length > 0) {
|
|
const alphaFrame = this.alphaQueue.shift();
|
|
assert(alphaFrame !== void 0);
|
|
this.mergeAlpha(frame, alphaFrame);
|
|
} else {
|
|
this.colorQueue.push(frame);
|
|
}
|
|
};
|
|
if (codec === "avc" && this.decoderConfig.description && isChromium()) {
|
|
const record = deserializeAvcDecoderConfigurationRecord(toUint8Array(this.decoderConfig.description));
|
|
if (record && record.sequenceParameterSets.length > 0) {
|
|
const sps = parseAvcSps(record.sequenceParameterSets[0]);
|
|
if (sps && sps.frameMbsOnlyFlag === 0) {
|
|
this.decoderConfig = {
|
|
...this.decoderConfig,
|
|
hardwareAcceleration: "prefer-software"
|
|
};
|
|
}
|
|
}
|
|
}
|
|
const stack = new Error("Decoding error").stack;
|
|
this.decoder = new VideoDecoder({
|
|
output: (frame) => {
|
|
try {
|
|
colorHandler(frame);
|
|
} catch (error) {
|
|
this.onError(error);
|
|
}
|
|
},
|
|
error: (error) => {
|
|
error.stack = stack;
|
|
this.onError(error);
|
|
}
|
|
});
|
|
this.decoder.configure(this.decoderConfig);
|
|
}
|
|
}
|
|
getDecodeQueueSize() {
|
|
if (this.customDecoder) {
|
|
return this.customDecoderQueueSize;
|
|
} else {
|
|
assert(this.decoder);
|
|
return Math.max(
|
|
this.decoder.decodeQueueSize,
|
|
this.alphaDecoder?.decodeQueueSize ?? 0
|
|
);
|
|
}
|
|
}
|
|
decode(packet) {
|
|
if (this.codec === "hevc" && this.currentPacketIndex > 0 && !this.raslSkipped) {
|
|
if (this.hasHevcRaslPicture(packet.data)) {
|
|
return;
|
|
}
|
|
this.raslSkipped = true;
|
|
}
|
|
if (this.customDecoder) {
|
|
this.customDecoderQueueSize++;
|
|
void this.customDecoderCallSerializer.call(() => this.customDecoder.decode(packet)).then(() => this.customDecoderQueueSize--);
|
|
} else {
|
|
assert(this.decoder);
|
|
if (!isWebKit()) {
|
|
insertSorted(this.inputTimestamps, packet.timestamp, (x) => x);
|
|
}
|
|
if (isChromium() && this.currentPacketIndex === 0 && this.codec === "avc") {
|
|
const filteredNalUnits = [];
|
|
for (const loc of iterateAvcNalUnits(packet.data, this.decoderConfig)) {
|
|
const type = extractNalUnitTypeForAvc(packet.data[loc.offset]);
|
|
if (!(type >= 20 && type <= 31)) {
|
|
filteredNalUnits.push(packet.data.subarray(loc.offset, loc.offset + loc.length));
|
|
}
|
|
}
|
|
const newData = concatAvcNalUnits(filteredNalUnits, this.decoderConfig);
|
|
packet = new EncodedPacket(newData, packet.type, packet.timestamp, packet.duration);
|
|
}
|
|
this.decoder.decode(packet.toEncodedVideoChunk());
|
|
this.decodeAlphaData(packet);
|
|
}
|
|
this.currentPacketIndex++;
|
|
}
|
|
decodeAlphaData(packet) {
|
|
if (!packet.sideData.alpha || this.mergerCreationFailed) {
|
|
this.pushNullAlphaFrame();
|
|
return;
|
|
}
|
|
if (!this.merger) {
|
|
try {
|
|
this.merger = new ColorAlphaMerger();
|
|
} catch (error) {
|
|
console.error("Due to an error, only color data will be decoded.", error);
|
|
this.mergerCreationFailed = true;
|
|
this.decodeAlphaData(packet);
|
|
return;
|
|
}
|
|
}
|
|
if (!this.alphaDecoder) {
|
|
const alphaHandler = (frame) => {
|
|
this.alphaDecoderQueueSize--;
|
|
if (this.colorQueue.length > 0) {
|
|
const colorFrame = this.colorQueue.shift();
|
|
assert(colorFrame !== void 0);
|
|
this.mergeAlpha(colorFrame, frame);
|
|
} else {
|
|
this.alphaQueue.push(frame);
|
|
}
|
|
this.decodedAlphaChunkCount++;
|
|
while (this.nullAlphaFrameQueue.length > 0 && this.nullAlphaFrameQueue[0] === this.decodedAlphaChunkCount) {
|
|
this.nullAlphaFrameQueue.shift();
|
|
if (this.colorQueue.length > 0) {
|
|
const colorFrame = this.colorQueue.shift();
|
|
assert(colorFrame !== void 0);
|
|
this.mergeAlpha(colorFrame, null);
|
|
} else {
|
|
this.alphaQueue.push(null);
|
|
}
|
|
}
|
|
};
|
|
const stack = new Error("Decoding error").stack;
|
|
this.alphaDecoder = new VideoDecoder({
|
|
output: (frame) => {
|
|
try {
|
|
alphaHandler(frame);
|
|
} catch (error) {
|
|
this.onError(error);
|
|
}
|
|
},
|
|
error: (error) => {
|
|
error.stack = stack;
|
|
this.onError(error);
|
|
}
|
|
});
|
|
this.alphaDecoder.configure(this.decoderConfig);
|
|
}
|
|
const type = determineVideoPacketType(this.codec, this.decoderConfig, packet.sideData.alpha);
|
|
if (!this.alphaHadKeyframe) {
|
|
this.alphaHadKeyframe = type === "key";
|
|
}
|
|
if (this.alphaHadKeyframe) {
|
|
if (this.codec === "hevc" && this.currentAlphaPacketIndex > 0 && !this.alphaRaslSkipped) {
|
|
if (this.hasHevcRaslPicture(packet.sideData.alpha)) {
|
|
this.pushNullAlphaFrame();
|
|
return;
|
|
}
|
|
this.alphaRaslSkipped = true;
|
|
}
|
|
this.currentAlphaPacketIndex++;
|
|
this.alphaDecoder.decode(packet.alphaToEncodedVideoChunk(type ?? packet.type));
|
|
this.alphaDecoderQueueSize++;
|
|
} else {
|
|
this.pushNullAlphaFrame();
|
|
}
|
|
}
|
|
pushNullAlphaFrame() {
|
|
if (this.alphaDecoderQueueSize === 0) {
|
|
this.alphaQueue.push(null);
|
|
} else {
|
|
this.nullAlphaFrameQueue.push(this.decodedAlphaChunkCount + this.alphaDecoderQueueSize);
|
|
}
|
|
}
|
|
/**
|
|
* If we're using HEVC, we need to make sure to skip any RASL slices that follow a non-IDR key frame such as
|
|
* CRA_NUT. This is because RASL slices cannot be decoded without data before the CRA_NUT. Browsers behave
|
|
* differently here: Chromium drops the packets, Safari throws a decoder error. Either way, it's not good
|
|
* and causes bugs upstream. So, let's take the dropping into our own hands.
|
|
*/
|
|
hasHevcRaslPicture(packetData) {
|
|
for (const loc of iterateHevcNalUnits(packetData, this.decoderConfig)) {
|
|
const type = extractNalUnitTypeForHevc(packetData[loc.offset]);
|
|
if (type === 8 /* RASL_N */ || type === 9 /* RASL_R */) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
/** Handler for the WebCodecs VideoDecoder for ironing out browser differences. */
|
|
sampleHandler(sample) {
|
|
if (isWebKit()) {
|
|
if (this.sampleQueue.length > 0 && sample.timestamp >= last(this.sampleQueue).timestamp) {
|
|
for (const sample2 of this.sampleQueue) {
|
|
this.finalizeAndEmitSample(sample2);
|
|
}
|
|
this.sampleQueue.length = 0;
|
|
}
|
|
insertSorted(this.sampleQueue, sample, (x) => x.timestamp);
|
|
} else {
|
|
const timestamp = this.inputTimestamps.shift();
|
|
assert(timestamp !== void 0);
|
|
sample.setTimestamp(timestamp);
|
|
this.finalizeAndEmitSample(sample);
|
|
}
|
|
}
|
|
finalizeAndEmitSample(sample) {
|
|
sample.setTimestamp(Math.round(sample.timestamp * this.timeResolution) / this.timeResolution);
|
|
sample.setDuration(Math.round(sample.duration * this.timeResolution) / this.timeResolution);
|
|
sample.setRotation(this.rotation);
|
|
this.onSample(sample);
|
|
}
|
|
mergeAlpha(color, alpha) {
|
|
if (!alpha) {
|
|
const finalSample2 = new VideoSample(color);
|
|
this.sampleHandler(finalSample2);
|
|
return;
|
|
}
|
|
assert(this.merger);
|
|
this.merger.update(color, alpha);
|
|
color.close();
|
|
alpha.close();
|
|
const finalFrame = new VideoFrame(this.merger.canvas, {
|
|
timestamp: color.timestamp,
|
|
duration: color.duration ?? void 0
|
|
});
|
|
const finalSample = new VideoSample(finalFrame);
|
|
this.sampleHandler(finalSample);
|
|
}
|
|
async flush() {
|
|
if (this.customDecoder) {
|
|
await this.customDecoderCallSerializer.call(() => this.customDecoder.flush());
|
|
} else {
|
|
assert(this.decoder);
|
|
await Promise.all([
|
|
this.decoder.flush(),
|
|
this.alphaDecoder?.flush()
|
|
]);
|
|
this.colorQueue.forEach((x) => x.close());
|
|
this.colorQueue.length = 0;
|
|
this.alphaQueue.forEach((x) => x?.close());
|
|
this.alphaQueue.length = 0;
|
|
this.alphaHadKeyframe = false;
|
|
this.decodedAlphaChunkCount = 0;
|
|
this.alphaDecoderQueueSize = 0;
|
|
this.nullAlphaFrameQueue.length = 0;
|
|
this.currentAlphaPacketIndex = 0;
|
|
this.alphaRaslSkipped = false;
|
|
}
|
|
if (isWebKit()) {
|
|
for (const sample of this.sampleQueue) {
|
|
this.finalizeAndEmitSample(sample);
|
|
}
|
|
this.sampleQueue.length = 0;
|
|
}
|
|
this.currentPacketIndex = 0;
|
|
this.raslSkipped = false;
|
|
}
|
|
close() {
|
|
if (this.customDecoder) {
|
|
void this.customDecoderCallSerializer.call(() => this.customDecoder.close());
|
|
} else {
|
|
assert(this.decoder);
|
|
this.decoder.close();
|
|
this.alphaDecoder?.close();
|
|
this.colorQueue.forEach((x) => x.close());
|
|
this.colorQueue.length = 0;
|
|
this.alphaQueue.forEach((x) => x?.close());
|
|
this.alphaQueue.length = 0;
|
|
this.merger?.close();
|
|
}
|
|
for (const sample of this.sampleQueue) {
|
|
sample.close();
|
|
}
|
|
this.sampleQueue.length = 0;
|
|
}
|
|
};
|
|
var ColorAlphaMerger = class {
|
|
constructor() {
|
|
if (typeof OffscreenCanvas !== "undefined") {
|
|
this.canvas = new OffscreenCanvas(300, 150);
|
|
} else {
|
|
this.canvas = document.createElement("canvas");
|
|
}
|
|
const gl = this.canvas.getContext("webgl2", {
|
|
premultipliedAlpha: false
|
|
});
|
|
if (!gl) {
|
|
throw new Error("Couldn't acquire WebGL 2 context.");
|
|
}
|
|
this.gl = gl;
|
|
this.program = this.createProgram();
|
|
this.vao = this.createVAO();
|
|
this.colorTexture = this.createTexture();
|
|
this.alphaTexture = this.createTexture();
|
|
this.gl.useProgram(this.program);
|
|
this.gl.uniform1i(this.gl.getUniformLocation(this.program, "u_colorTexture"), 0);
|
|
this.gl.uniform1i(this.gl.getUniformLocation(this.program, "u_alphaTexture"), 1);
|
|
}
|
|
createProgram() {
|
|
const vertexShader = this.createShader(this.gl.VERTEX_SHADER, `#version 300 es
|
|
in vec2 a_position;
|
|
in vec2 a_texCoord;
|
|
out vec2 v_texCoord;
|
|
|
|
void main() {
|
|
gl_Position = vec4(a_position, 0.0, 1.0);
|
|
v_texCoord = a_texCoord;
|
|
}
|
|
`);
|
|
const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, `#version 300 es
|
|
precision highp float;
|
|
|
|
uniform sampler2D u_colorTexture;
|
|
uniform sampler2D u_alphaTexture;
|
|
in vec2 v_texCoord;
|
|
out vec4 fragColor;
|
|
|
|
void main() {
|
|
vec3 color = texture(u_colorTexture, v_texCoord).rgb;
|
|
float alpha = texture(u_alphaTexture, v_texCoord).r;
|
|
fragColor = vec4(color, alpha);
|
|
}
|
|
`);
|
|
const program = this.gl.createProgram();
|
|
this.gl.attachShader(program, vertexShader);
|
|
this.gl.attachShader(program, fragmentShader);
|
|
this.gl.linkProgram(program);
|
|
return program;
|
|
}
|
|
createShader(type, source) {
|
|
const shader = this.gl.createShader(type);
|
|
this.gl.shaderSource(shader, source);
|
|
this.gl.compileShader(shader);
|
|
return shader;
|
|
}
|
|
createVAO() {
|
|
const vao = this.gl.createVertexArray();
|
|
this.gl.bindVertexArray(vao);
|
|
const vertices = new Float32Array([
|
|
-1,
|
|
-1,
|
|
0,
|
|
1,
|
|
1,
|
|
-1,
|
|
1,
|
|
1,
|
|
-1,
|
|
1,
|
|
0,
|
|
0,
|
|
1,
|
|
1,
|
|
1,
|
|
0
|
|
]);
|
|
const buffer = this.gl.createBuffer();
|
|
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer);
|
|
this.gl.bufferData(this.gl.ARRAY_BUFFER, vertices, this.gl.STATIC_DRAW);
|
|
const positionLocation = this.gl.getAttribLocation(this.program, "a_position");
|
|
const texCoordLocation = this.gl.getAttribLocation(this.program, "a_texCoord");
|
|
this.gl.enableVertexAttribArray(positionLocation);
|
|
this.gl.vertexAttribPointer(positionLocation, 2, this.gl.FLOAT, false, 16, 0);
|
|
this.gl.enableVertexAttribArray(texCoordLocation);
|
|
this.gl.vertexAttribPointer(texCoordLocation, 2, this.gl.FLOAT, false, 16, 8);
|
|
return vao;
|
|
}
|
|
createTexture() {
|
|
const texture = this.gl.createTexture();
|
|
this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
|
|
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
|
|
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
|
|
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
|
|
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
|
|
return texture;
|
|
}
|
|
update(color, alpha) {
|
|
if (color.displayWidth !== this.canvas.width || color.displayHeight !== this.canvas.height) {
|
|
this.canvas.width = color.displayWidth;
|
|
this.canvas.height = color.displayHeight;
|
|
}
|
|
this.gl.activeTexture(this.gl.TEXTURE0);
|
|
this.gl.bindTexture(this.gl.TEXTURE_2D, this.colorTexture);
|
|
this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, color);
|
|
this.gl.activeTexture(this.gl.TEXTURE1);
|
|
this.gl.bindTexture(this.gl.TEXTURE_2D, this.alphaTexture);
|
|
this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, alpha);
|
|
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
|
this.gl.bindVertexArray(this.vao);
|
|
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
|
}
|
|
close() {
|
|
this.gl.getExtension("WEBGL_lose_context")?.loseContext();
|
|
this.gl = null;
|
|
}
|
|
};
|
|
var VideoSampleSink = class extends BaseMediaSampleSink {
|
|
/** Creates a new {@link VideoSampleSink} for the given {@link InputVideoTrack}. */
|
|
constructor(videoTrack) {
|
|
if (!(videoTrack instanceof InputVideoTrack)) {
|
|
throw new TypeError("videoTrack must be an InputVideoTrack.");
|
|
}
|
|
super();
|
|
this._track = videoTrack;
|
|
}
|
|
/** @internal */
|
|
async _createDecoder(onSample, onError) {
|
|
if (!await this._track.canDecode()) {
|
|
throw new Error(
|
|
"This video track cannot be decoded by this browser. Make sure to check decodability before using a track."
|
|
);
|
|
}
|
|
const codec = this._track.codec;
|
|
const rotation = this._track.rotation;
|
|
const decoderConfig = await this._track.getDecoderConfig();
|
|
const timeResolution = this._track.timeResolution;
|
|
assert(codec && decoderConfig);
|
|
return new VideoDecoderWrapper(onSample, onError, codec, decoderConfig, rotation, timeResolution);
|
|
}
|
|
/** @internal */
|
|
_createPacketSink() {
|
|
return new EncodedPacketSink(this._track);
|
|
}
|
|
/**
|
|
* Retrieves the video sample (frame) corresponding to the given timestamp, in seconds. More specifically, returns
|
|
* the last video sample (in presentation order) with a start timestamp less than or equal to the given timestamp.
|
|
* Returns null if the timestamp is before the track's first timestamp.
|
|
*
|
|
* @param timestamp - The timestamp used for retrieval, in seconds.
|
|
*/
|
|
async getSample(timestamp) {
|
|
validateTimestamp(timestamp);
|
|
for await (const sample of this.mediaSamplesAtTimestamps([timestamp])) {
|
|
return sample;
|
|
}
|
|
throw new Error("Internal error: Iterator returned nothing.");
|
|
}
|
|
/**
|
|
* Creates an async iterator that yields the video samples (frames) of this track in presentation order. This method
|
|
* will intelligently pre-decode a few frames ahead to enable fast iteration.
|
|
*
|
|
* @param startTimestamp - The timestamp in seconds at which to start yielding samples (inclusive).
|
|
* @param endTimestamp - The timestamp in seconds at which to stop yielding samples (exclusive).
|
|
*/
|
|
samples(startTimestamp = 0, endTimestamp = Infinity) {
|
|
return this.mediaSamplesInRange(startTimestamp, endTimestamp);
|
|
}
|
|
/**
|
|
* Creates an async iterator that yields a video sample (frame) for each timestamp in the argument. This method
|
|
* uses an optimized decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most
|
|
* once, and is therefore more efficient than manually getting the sample for every timestamp. The iterator may
|
|
* yield null if no frame is available for a given timestamp.
|
|
*
|
|
* @param timestamps - An iterable or async iterable of timestamps in seconds.
|
|
*/
|
|
samplesAtTimestamps(timestamps) {
|
|
return this.mediaSamplesAtTimestamps(timestamps);
|
|
}
|
|
};
|
|
var CanvasSink = class {
|
|
/** Creates a new {@link CanvasSink} for the given {@link InputVideoTrack}. */
|
|
constructor(videoTrack, options = {}) {
|
|
/** @internal */
|
|
this._nextCanvasIndex = 0;
|
|
if (!(videoTrack instanceof InputVideoTrack)) {
|
|
throw new TypeError("videoTrack must be an InputVideoTrack.");
|
|
}
|
|
if (options && typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.alpha !== void 0 && typeof options.alpha !== "boolean") {
|
|
throw new TypeError("options.alpha, when provided, must be a boolean.");
|
|
}
|
|
if (options.width !== void 0 && (!Number.isInteger(options.width) || options.width <= 0)) {
|
|
throw new TypeError("options.width, when defined, must be a positive integer.");
|
|
}
|
|
if (options.height !== void 0 && (!Number.isInteger(options.height) || options.height <= 0)) {
|
|
throw new TypeError("options.height, when defined, must be a positive integer.");
|
|
}
|
|
if (options.fit !== void 0 && !["fill", "contain", "cover"].includes(options.fit)) {
|
|
throw new TypeError('options.fit, when provided, must be one of "fill", "contain", or "cover".');
|
|
}
|
|
if (options.width !== void 0 && options.height !== void 0 && options.fit === void 0) {
|
|
throw new TypeError(
|
|
"When both options.width and options.height are provided, options.fit must also be provided."
|
|
);
|
|
}
|
|
if (options.rotation !== void 0 && ![0, 90, 180, 270].includes(options.rotation)) {
|
|
throw new TypeError("options.rotation, when provided, must be 0, 90, 180 or 270.");
|
|
}
|
|
if (options.crop !== void 0) {
|
|
validateCropRectangle(options.crop, "options.");
|
|
}
|
|
if (options.poolSize !== void 0 && (typeof options.poolSize !== "number" || !Number.isInteger(options.poolSize) || options.poolSize < 0)) {
|
|
throw new TypeError("poolSize must be a non-negative integer.");
|
|
}
|
|
const rotation = options.rotation ?? videoTrack.rotation;
|
|
const [rotatedWidth, rotatedHeight] = rotation % 180 === 0 ? [videoTrack.codedWidth, videoTrack.codedHeight] : [videoTrack.codedHeight, videoTrack.codedWidth];
|
|
const crop = options.crop;
|
|
if (crop) {
|
|
clampCropRectangle(crop, rotatedWidth, rotatedHeight);
|
|
}
|
|
let [width, height] = crop ? [crop.width, crop.height] : [rotatedWidth, rotatedHeight];
|
|
const originalAspectRatio = width / height;
|
|
if (options.width !== void 0 && options.height === void 0) {
|
|
width = options.width;
|
|
height = Math.round(width / originalAspectRatio);
|
|
} else if (options.width === void 0 && options.height !== void 0) {
|
|
height = options.height;
|
|
width = Math.round(height * originalAspectRatio);
|
|
} else if (options.width !== void 0 && options.height !== void 0) {
|
|
width = options.width;
|
|
height = options.height;
|
|
}
|
|
this._videoTrack = videoTrack;
|
|
this._alpha = options.alpha ?? false;
|
|
this._width = width;
|
|
this._height = height;
|
|
this._rotation = rotation;
|
|
this._crop = crop;
|
|
this._fit = options.fit ?? "fill";
|
|
this._videoSampleSink = new VideoSampleSink(videoTrack);
|
|
this._canvasPool = Array.from({ length: options.poolSize ?? 0 }, () => null);
|
|
}
|
|
/** @internal */
|
|
_videoSampleToWrappedCanvas(sample) {
|
|
let canvas = this._canvasPool[this._nextCanvasIndex];
|
|
let canvasIsNew = false;
|
|
if (!canvas) {
|
|
if (typeof document !== "undefined") {
|
|
canvas = document.createElement("canvas");
|
|
canvas.width = this._width;
|
|
canvas.height = this._height;
|
|
} else {
|
|
canvas = new OffscreenCanvas(this._width, this._height);
|
|
}
|
|
if (this._canvasPool.length > 0) {
|
|
this._canvasPool[this._nextCanvasIndex] = canvas;
|
|
}
|
|
canvasIsNew = true;
|
|
}
|
|
if (this._canvasPool.length > 0) {
|
|
this._nextCanvasIndex = (this._nextCanvasIndex + 1) % this._canvasPool.length;
|
|
}
|
|
const context = canvas.getContext("2d", {
|
|
alpha: this._alpha || isFirefox()
|
|
// Firefox has VideoFrame glitches with opaque canvases
|
|
});
|
|
assert(context);
|
|
context.resetTransform();
|
|
if (!canvasIsNew) {
|
|
if (!this._alpha && isFirefox()) {
|
|
context.fillStyle = "black";
|
|
context.fillRect(0, 0, this._width, this._height);
|
|
} else {
|
|
context.clearRect(0, 0, this._width, this._height);
|
|
}
|
|
}
|
|
sample.drawWithFit(context, {
|
|
fit: this._fit,
|
|
rotation: this._rotation,
|
|
crop: this._crop
|
|
});
|
|
const result = {
|
|
canvas,
|
|
timestamp: sample.timestamp,
|
|
duration: sample.duration
|
|
};
|
|
sample.close();
|
|
return result;
|
|
}
|
|
/**
|
|
* Retrieves a canvas with the video frame corresponding to the given timestamp, in seconds. More specifically,
|
|
* returns the last video frame (in presentation order) with a start timestamp less than or equal to the given
|
|
* timestamp. Returns null if the timestamp is before the track's first timestamp.
|
|
*
|
|
* @param timestamp - The timestamp used for retrieval, in seconds.
|
|
*/
|
|
async getCanvas(timestamp) {
|
|
validateTimestamp(timestamp);
|
|
const sample = await this._videoSampleSink.getSample(timestamp);
|
|
return sample && this._videoSampleToWrappedCanvas(sample);
|
|
}
|
|
/**
|
|
* Creates an async iterator that yields canvases with the video frames of this track in presentation order. This
|
|
* method will intelligently pre-decode a few frames ahead to enable fast iteration.
|
|
*
|
|
* @param startTimestamp - The timestamp in seconds at which to start yielding canvases (inclusive).
|
|
* @param endTimestamp - The timestamp in seconds at which to stop yielding canvases (exclusive).
|
|
*/
|
|
canvases(startTimestamp = 0, endTimestamp = Infinity) {
|
|
return mapAsyncGenerator(
|
|
this._videoSampleSink.samples(startTimestamp, endTimestamp),
|
|
(sample) => this._videoSampleToWrappedCanvas(sample)
|
|
);
|
|
}
|
|
/**
|
|
* Creates an async iterator that yields a canvas for each timestamp in the argument. This method uses an optimized
|
|
* decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most once, and is
|
|
* therefore more efficient than manually getting the canvas for every timestamp. The iterator may yield null if
|
|
* no frame is available for a given timestamp.
|
|
*
|
|
* @param timestamps - An iterable or async iterable of timestamps in seconds.
|
|
*/
|
|
canvasesAtTimestamps(timestamps) {
|
|
return mapAsyncGenerator(
|
|
this._videoSampleSink.samplesAtTimestamps(timestamps),
|
|
(sample) => sample && this._videoSampleToWrappedCanvas(sample)
|
|
);
|
|
}
|
|
};
|
|
var AudioDecoderWrapper = class extends DecoderWrapper {
|
|
constructor(onSample, onError, codec, decoderConfig) {
|
|
super(onSample, onError);
|
|
this.decoder = null;
|
|
this.customDecoder = null;
|
|
this.customDecoderCallSerializer = new CallSerializer();
|
|
this.customDecoderQueueSize = 0;
|
|
// Internal state to accumulate a precise current timestamp based on audio durations, not the (potentially
|
|
// inaccurate) packet timestamps.
|
|
this.currentTimestamp = null;
|
|
const sampleHandler = (sample) => {
|
|
if (this.currentTimestamp === null || Math.abs(sample.timestamp - this.currentTimestamp) >= sample.duration) {
|
|
this.currentTimestamp = sample.timestamp;
|
|
}
|
|
const preciseTimestamp = this.currentTimestamp;
|
|
this.currentTimestamp += sample.duration;
|
|
if (sample.numberOfFrames === 0) {
|
|
sample.close();
|
|
return;
|
|
}
|
|
const sampleRate = decoderConfig.sampleRate;
|
|
sample.setTimestamp(Math.round(preciseTimestamp * sampleRate) / sampleRate);
|
|
onSample(sample);
|
|
};
|
|
const MatchingCustomDecoder = customAudioDecoders.find((x) => x.supports(codec, decoderConfig));
|
|
if (MatchingCustomDecoder) {
|
|
this.customDecoder = new MatchingCustomDecoder();
|
|
this.customDecoder.codec = codec;
|
|
this.customDecoder.config = decoderConfig;
|
|
this.customDecoder.onSample = (sample) => {
|
|
if (!(sample instanceof AudioSample)) {
|
|
throw new TypeError("The argument passed to onSample must be an AudioSample.");
|
|
}
|
|
sampleHandler(sample);
|
|
};
|
|
void this.customDecoderCallSerializer.call(() => this.customDecoder.init());
|
|
} else {
|
|
const stack = new Error("Decoding error").stack;
|
|
this.decoder = new AudioDecoder({
|
|
output: (data) => {
|
|
try {
|
|
sampleHandler(new AudioSample(data));
|
|
} catch (error) {
|
|
this.onError(error);
|
|
}
|
|
},
|
|
error: (error) => {
|
|
error.stack = stack;
|
|
this.onError(error);
|
|
}
|
|
});
|
|
this.decoder.configure(decoderConfig);
|
|
}
|
|
}
|
|
getDecodeQueueSize() {
|
|
if (this.customDecoder) {
|
|
return this.customDecoderQueueSize;
|
|
} else {
|
|
assert(this.decoder);
|
|
return this.decoder.decodeQueueSize;
|
|
}
|
|
}
|
|
decode(packet) {
|
|
if (this.customDecoder) {
|
|
this.customDecoderQueueSize++;
|
|
void this.customDecoderCallSerializer.call(() => this.customDecoder.decode(packet)).then(() => this.customDecoderQueueSize--);
|
|
} else {
|
|
assert(this.decoder);
|
|
this.decoder.decode(packet.toEncodedAudioChunk());
|
|
}
|
|
}
|
|
flush() {
|
|
if (this.customDecoder) {
|
|
return this.customDecoderCallSerializer.call(() => this.customDecoder.flush());
|
|
} else {
|
|
assert(this.decoder);
|
|
return this.decoder.flush();
|
|
}
|
|
}
|
|
close() {
|
|
if (this.customDecoder) {
|
|
void this.customDecoderCallSerializer.call(() => this.customDecoder.close());
|
|
} else {
|
|
assert(this.decoder);
|
|
this.decoder.close();
|
|
}
|
|
}
|
|
};
|
|
var PcmAudioDecoderWrapper = class extends DecoderWrapper {
|
|
constructor(onSample, onError, decoderConfig) {
|
|
super(onSample, onError);
|
|
this.decoderConfig = decoderConfig;
|
|
// Internal state to accumulate a precise current timestamp based on audio durations, not the (potentially
|
|
// inaccurate) packet timestamps.
|
|
this.currentTimestamp = null;
|
|
assert(PCM_AUDIO_CODECS.includes(decoderConfig.codec));
|
|
this.codec = decoderConfig.codec;
|
|
const { dataType, sampleSize, littleEndian } = parsePcmCodec(this.codec);
|
|
this.inputSampleSize = sampleSize;
|
|
switch (sampleSize) {
|
|
case 1:
|
|
{
|
|
if (dataType === "unsigned") {
|
|
this.readInputValue = (view2, byteOffset) => view2.getUint8(byteOffset) - 2 ** 7;
|
|
} else if (dataType === "signed") {
|
|
this.readInputValue = (view2, byteOffset) => view2.getInt8(byteOffset);
|
|
} else if (dataType === "ulaw") {
|
|
this.readInputValue = (view2, byteOffset) => fromUlaw(view2.getUint8(byteOffset));
|
|
} else if (dataType === "alaw") {
|
|
this.readInputValue = (view2, byteOffset) => fromAlaw(view2.getUint8(byteOffset));
|
|
} else {
|
|
assert(false);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 2:
|
|
{
|
|
if (dataType === "unsigned") {
|
|
this.readInputValue = (view2, byteOffset) => view2.getUint16(byteOffset, littleEndian) - 2 ** 15;
|
|
} else if (dataType === "signed") {
|
|
this.readInputValue = (view2, byteOffset) => view2.getInt16(byteOffset, littleEndian);
|
|
} else {
|
|
assert(false);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 3:
|
|
{
|
|
if (dataType === "unsigned") {
|
|
this.readInputValue = (view2, byteOffset) => getUint24(view2, byteOffset, littleEndian) - 2 ** 23;
|
|
} else if (dataType === "signed") {
|
|
this.readInputValue = (view2, byteOffset) => getInt24(view2, byteOffset, littleEndian);
|
|
} else {
|
|
assert(false);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 4:
|
|
{
|
|
if (dataType === "unsigned") {
|
|
this.readInputValue = (view2, byteOffset) => view2.getUint32(byteOffset, littleEndian) - 2 ** 31;
|
|
} else if (dataType === "signed") {
|
|
this.readInputValue = (view2, byteOffset) => view2.getInt32(byteOffset, littleEndian);
|
|
} else if (dataType === "float") {
|
|
this.readInputValue = (view2, byteOffset) => view2.getFloat32(byteOffset, littleEndian);
|
|
} else {
|
|
assert(false);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 8:
|
|
{
|
|
if (dataType === "float") {
|
|
this.readInputValue = (view2, byteOffset) => view2.getFloat64(byteOffset, littleEndian);
|
|
} else {
|
|
assert(false);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
{
|
|
assertNever(sampleSize);
|
|
assert(false);
|
|
}
|
|
;
|
|
}
|
|
switch (sampleSize) {
|
|
case 1:
|
|
{
|
|
if (dataType === "ulaw" || dataType === "alaw") {
|
|
this.outputSampleSize = 2;
|
|
this.outputFormat = "s16";
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setInt16(byteOffset, value, true);
|
|
} else {
|
|
this.outputSampleSize = 1;
|
|
this.outputFormat = "u8";
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setUint8(byteOffset, value + 2 ** 7);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 2:
|
|
{
|
|
this.outputSampleSize = 2;
|
|
this.outputFormat = "s16";
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setInt16(byteOffset, value, true);
|
|
}
|
|
;
|
|
break;
|
|
case 3:
|
|
{
|
|
this.outputSampleSize = 4;
|
|
this.outputFormat = "s32";
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setInt32(byteOffset, value << 8, true);
|
|
}
|
|
;
|
|
break;
|
|
case 4:
|
|
{
|
|
this.outputSampleSize = 4;
|
|
if (dataType === "float") {
|
|
this.outputFormat = "f32";
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setFloat32(byteOffset, value, true);
|
|
} else {
|
|
this.outputFormat = "s32";
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setInt32(byteOffset, value, true);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 8:
|
|
{
|
|
this.outputSampleSize = 4;
|
|
this.outputFormat = "f32";
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setFloat32(byteOffset, value, true);
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
{
|
|
assertNever(sampleSize);
|
|
assert(false);
|
|
}
|
|
;
|
|
}
|
|
;
|
|
}
|
|
getDecodeQueueSize() {
|
|
return 0;
|
|
}
|
|
decode(packet) {
|
|
const inputView = toDataView(packet.data);
|
|
const numberOfFrames = packet.byteLength / this.decoderConfig.numberOfChannels / this.inputSampleSize;
|
|
const outputBufferSize = numberOfFrames * this.decoderConfig.numberOfChannels * this.outputSampleSize;
|
|
const outputBuffer = new ArrayBuffer(outputBufferSize);
|
|
const outputView = new DataView(outputBuffer);
|
|
for (let i = 0; i < numberOfFrames * this.decoderConfig.numberOfChannels; i++) {
|
|
const inputIndex = i * this.inputSampleSize;
|
|
const outputIndex = i * this.outputSampleSize;
|
|
const value = this.readInputValue(inputView, inputIndex);
|
|
this.writeOutputValue(outputView, outputIndex, value);
|
|
}
|
|
const preciseDuration = numberOfFrames / this.decoderConfig.sampleRate;
|
|
if (this.currentTimestamp === null || Math.abs(packet.timestamp - this.currentTimestamp) >= preciseDuration) {
|
|
this.currentTimestamp = packet.timestamp;
|
|
}
|
|
const preciseTimestamp = this.currentTimestamp;
|
|
this.currentTimestamp += preciseDuration;
|
|
const audioSample = new AudioSample({
|
|
format: this.outputFormat,
|
|
data: outputBuffer,
|
|
numberOfChannels: this.decoderConfig.numberOfChannels,
|
|
sampleRate: this.decoderConfig.sampleRate,
|
|
numberOfFrames,
|
|
timestamp: preciseTimestamp
|
|
});
|
|
this.onSample(audioSample);
|
|
}
|
|
async flush() {
|
|
}
|
|
close() {
|
|
}
|
|
};
|
|
var AudioSampleSink = class extends BaseMediaSampleSink {
|
|
/** Creates a new {@link AudioSampleSink} for the given {@link InputAudioTrack}. */
|
|
constructor(audioTrack) {
|
|
if (!(audioTrack instanceof InputAudioTrack)) {
|
|
throw new TypeError("audioTrack must be an InputAudioTrack.");
|
|
}
|
|
super();
|
|
this._track = audioTrack;
|
|
}
|
|
/** @internal */
|
|
async _createDecoder(onSample, onError) {
|
|
if (!await this._track.canDecode()) {
|
|
throw new Error(
|
|
"This audio track cannot be decoded by this browser. Make sure to check decodability before using a track."
|
|
);
|
|
}
|
|
const codec = this._track.codec;
|
|
const decoderConfig = await this._track.getDecoderConfig();
|
|
assert(codec && decoderConfig);
|
|
if (PCM_AUDIO_CODECS.includes(decoderConfig.codec)) {
|
|
return new PcmAudioDecoderWrapper(onSample, onError, decoderConfig);
|
|
} else {
|
|
return new AudioDecoderWrapper(onSample, onError, codec, decoderConfig);
|
|
}
|
|
}
|
|
/** @internal */
|
|
_createPacketSink() {
|
|
return new EncodedPacketSink(this._track);
|
|
}
|
|
/**
|
|
* Retrieves the audio sample corresponding to the given timestamp, in seconds. More specifically, returns
|
|
* the last audio sample (in presentation order) with a start timestamp less than or equal to the given timestamp.
|
|
* Returns null if the timestamp is before the track's first timestamp.
|
|
*
|
|
* @param timestamp - The timestamp used for retrieval, in seconds.
|
|
*/
|
|
async getSample(timestamp) {
|
|
validateTimestamp(timestamp);
|
|
for await (const sample of this.mediaSamplesAtTimestamps([timestamp])) {
|
|
return sample;
|
|
}
|
|
throw new Error("Internal error: Iterator returned nothing.");
|
|
}
|
|
/**
|
|
* Creates an async iterator that yields the audio samples of this track in presentation order. This method
|
|
* will intelligently pre-decode a few samples ahead to enable fast iteration.
|
|
*
|
|
* @param startTimestamp - The timestamp in seconds at which to start yielding samples (inclusive).
|
|
* @param endTimestamp - The timestamp in seconds at which to stop yielding samples (exclusive).
|
|
*/
|
|
samples(startTimestamp = 0, endTimestamp = Infinity) {
|
|
return this.mediaSamplesInRange(startTimestamp, endTimestamp);
|
|
}
|
|
/**
|
|
* Creates an async iterator that yields an audio sample for each timestamp in the argument. This method
|
|
* uses an optimized decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most
|
|
* once, and is therefore more efficient than manually getting the sample for every timestamp. The iterator may
|
|
* yield null if no sample is available for a given timestamp.
|
|
*
|
|
* @param timestamps - An iterable or async iterable of timestamps in seconds.
|
|
*/
|
|
samplesAtTimestamps(timestamps) {
|
|
return this.mediaSamplesAtTimestamps(timestamps);
|
|
}
|
|
};
|
|
var AudioBufferSink = class {
|
|
/** Creates a new {@link AudioBufferSink} for the given {@link InputAudioTrack}. */
|
|
constructor(audioTrack) {
|
|
if (!(audioTrack instanceof InputAudioTrack)) {
|
|
throw new TypeError("audioTrack must be an InputAudioTrack.");
|
|
}
|
|
this._audioSampleSink = new AudioSampleSink(audioTrack);
|
|
}
|
|
/** @internal */
|
|
_audioSampleToWrappedArrayBuffer(sample) {
|
|
const result = {
|
|
buffer: sample.toAudioBuffer(),
|
|
timestamp: sample.timestamp,
|
|
duration: sample.duration
|
|
};
|
|
sample.close();
|
|
return result;
|
|
}
|
|
/**
|
|
* Retrieves the audio buffer corresponding to the given timestamp, in seconds. More specifically, returns
|
|
* the last audio buffer (in presentation order) with a start timestamp less than or equal to the given timestamp.
|
|
* Returns null if the timestamp is before the track's first timestamp.
|
|
*
|
|
* @param timestamp - The timestamp used for retrieval, in seconds.
|
|
*/
|
|
async getBuffer(timestamp) {
|
|
validateTimestamp(timestamp);
|
|
const data = await this._audioSampleSink.getSample(timestamp);
|
|
return data && this._audioSampleToWrappedArrayBuffer(data);
|
|
}
|
|
/**
|
|
* Creates an async iterator that yields audio buffers of this track in presentation order. This method
|
|
* will intelligently pre-decode a few buffers ahead to enable fast iteration.
|
|
*
|
|
* @param startTimestamp - The timestamp in seconds at which to start yielding buffers (inclusive).
|
|
* @param endTimestamp - The timestamp in seconds at which to stop yielding buffers (exclusive).
|
|
*/
|
|
buffers(startTimestamp = 0, endTimestamp = Infinity) {
|
|
return mapAsyncGenerator(
|
|
this._audioSampleSink.samples(startTimestamp, endTimestamp),
|
|
(data) => this._audioSampleToWrappedArrayBuffer(data)
|
|
);
|
|
}
|
|
/**
|
|
* Creates an async iterator that yields an audio buffer for each timestamp in the argument. This method
|
|
* uses an optimized decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most
|
|
* once, and is therefore more efficient than manually getting the buffer for every timestamp. The iterator may
|
|
* yield null if no buffer is available for a given timestamp.
|
|
*
|
|
* @param timestamps - An iterable or async iterable of timestamps in seconds.
|
|
*/
|
|
buffersAtTimestamps(timestamps) {
|
|
return mapAsyncGenerator(
|
|
this._audioSampleSink.samplesAtTimestamps(timestamps),
|
|
(data) => data && this._audioSampleToWrappedArrayBuffer(data)
|
|
);
|
|
}
|
|
};
|
|
|
|
// src/input-track.ts
|
|
var InputTrack = class {
|
|
/** @internal */
|
|
constructor(input, backing) {
|
|
this.input = input;
|
|
this._backing = backing;
|
|
}
|
|
/** Returns true if and only if this track is a video track. */
|
|
isVideoTrack() {
|
|
return this instanceof InputVideoTrack;
|
|
}
|
|
/** Returns true if and only if this track is an audio track. */
|
|
isAudioTrack() {
|
|
return this instanceof InputAudioTrack;
|
|
}
|
|
/** The unique ID of this track in the input file. */
|
|
get id() {
|
|
return this._backing.getId();
|
|
}
|
|
/**
|
|
* The 1-based index of this track among all tracks of the same type in the input file. For example, the first
|
|
* video track has number 1, the second video track has number 2, and so on. The index refers to the order in
|
|
* which the tracks are returned by {@link Input.getTracks}.
|
|
*/
|
|
get number() {
|
|
return this._backing.getNumber();
|
|
}
|
|
/**
|
|
* The identifier of the codec used internally by the container. It is not homogenized by Mediabunny
|
|
* and depends entirely on the container format.
|
|
*
|
|
* This field can be used to determine the codec of a track in case Mediabunny doesn't know that codec.
|
|
*
|
|
* - For ISOBMFF files, this field returns the name of the Sample Description Box (e.g. `'avc1'`).
|
|
* - For Matroska files, this field returns the value of the `CodecID` element.
|
|
* - For WAVE files, this field returns the value of the format tag in the `'fmt '` chunk.
|
|
* - For ADTS files, this field contains the `MPEG-4 Audio Object Type`.
|
|
* - For MPEG-TS files, this field contains the `streamType` value from the Program Map Table.
|
|
* - In all other cases, this field is `null`.
|
|
*/
|
|
get internalCodecId() {
|
|
return this._backing.getInternalCodecId();
|
|
}
|
|
/**
|
|
* The ISO 639-2/T language code for this track. If the language is unknown, this field is `'und'` (undetermined).
|
|
*/
|
|
get languageCode() {
|
|
return this._backing.getLanguageCode();
|
|
}
|
|
/** A user-defined name for this track. */
|
|
get name() {
|
|
return this._backing.getName();
|
|
}
|
|
/**
|
|
* A positive number x such that all timestamps and durations of all packets of this track are
|
|
* integer multiples of 1/x.
|
|
*/
|
|
get timeResolution() {
|
|
return this._backing.getTimeResolution();
|
|
}
|
|
/** The track's disposition, i.e. information about its intended usage. */
|
|
get disposition() {
|
|
return this._backing.getDisposition();
|
|
}
|
|
/**
|
|
* Returns the start timestamp of the first packet of this track, in seconds. While often near zero, this value
|
|
* may be positive or even negative. A negative starting timestamp means the track's timing has been offset. Samples
|
|
* with a negative timestamp should not be presented.
|
|
*/
|
|
getFirstTimestamp() {
|
|
return this._backing.getFirstTimestamp();
|
|
}
|
|
/** Returns the end timestamp of the last packet of this track, in seconds. */
|
|
computeDuration() {
|
|
return this._backing.computeDuration();
|
|
}
|
|
/**
|
|
* Computes aggregate packet statistics for this track, such as average packet rate or bitrate.
|
|
*
|
|
* @param targetPacketCount - This optional parameter sets a target for how many packets this method must have
|
|
* looked at before it can return early; this means, you can use it to aggregate only a subset (prefix) of all
|
|
* packets. This is very useful for getting a great estimate of video frame rate without having to scan through the
|
|
* entire file.
|
|
*/
|
|
async computePacketStats(targetPacketCount = Infinity) {
|
|
const sink = new EncodedPacketSink(this);
|
|
let startTimestamp = Infinity;
|
|
let endTimestamp = -Infinity;
|
|
let packetCount = 0;
|
|
let totalPacketBytes = 0;
|
|
for await (const packet of sink.packets(void 0, void 0, { metadataOnly: true })) {
|
|
if (packetCount >= targetPacketCount && packet.timestamp >= endTimestamp) {
|
|
break;
|
|
}
|
|
startTimestamp = Math.min(startTimestamp, packet.timestamp);
|
|
endTimestamp = Math.max(endTimestamp, packet.timestamp + packet.duration);
|
|
packetCount++;
|
|
totalPacketBytes += packet.byteLength;
|
|
}
|
|
return {
|
|
packetCount,
|
|
averagePacketRate: packetCount ? Number((packetCount / (endTimestamp - startTimestamp)).toPrecision(16)) : 0,
|
|
averageBitrate: packetCount ? Number((8 * totalPacketBytes / (endTimestamp - startTimestamp)).toPrecision(16)) : 0
|
|
};
|
|
}
|
|
};
|
|
var InputVideoTrack = class extends InputTrack {
|
|
/** @internal */
|
|
constructor(input, backing) {
|
|
super(input, backing);
|
|
this._backing = backing;
|
|
}
|
|
get type() {
|
|
return "video";
|
|
}
|
|
get codec() {
|
|
return this._backing.getCodec();
|
|
}
|
|
/** The width in pixels of the track's coded samples, before any transformations or rotations. */
|
|
get codedWidth() {
|
|
return this._backing.getCodedWidth();
|
|
}
|
|
/** The height in pixels of the track's coded samples, before any transformations or rotations. */
|
|
get codedHeight() {
|
|
return this._backing.getCodedHeight();
|
|
}
|
|
/** The angle in degrees by which the track's frames should be rotated (clockwise). */
|
|
get rotation() {
|
|
return this._backing.getRotation();
|
|
}
|
|
/** The width in pixels of the track's frames after rotation. */
|
|
get displayWidth() {
|
|
const rotation = this._backing.getRotation();
|
|
return rotation % 180 === 0 ? this._backing.getCodedWidth() : this._backing.getCodedHeight();
|
|
}
|
|
/** The height in pixels of the track's frames after rotation. */
|
|
get displayHeight() {
|
|
const rotation = this._backing.getRotation();
|
|
return rotation % 180 === 0 ? this._backing.getCodedHeight() : this._backing.getCodedWidth();
|
|
}
|
|
/** Returns the color space of the track's samples. */
|
|
getColorSpace() {
|
|
return this._backing.getColorSpace();
|
|
}
|
|
/** If this method returns true, the track's samples use a high dynamic range (HDR). */
|
|
async hasHighDynamicRange() {
|
|
const colorSpace = await this._backing.getColorSpace();
|
|
return colorSpace.primaries === "bt2020" || colorSpace.primaries === "smpte432" || colorSpace.transfer === "pg" || colorSpace.transfer === "hlg" || colorSpace.matrix === "bt2020-ncl";
|
|
}
|
|
/** Checks if this track may contain transparent samples with alpha data. */
|
|
canBeTransparent() {
|
|
return this._backing.canBeTransparent();
|
|
}
|
|
/**
|
|
* Returns the [decoder configuration](https://www.w3.org/TR/webcodecs/#video-decoder-config) for decoding the
|
|
* track's packets using a [`VideoDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/VideoDecoder). Returns
|
|
* null if the track's codec is unknown.
|
|
*/
|
|
getDecoderConfig() {
|
|
return this._backing.getDecoderConfig();
|
|
}
|
|
async getCodecParameterString() {
|
|
const decoderConfig = await this._backing.getDecoderConfig();
|
|
return decoderConfig?.codec ?? null;
|
|
}
|
|
async canDecode() {
|
|
try {
|
|
const decoderConfig = await this._backing.getDecoderConfig();
|
|
if (!decoderConfig) {
|
|
return false;
|
|
}
|
|
const codec = this._backing.getCodec();
|
|
assert(codec !== null);
|
|
if (customVideoDecoders.some((x) => x.supports(codec, decoderConfig))) {
|
|
return true;
|
|
}
|
|
if (typeof VideoDecoder === "undefined") {
|
|
return false;
|
|
}
|
|
const support = await VideoDecoder.isConfigSupported(decoderConfig);
|
|
return support.supported === true;
|
|
} catch (error) {
|
|
console.error("Error during decodability check:", error);
|
|
return false;
|
|
}
|
|
}
|
|
async determinePacketType(packet) {
|
|
if (!(packet instanceof EncodedPacket)) {
|
|
throw new TypeError("packet must be an EncodedPacket.");
|
|
}
|
|
if (packet.isMetadataOnly) {
|
|
throw new TypeError("packet must not be metadata-only to determine its type.");
|
|
}
|
|
if (this.codec === null) {
|
|
return null;
|
|
}
|
|
const decoderConfig = await this.getDecoderConfig();
|
|
assert(decoderConfig);
|
|
return determineVideoPacketType(this.codec, decoderConfig, packet.data);
|
|
}
|
|
};
|
|
var InputAudioTrack = class extends InputTrack {
|
|
/** @internal */
|
|
constructor(input, backing) {
|
|
super(input, backing);
|
|
this._backing = backing;
|
|
}
|
|
get type() {
|
|
return "audio";
|
|
}
|
|
get codec() {
|
|
return this._backing.getCodec();
|
|
}
|
|
/** The number of audio channels in the track. */
|
|
get numberOfChannels() {
|
|
return this._backing.getNumberOfChannels();
|
|
}
|
|
/** The track's audio sample rate in hertz. */
|
|
get sampleRate() {
|
|
return this._backing.getSampleRate();
|
|
}
|
|
/**
|
|
* Returns the [decoder configuration](https://www.w3.org/TR/webcodecs/#audio-decoder-config) for decoding the
|
|
* track's packets using an [`AudioDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/AudioDecoder). Returns
|
|
* null if the track's codec is unknown.
|
|
*/
|
|
getDecoderConfig() {
|
|
return this._backing.getDecoderConfig();
|
|
}
|
|
async getCodecParameterString() {
|
|
const decoderConfig = await this._backing.getDecoderConfig();
|
|
return decoderConfig?.codec ?? null;
|
|
}
|
|
async canDecode() {
|
|
try {
|
|
const decoderConfig = await this._backing.getDecoderConfig();
|
|
if (!decoderConfig) {
|
|
return false;
|
|
}
|
|
const codec = this._backing.getCodec();
|
|
assert(codec !== null);
|
|
if (customAudioDecoders.some((x) => x.supports(codec, decoderConfig))) {
|
|
return true;
|
|
}
|
|
if (decoderConfig.codec.startsWith("pcm-")) {
|
|
return true;
|
|
} else {
|
|
if (typeof AudioDecoder === "undefined") {
|
|
return false;
|
|
}
|
|
const support = await AudioDecoder.isConfigSupported(decoderConfig);
|
|
return support.supported === true;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error during decodability check:", error);
|
|
return false;
|
|
}
|
|
}
|
|
async determinePacketType(packet) {
|
|
if (!(packet instanceof EncodedPacket)) {
|
|
throw new TypeError("packet must be an EncodedPacket.");
|
|
}
|
|
if (this.codec === null) {
|
|
return null;
|
|
}
|
|
return "key";
|
|
}
|
|
};
|
|
|
|
// src/isobmff/isobmff-misc.ts
|
|
var buildIsobmffMimeType = (info) => {
|
|
const base = info.hasVideo ? "video/" : info.hasAudio ? "audio/" : "application/";
|
|
let string = base + (info.isQuickTime ? "quicktime" : "mp4");
|
|
if (info.codecStrings.length > 0) {
|
|
const uniqueCodecMimeTypes = [...new Set(info.codecStrings)];
|
|
string += `; codecs="${uniqueCodecMimeTypes.join(", ")}"`;
|
|
}
|
|
return string;
|
|
};
|
|
|
|
// src/isobmff/isobmff-reader.ts
|
|
var MIN_BOX_HEADER_SIZE = 8;
|
|
var MAX_BOX_HEADER_SIZE = 16;
|
|
var readBoxHeader = (slice) => {
|
|
let totalSize = readU32Be(slice);
|
|
const name = readAscii(slice, 4);
|
|
let headerSize = 8;
|
|
const hasLargeSize = totalSize === 1;
|
|
if (hasLargeSize) {
|
|
totalSize = readU64Be(slice);
|
|
headerSize = 16;
|
|
}
|
|
const contentSize = totalSize - headerSize;
|
|
if (contentSize < 0) {
|
|
return null;
|
|
}
|
|
return { name, totalSize, headerSize, contentSize };
|
|
};
|
|
var readFixed_16_16 = (slice) => {
|
|
return readI32Be(slice) / 65536;
|
|
};
|
|
var readFixed_2_30 = (slice) => {
|
|
return readI32Be(slice) / 1073741824;
|
|
};
|
|
var readIsomVariableInteger = (slice) => {
|
|
let result = 0;
|
|
for (let i = 0; i < 4; i++) {
|
|
result <<= 7;
|
|
const nextByte = readU8(slice);
|
|
result |= nextByte & 127;
|
|
if ((nextByte & 128) === 0) {
|
|
break;
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
var readMetadataStringShort = (slice) => {
|
|
let stringLength = readU16Be(slice);
|
|
slice.skip(2);
|
|
stringLength = Math.min(stringLength, slice.remainingLength);
|
|
return textDecoder.decode(readBytes(slice, stringLength));
|
|
};
|
|
var readDataBox = (slice) => {
|
|
const header = readBoxHeader(slice);
|
|
if (!header || header.name !== "data") {
|
|
return null;
|
|
}
|
|
if (slice.remainingLength < 8) {
|
|
return null;
|
|
}
|
|
const typeIndicator = readU32Be(slice);
|
|
slice.skip(4);
|
|
const data = readBytes(slice, header.contentSize - 8);
|
|
switch (typeIndicator) {
|
|
case 1:
|
|
return textDecoder.decode(data);
|
|
// UTF-8
|
|
case 2:
|
|
return new TextDecoder("utf-16be").decode(data);
|
|
// UTF-16-BE
|
|
case 13:
|
|
return new RichImageData(data, "image/jpeg");
|
|
case 14:
|
|
return new RichImageData(data, "image/png");
|
|
case 27:
|
|
return new RichImageData(data, "image/bmp");
|
|
default:
|
|
return data;
|
|
}
|
|
};
|
|
|
|
// src/isobmff/isobmff-demuxer.ts
|
|
var IsobmffDemuxer = class extends Demuxer {
|
|
constructor(input) {
|
|
super(input);
|
|
this.moovSlice = null;
|
|
this.currentTrack = null;
|
|
this.tracks = [];
|
|
this.metadataPromise = null;
|
|
this.movieTimescale = -1;
|
|
this.movieDurationInTimescale = -1;
|
|
this.isQuickTime = false;
|
|
this.metadataTags = {};
|
|
this.currentMetadataKeys = null;
|
|
this.isFragmented = false;
|
|
this.fragmentTrackDefaults = [];
|
|
this.currentFragment = null;
|
|
/**
|
|
* Caches the last fragment that was read. Based on the assumption that there will be multiple reads to the
|
|
* same fragment in quick succession.
|
|
*/
|
|
this.lastReadFragment = null;
|
|
this.reader = input._reader;
|
|
}
|
|
async computeDuration() {
|
|
const tracks = await this.getTracks();
|
|
const trackDurations = await Promise.all(tracks.map((x) => x.computeDuration()));
|
|
return Math.max(0, ...trackDurations);
|
|
}
|
|
async getTracks() {
|
|
await this.readMetadata();
|
|
return this.tracks.map((track) => track.inputTrack);
|
|
}
|
|
async getMimeType() {
|
|
await this.readMetadata();
|
|
const codecStrings = await Promise.all(this.tracks.map((x) => x.inputTrack.getCodecParameterString()));
|
|
return buildIsobmffMimeType({
|
|
isQuickTime: this.isQuickTime,
|
|
hasVideo: this.tracks.some((x) => x.info?.type === "video"),
|
|
hasAudio: this.tracks.some((x) => x.info?.type === "audio"),
|
|
codecStrings: codecStrings.filter(Boolean)
|
|
});
|
|
}
|
|
async getMetadataTags() {
|
|
await this.readMetadata();
|
|
return this.metadataTags;
|
|
}
|
|
readMetadata() {
|
|
return this.metadataPromise ??= (async () => {
|
|
let currentPos = 0;
|
|
while (true) {
|
|
let slice = this.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) break;
|
|
const startPos = currentPos;
|
|
const boxInfo = readBoxHeader(slice);
|
|
if (!boxInfo) {
|
|
break;
|
|
}
|
|
if (boxInfo.name === "ftyp") {
|
|
const majorBrand = readAscii(slice, 4);
|
|
this.isQuickTime = majorBrand === "qt ";
|
|
} else if (boxInfo.name === "moov") {
|
|
let moovSlice = this.reader.requestSlice(slice.filePos, boxInfo.contentSize);
|
|
if (moovSlice instanceof Promise) moovSlice = await moovSlice;
|
|
if (!moovSlice) break;
|
|
this.moovSlice = moovSlice;
|
|
this.readContiguousBoxes(this.moovSlice);
|
|
this.tracks.sort((a, b) => Number(b.disposition.default) - Number(a.disposition.default));
|
|
for (const track of this.tracks) {
|
|
const previousSegmentDurationsInSeconds = track.editListPreviousSegmentDurations / this.movieTimescale;
|
|
track.editListOffset -= Math.round(previousSegmentDurationsInSeconds * track.timescale);
|
|
}
|
|
break;
|
|
}
|
|
currentPos = startPos + boxInfo.totalSize;
|
|
}
|
|
if (this.isFragmented && this.reader.fileSize !== null) {
|
|
let lastWordSlice = this.reader.requestSlice(this.reader.fileSize - 4, 4);
|
|
if (lastWordSlice instanceof Promise) lastWordSlice = await lastWordSlice;
|
|
assert(lastWordSlice);
|
|
const lastWord = readU32Be(lastWordSlice);
|
|
const potentialMfraPos = this.reader.fileSize - lastWord;
|
|
if (potentialMfraPos >= 0 && potentialMfraPos <= this.reader.fileSize - MAX_BOX_HEADER_SIZE) {
|
|
let mfraHeaderSlice = this.reader.requestSliceRange(
|
|
potentialMfraPos,
|
|
MIN_BOX_HEADER_SIZE,
|
|
MAX_BOX_HEADER_SIZE
|
|
);
|
|
if (mfraHeaderSlice instanceof Promise) mfraHeaderSlice = await mfraHeaderSlice;
|
|
if (mfraHeaderSlice) {
|
|
const boxInfo = readBoxHeader(mfraHeaderSlice);
|
|
if (boxInfo && boxInfo.name === "mfra") {
|
|
let mfraSlice = this.reader.requestSlice(mfraHeaderSlice.filePos, boxInfo.contentSize);
|
|
if (mfraSlice instanceof Promise) mfraSlice = await mfraSlice;
|
|
if (mfraSlice) {
|
|
this.readContiguousBoxes(mfraSlice);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
getSampleTableForTrack(internalTrack) {
|
|
if (internalTrack.sampleTable) {
|
|
return internalTrack.sampleTable;
|
|
}
|
|
const sampleTable = {
|
|
sampleTimingEntries: [],
|
|
sampleCompositionTimeOffsets: [],
|
|
sampleSizes: [],
|
|
keySampleIndices: null,
|
|
chunkOffsets: [],
|
|
sampleToChunk: [],
|
|
presentationTimestamps: null,
|
|
presentationTimestampIndexMap: null
|
|
};
|
|
internalTrack.sampleTable = sampleTable;
|
|
assert(this.moovSlice);
|
|
const stblContainerSlice = this.moovSlice.slice(internalTrack.sampleTableByteOffset);
|
|
this.currentTrack = internalTrack;
|
|
this.traverseBox(stblContainerSlice);
|
|
this.currentTrack = null;
|
|
const isPcmCodec = internalTrack.info?.type === "audio" && internalTrack.info.codec && PCM_AUDIO_CODECS.includes(internalTrack.info.codec);
|
|
if (isPcmCodec && sampleTable.sampleCompositionTimeOffsets.length === 0) {
|
|
assert(internalTrack.info?.type === "audio");
|
|
const pcmInfo = parsePcmCodec(internalTrack.info.codec);
|
|
const newSampleTimingEntries = [];
|
|
const newSampleSizes = [];
|
|
for (let i = 0; i < sampleTable.sampleToChunk.length; i++) {
|
|
const chunkEntry = sampleTable.sampleToChunk[i];
|
|
const nextEntry = sampleTable.sampleToChunk[i + 1];
|
|
const chunkCount = (nextEntry ? nextEntry.startChunkIndex : sampleTable.chunkOffsets.length) - chunkEntry.startChunkIndex;
|
|
for (let j = 0; j < chunkCount; j++) {
|
|
const startSampleIndex = chunkEntry.startSampleIndex + j * chunkEntry.samplesPerChunk;
|
|
const endSampleIndex = startSampleIndex + chunkEntry.samplesPerChunk;
|
|
const startTimingEntryIndex = binarySearchLessOrEqual(
|
|
sampleTable.sampleTimingEntries,
|
|
startSampleIndex,
|
|
(x) => x.startIndex
|
|
);
|
|
const startTimingEntry = sampleTable.sampleTimingEntries[startTimingEntryIndex];
|
|
const endTimingEntryIndex = binarySearchLessOrEqual(
|
|
sampleTable.sampleTimingEntries,
|
|
endSampleIndex,
|
|
(x) => x.startIndex
|
|
);
|
|
const endTimingEntry = sampleTable.sampleTimingEntries[endTimingEntryIndex];
|
|
const firstSampleTimestamp = startTimingEntry.startDecodeTimestamp + (startSampleIndex - startTimingEntry.startIndex) * startTimingEntry.delta;
|
|
const lastSampleTimestamp = endTimingEntry.startDecodeTimestamp + (endSampleIndex - endTimingEntry.startIndex) * endTimingEntry.delta;
|
|
const delta = lastSampleTimestamp - firstSampleTimestamp;
|
|
const lastSampleTimingEntry = last(newSampleTimingEntries);
|
|
if (lastSampleTimingEntry && lastSampleTimingEntry.delta === delta) {
|
|
lastSampleTimingEntry.count++;
|
|
} else {
|
|
newSampleTimingEntries.push({
|
|
startIndex: chunkEntry.startChunkIndex + j,
|
|
startDecodeTimestamp: firstSampleTimestamp,
|
|
count: 1,
|
|
delta
|
|
});
|
|
}
|
|
const chunkSize = chunkEntry.samplesPerChunk * pcmInfo.sampleSize * internalTrack.info.numberOfChannels;
|
|
newSampleSizes.push(chunkSize);
|
|
}
|
|
chunkEntry.startSampleIndex = chunkEntry.startChunkIndex;
|
|
chunkEntry.samplesPerChunk = 1;
|
|
}
|
|
sampleTable.sampleTimingEntries = newSampleTimingEntries;
|
|
sampleTable.sampleSizes = newSampleSizes;
|
|
}
|
|
if (sampleTable.sampleCompositionTimeOffsets.length > 0) {
|
|
sampleTable.presentationTimestamps = [];
|
|
for (const entry of sampleTable.sampleTimingEntries) {
|
|
for (let i = 0; i < entry.count; i++) {
|
|
sampleTable.presentationTimestamps.push({
|
|
presentationTimestamp: entry.startDecodeTimestamp + i * entry.delta,
|
|
sampleIndex: entry.startIndex + i
|
|
});
|
|
}
|
|
}
|
|
for (const entry of sampleTable.sampleCompositionTimeOffsets) {
|
|
for (let i = 0; i < entry.count; i++) {
|
|
const sampleIndex = entry.startIndex + i;
|
|
const sample = sampleTable.presentationTimestamps[sampleIndex];
|
|
if (!sample) {
|
|
continue;
|
|
}
|
|
sample.presentationTimestamp += entry.offset;
|
|
}
|
|
}
|
|
sampleTable.presentationTimestamps.sort((a, b) => a.presentationTimestamp - b.presentationTimestamp);
|
|
sampleTable.presentationTimestampIndexMap = Array(sampleTable.presentationTimestamps.length).fill(-1);
|
|
for (let i = 0; i < sampleTable.presentationTimestamps.length; i++) {
|
|
sampleTable.presentationTimestampIndexMap[sampleTable.presentationTimestamps[i].sampleIndex] = i;
|
|
}
|
|
} else {
|
|
}
|
|
return sampleTable;
|
|
}
|
|
async readFragment(startPos) {
|
|
if (this.lastReadFragment?.moofOffset === startPos) {
|
|
return this.lastReadFragment;
|
|
}
|
|
let headerSlice = this.reader.requestSliceRange(startPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
|
|
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
|
|
assert(headerSlice);
|
|
const moofBoxInfo = readBoxHeader(headerSlice);
|
|
assert(moofBoxInfo?.name === "moof");
|
|
let entireSlice = this.reader.requestSlice(startPos, moofBoxInfo.totalSize);
|
|
if (entireSlice instanceof Promise) entireSlice = await entireSlice;
|
|
assert(entireSlice);
|
|
this.traverseBox(entireSlice);
|
|
const fragment = this.lastReadFragment;
|
|
assert(fragment && fragment.moofOffset === startPos);
|
|
for (const [, trackData] of fragment.trackData) {
|
|
const track = trackData.track;
|
|
const { fragmentPositionCache } = track;
|
|
if (!trackData.startTimestampIsFinal) {
|
|
const lookupEntry = track.fragmentLookupTable.find((x) => x.moofOffset === fragment.moofOffset);
|
|
if (lookupEntry) {
|
|
offsetFragmentTrackDataByTimestamp(trackData, lookupEntry.timestamp);
|
|
} else {
|
|
const lastCacheIndex = binarySearchLessOrEqual(
|
|
fragmentPositionCache,
|
|
fragment.moofOffset - 1,
|
|
(x) => x.moofOffset
|
|
);
|
|
if (lastCacheIndex !== -1) {
|
|
const lastCache = fragmentPositionCache[lastCacheIndex];
|
|
offsetFragmentTrackDataByTimestamp(trackData, lastCache.endTimestamp);
|
|
} else {
|
|
}
|
|
}
|
|
trackData.startTimestampIsFinal = true;
|
|
}
|
|
const insertionIndex = binarySearchLessOrEqual(
|
|
fragmentPositionCache,
|
|
trackData.startTimestamp,
|
|
(x) => x.startTimestamp
|
|
);
|
|
if (insertionIndex === -1 || fragmentPositionCache[insertionIndex].moofOffset !== fragment.moofOffset) {
|
|
fragmentPositionCache.splice(insertionIndex + 1, 0, {
|
|
moofOffset: fragment.moofOffset,
|
|
startTimestamp: trackData.startTimestamp,
|
|
endTimestamp: trackData.endTimestamp
|
|
});
|
|
}
|
|
}
|
|
return fragment;
|
|
}
|
|
readContiguousBoxes(slice) {
|
|
const startIndex = slice.filePos;
|
|
while (slice.filePos - startIndex <= slice.length - MIN_BOX_HEADER_SIZE) {
|
|
const foundBox = this.traverseBox(slice);
|
|
if (!foundBox) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// eslint-disable-next-line @stylistic/generator-star-spacing
|
|
*iterateContiguousBoxes(slice) {
|
|
const startIndex = slice.filePos;
|
|
while (slice.filePos - startIndex <= slice.length - MIN_BOX_HEADER_SIZE) {
|
|
const startPos = slice.filePos;
|
|
const boxInfo = readBoxHeader(slice);
|
|
if (!boxInfo) {
|
|
break;
|
|
}
|
|
yield { boxInfo, slice };
|
|
slice.filePos = startPos + boxInfo.totalSize;
|
|
}
|
|
}
|
|
traverseBox(slice) {
|
|
const startPos = slice.filePos;
|
|
const boxInfo = readBoxHeader(slice);
|
|
if (!boxInfo) {
|
|
return false;
|
|
}
|
|
const contentStartPos = slice.filePos;
|
|
const boxEndPos = startPos + boxInfo.totalSize;
|
|
switch (boxInfo.name) {
|
|
case "mdia":
|
|
case "minf":
|
|
case "dinf":
|
|
case "mfra":
|
|
case "edts":
|
|
{
|
|
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
|
|
}
|
|
;
|
|
break;
|
|
case "mvhd":
|
|
{
|
|
const version = readU8(slice);
|
|
slice.skip(3);
|
|
if (version === 1) {
|
|
slice.skip(8 + 8);
|
|
this.movieTimescale = readU32Be(slice);
|
|
this.movieDurationInTimescale = readU64Be(slice);
|
|
} else {
|
|
slice.skip(4 + 4);
|
|
this.movieTimescale = readU32Be(slice);
|
|
this.movieDurationInTimescale = readU32Be(slice);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "trak":
|
|
{
|
|
const track = {
|
|
id: -1,
|
|
demuxer: this,
|
|
inputTrack: null,
|
|
disposition: {
|
|
...DEFAULT_TRACK_DISPOSITION
|
|
},
|
|
info: null,
|
|
timescale: -1,
|
|
durationInMovieTimescale: -1,
|
|
durationInMediaTimescale: -1,
|
|
rotation: 0,
|
|
internalCodecId: null,
|
|
name: null,
|
|
languageCode: UNDETERMINED_LANGUAGE,
|
|
sampleTableByteOffset: -1,
|
|
sampleTable: null,
|
|
fragmentLookupTable: [],
|
|
currentFragmentState: null,
|
|
fragmentPositionCache: [],
|
|
editListPreviousSegmentDurations: 0,
|
|
editListOffset: 0
|
|
};
|
|
this.currentTrack = track;
|
|
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
|
|
if (track.id !== -1 && track.timescale !== -1 && track.info !== null) {
|
|
if (track.info.type === "video" && track.info.width !== -1) {
|
|
const videoTrack = track;
|
|
track.inputTrack = new InputVideoTrack(this.input, new IsobmffVideoTrackBacking(videoTrack));
|
|
this.tracks.push(track);
|
|
} else if (track.info.type === "audio" && track.info.numberOfChannels !== -1) {
|
|
const audioTrack = track;
|
|
track.inputTrack = new InputAudioTrack(this.input, new IsobmffAudioTrackBacking(audioTrack));
|
|
this.tracks.push(track);
|
|
}
|
|
}
|
|
this.currentTrack = null;
|
|
}
|
|
;
|
|
break;
|
|
case "tkhd":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
const version = readU8(slice);
|
|
const flags = readU24Be(slice);
|
|
const trackEnabled = !!(flags & 1);
|
|
track.disposition.default = trackEnabled;
|
|
if (version === 0) {
|
|
slice.skip(8);
|
|
track.id = readU32Be(slice);
|
|
slice.skip(4);
|
|
track.durationInMovieTimescale = readU32Be(slice);
|
|
} else if (version === 1) {
|
|
slice.skip(16);
|
|
track.id = readU32Be(slice);
|
|
slice.skip(4);
|
|
track.durationInMovieTimescale = readU64Be(slice);
|
|
} else {
|
|
throw new Error(`Incorrect track header version ${version}.`);
|
|
}
|
|
slice.skip(2 * 4 + 2 + 2 + 2 + 2);
|
|
const matrix = [
|
|
readFixed_16_16(slice),
|
|
readFixed_16_16(slice),
|
|
readFixed_2_30(slice),
|
|
readFixed_16_16(slice),
|
|
readFixed_16_16(slice),
|
|
readFixed_2_30(slice),
|
|
readFixed_16_16(slice),
|
|
readFixed_16_16(slice),
|
|
readFixed_2_30(slice)
|
|
];
|
|
const rotation = normalizeRotation(roundToMultiple(extractRotationFromMatrix(matrix), 90));
|
|
assert(rotation === 0 || rotation === 90 || rotation === 180 || rotation === 270);
|
|
track.rotation = rotation;
|
|
}
|
|
;
|
|
break;
|
|
case "elst":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
const version = readU8(slice);
|
|
slice.skip(3);
|
|
let relevantEntryFound = false;
|
|
let previousSegmentDurations = 0;
|
|
const entryCount = readU32Be(slice);
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const segmentDuration = version === 1 ? readU64Be(slice) : readU32Be(slice);
|
|
const mediaTime = version === 1 ? readI64Be(slice) : readI32Be(slice);
|
|
const mediaRate = readFixed_16_16(slice);
|
|
if (segmentDuration === 0) {
|
|
continue;
|
|
}
|
|
if (relevantEntryFound) {
|
|
console.warn(
|
|
"Unsupported edit list: multiple edits are not currently supported. Only using first edit."
|
|
);
|
|
break;
|
|
}
|
|
if (mediaTime === -1) {
|
|
previousSegmentDurations += segmentDuration;
|
|
continue;
|
|
}
|
|
if (mediaRate !== 1) {
|
|
console.warn("Unsupported edit list entry: media rate must be 1.");
|
|
break;
|
|
}
|
|
track.editListPreviousSegmentDurations = previousSegmentDurations;
|
|
track.editListOffset = mediaTime;
|
|
relevantEntryFound = true;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "mdhd":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
const version = readU8(slice);
|
|
slice.skip(3);
|
|
if (version === 0) {
|
|
slice.skip(8);
|
|
track.timescale = readU32Be(slice);
|
|
track.durationInMediaTimescale = readU32Be(slice);
|
|
} else if (version === 1) {
|
|
slice.skip(16);
|
|
track.timescale = readU32Be(slice);
|
|
track.durationInMediaTimescale = readU64Be(slice);
|
|
}
|
|
let language = readU16Be(slice);
|
|
if (language > 0) {
|
|
track.languageCode = "";
|
|
for (let i = 0; i < 3; i++) {
|
|
track.languageCode = String.fromCharCode(96 + (language & 31)) + track.languageCode;
|
|
language >>= 5;
|
|
}
|
|
if (!isIso639Dash2LanguageCode(track.languageCode)) {
|
|
track.languageCode = UNDETERMINED_LANGUAGE;
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "hdlr":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
slice.skip(8);
|
|
const handlerType = readAscii(slice, 4);
|
|
if (handlerType === "vide") {
|
|
track.info = {
|
|
type: "video",
|
|
width: -1,
|
|
height: -1,
|
|
codec: null,
|
|
codecDescription: null,
|
|
colorSpace: null,
|
|
avcType: null,
|
|
avcCodecInfo: null,
|
|
hevcCodecInfo: null,
|
|
vp9CodecInfo: null,
|
|
av1CodecInfo: null
|
|
};
|
|
} else if (handlerType === "soun") {
|
|
track.info = {
|
|
type: "audio",
|
|
numberOfChannels: -1,
|
|
sampleRate: -1,
|
|
codec: null,
|
|
codecDescription: null,
|
|
aacCodecInfo: null
|
|
};
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "stbl":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
track.sampleTableByteOffset = startPos;
|
|
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
|
|
}
|
|
;
|
|
break;
|
|
case "stsd":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
if (track.info === null || track.sampleTable) {
|
|
break;
|
|
}
|
|
const stsdVersion = readU8(slice);
|
|
slice.skip(3);
|
|
const entries = readU32Be(slice);
|
|
for (let i = 0; i < entries; i++) {
|
|
const sampleBoxStartPos = slice.filePos;
|
|
const sampleBoxInfo = readBoxHeader(slice);
|
|
if (!sampleBoxInfo) {
|
|
break;
|
|
}
|
|
track.internalCodecId = sampleBoxInfo.name;
|
|
const lowercaseBoxName = sampleBoxInfo.name.toLowerCase();
|
|
if (track.info.type === "video") {
|
|
if (lowercaseBoxName === "avc1" || lowercaseBoxName === "avc3") {
|
|
track.info.codec = "avc";
|
|
track.info.avcType = lowercaseBoxName === "avc1" ? 1 : 3;
|
|
} else if (lowercaseBoxName === "hvc1" || lowercaseBoxName === "hev1") {
|
|
track.info.codec = "hevc";
|
|
} else if (lowercaseBoxName === "vp08") {
|
|
track.info.codec = "vp8";
|
|
} else if (lowercaseBoxName === "vp09") {
|
|
track.info.codec = "vp9";
|
|
} else if (lowercaseBoxName === "av01") {
|
|
track.info.codec = "av1";
|
|
} else {
|
|
console.warn(`Unsupported video codec (sample entry type '${sampleBoxInfo.name}').`);
|
|
}
|
|
slice.skip(6 * 1 + 2 + 2 + 2 + 3 * 4);
|
|
track.info.width = readU16Be(slice);
|
|
track.info.height = readU16Be(slice);
|
|
slice.skip(4 + 4 + 4 + 2 + 32 + 2 + 2);
|
|
this.readContiguousBoxes(
|
|
slice.slice(
|
|
slice.filePos,
|
|
sampleBoxStartPos + sampleBoxInfo.totalSize - slice.filePos
|
|
)
|
|
);
|
|
} else {
|
|
if (lowercaseBoxName === "mp4a") {
|
|
} else if (lowercaseBoxName === "opus") {
|
|
track.info.codec = "opus";
|
|
} else if (lowercaseBoxName === "flac") {
|
|
track.info.codec = "flac";
|
|
} else if (lowercaseBoxName === "twos" || lowercaseBoxName === "sowt" || lowercaseBoxName === "raw " || lowercaseBoxName === "in24" || lowercaseBoxName === "in32" || lowercaseBoxName === "fl32" || lowercaseBoxName === "fl64" || lowercaseBoxName === "lpcm" || lowercaseBoxName === "ipcm" || lowercaseBoxName === "fpcm") {
|
|
} else if (lowercaseBoxName === "ulaw") {
|
|
track.info.codec = "ulaw";
|
|
} else if (lowercaseBoxName === "alaw") {
|
|
track.info.codec = "alaw";
|
|
} else if (lowercaseBoxName === "ac-3") {
|
|
track.info.codec = "ac3";
|
|
} else if (lowercaseBoxName === "ec-3") {
|
|
track.info.codec = "eac3";
|
|
} else {
|
|
console.warn(`Unsupported audio codec (sample entry type '${sampleBoxInfo.name}').`);
|
|
}
|
|
slice.skip(6 * 1 + 2);
|
|
const version = readU16Be(slice);
|
|
slice.skip(3 * 2);
|
|
let channelCount = readU16Be(slice);
|
|
let sampleSize = readU16Be(slice);
|
|
slice.skip(2 * 2);
|
|
let sampleRate = readU32Be(slice) / 65536;
|
|
if (stsdVersion === 0 && version > 0) {
|
|
if (version === 1) {
|
|
slice.skip(4);
|
|
sampleSize = 8 * readU32Be(slice);
|
|
slice.skip(2 * 4);
|
|
} else if (version === 2) {
|
|
slice.skip(4);
|
|
sampleRate = readF64Be(slice);
|
|
channelCount = readU32Be(slice);
|
|
slice.skip(4);
|
|
sampleSize = readU32Be(slice);
|
|
const flags = readU32Be(slice);
|
|
slice.skip(2 * 4);
|
|
if (lowercaseBoxName === "lpcm") {
|
|
const bytesPerSample = sampleSize + 7 >> 3;
|
|
const isFloat = Boolean(flags & 1);
|
|
const isBigEndian = Boolean(flags & 2);
|
|
const sFlags = flags & 4 ? -1 : 0;
|
|
if (sampleSize > 0 && sampleSize <= 64) {
|
|
if (isFloat) {
|
|
if (sampleSize === 32) {
|
|
track.info.codec = isBigEndian ? "pcm-f32be" : "pcm-f32";
|
|
}
|
|
} else {
|
|
if (sFlags & 1 << bytesPerSample - 1) {
|
|
if (bytesPerSample === 1) {
|
|
track.info.codec = "pcm-s8";
|
|
} else if (bytesPerSample === 2) {
|
|
track.info.codec = isBigEndian ? "pcm-s16be" : "pcm-s16";
|
|
} else if (bytesPerSample === 3) {
|
|
track.info.codec = isBigEndian ? "pcm-s24be" : "pcm-s24";
|
|
} else if (bytesPerSample === 4) {
|
|
track.info.codec = isBigEndian ? "pcm-s32be" : "pcm-s32";
|
|
}
|
|
} else {
|
|
if (bytesPerSample === 1) {
|
|
track.info.codec = "pcm-u8";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (track.info.codec === null) {
|
|
console.warn("Unsupported PCM format.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (track.info.codec === "opus") {
|
|
sampleRate = OPUS_SAMPLE_RATE;
|
|
}
|
|
track.info.numberOfChannels = channelCount;
|
|
track.info.sampleRate = sampleRate;
|
|
if (lowercaseBoxName === "twos") {
|
|
if (sampleSize === 8) {
|
|
track.info.codec = "pcm-s8";
|
|
} else if (sampleSize === 16) {
|
|
track.info.codec = "pcm-s16be";
|
|
} else {
|
|
console.warn(`Unsupported sample size ${sampleSize} for codec 'twos'.`);
|
|
track.info.codec = null;
|
|
}
|
|
} else if (lowercaseBoxName === "sowt") {
|
|
if (sampleSize === 8) {
|
|
track.info.codec = "pcm-s8";
|
|
} else if (sampleSize === 16) {
|
|
track.info.codec = "pcm-s16";
|
|
} else {
|
|
console.warn(`Unsupported sample size ${sampleSize} for codec 'sowt'.`);
|
|
track.info.codec = null;
|
|
}
|
|
} else if (lowercaseBoxName === "raw ") {
|
|
track.info.codec = "pcm-u8";
|
|
} else if (lowercaseBoxName === "in24") {
|
|
track.info.codec = "pcm-s24be";
|
|
} else if (lowercaseBoxName === "in32") {
|
|
track.info.codec = "pcm-s32be";
|
|
} else if (lowercaseBoxName === "fl32") {
|
|
track.info.codec = "pcm-f32be";
|
|
} else if (lowercaseBoxName === "fl64") {
|
|
track.info.codec = "pcm-f64be";
|
|
} else if (lowercaseBoxName === "ipcm") {
|
|
track.info.codec = "pcm-s16be";
|
|
} else if (lowercaseBoxName === "fpcm") {
|
|
track.info.codec = "pcm-f32be";
|
|
}
|
|
this.readContiguousBoxes(
|
|
slice.slice(
|
|
slice.filePos,
|
|
sampleBoxStartPos + sampleBoxInfo.totalSize - slice.filePos
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "avcC":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.info);
|
|
track.info.codecDescription = readBytes(slice, boxInfo.contentSize);
|
|
}
|
|
;
|
|
break;
|
|
case "hvcC":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.info);
|
|
track.info.codecDescription = readBytes(slice, boxInfo.contentSize);
|
|
}
|
|
;
|
|
break;
|
|
case "vpcC":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.info?.type === "video");
|
|
slice.skip(4);
|
|
const profile = readU8(slice);
|
|
const level = readU8(slice);
|
|
const thirdByte = readU8(slice);
|
|
const bitDepth = thirdByte >> 4;
|
|
const chromaSubsampling = thirdByte >> 1 & 7;
|
|
const videoFullRangeFlag = thirdByte & 1;
|
|
const colourPrimaries = readU8(slice);
|
|
const transferCharacteristics = readU8(slice);
|
|
const matrixCoefficients = readU8(slice);
|
|
track.info.vp9CodecInfo = {
|
|
profile,
|
|
level,
|
|
bitDepth,
|
|
chromaSubsampling,
|
|
videoFullRangeFlag,
|
|
colourPrimaries,
|
|
transferCharacteristics,
|
|
matrixCoefficients
|
|
};
|
|
}
|
|
;
|
|
break;
|
|
case "av1C":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.info?.type === "video");
|
|
slice.skip(1);
|
|
const secondByte = readU8(slice);
|
|
const profile = secondByte >> 5;
|
|
const level = secondByte & 31;
|
|
const thirdByte = readU8(slice);
|
|
const tier = thirdByte >> 7;
|
|
const highBitDepth = thirdByte >> 6 & 1;
|
|
const twelveBit = thirdByte >> 5 & 1;
|
|
const monochrome = thirdByte >> 4 & 1;
|
|
const chromaSubsamplingX = thirdByte >> 3 & 1;
|
|
const chromaSubsamplingY = thirdByte >> 2 & 1;
|
|
const chromaSamplePosition = thirdByte & 3;
|
|
const bitDepth = profile === 2 && highBitDepth ? twelveBit ? 12 : 10 : highBitDepth ? 10 : 8;
|
|
track.info.av1CodecInfo = {
|
|
profile,
|
|
level,
|
|
tier,
|
|
bitDepth,
|
|
monochrome,
|
|
chromaSubsamplingX,
|
|
chromaSubsamplingY,
|
|
chromaSamplePosition
|
|
};
|
|
}
|
|
;
|
|
break;
|
|
case "colr":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.info?.type === "video");
|
|
const colourType = readAscii(slice, 4);
|
|
if (colourType !== "nclx") {
|
|
break;
|
|
}
|
|
const colourPrimaries = readU16Be(slice);
|
|
const transferCharacteristics = readU16Be(slice);
|
|
const matrixCoefficients = readU16Be(slice);
|
|
const fullRangeFlag = Boolean(readU8(slice) & 128);
|
|
track.info.colorSpace = {
|
|
primaries: COLOR_PRIMARIES_MAP_INVERSE[colourPrimaries],
|
|
transfer: TRANSFER_CHARACTERISTICS_MAP_INVERSE[transferCharacteristics],
|
|
matrix: MATRIX_COEFFICIENTS_MAP_INVERSE[matrixCoefficients],
|
|
fullRange: fullRangeFlag
|
|
};
|
|
}
|
|
;
|
|
break;
|
|
case "wave":
|
|
{
|
|
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
|
|
}
|
|
;
|
|
break;
|
|
case "esds":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.info?.type === "audio");
|
|
slice.skip(4);
|
|
const tag = readU8(slice);
|
|
assert(tag === 3);
|
|
readIsomVariableInteger(slice);
|
|
slice.skip(2);
|
|
const mixed = readU8(slice);
|
|
const streamDependenceFlag = (mixed & 128) !== 0;
|
|
const urlFlag = (mixed & 64) !== 0;
|
|
const ocrStreamFlag = (mixed & 32) !== 0;
|
|
if (streamDependenceFlag) {
|
|
slice.skip(2);
|
|
}
|
|
if (urlFlag) {
|
|
const urlLength = readU8(slice);
|
|
slice.skip(urlLength);
|
|
}
|
|
if (ocrStreamFlag) {
|
|
slice.skip(2);
|
|
}
|
|
const decoderConfigTag = readU8(slice);
|
|
assert(decoderConfigTag === 4);
|
|
const decoderConfigDescriptorLength = readIsomVariableInteger(slice);
|
|
const payloadStart = slice.filePos;
|
|
const objectTypeIndication = readU8(slice);
|
|
if (objectTypeIndication === 64 || objectTypeIndication === 103) {
|
|
track.info.codec = "aac";
|
|
track.info.aacCodecInfo = {
|
|
isMpeg2: objectTypeIndication === 103,
|
|
objectType: null
|
|
};
|
|
} else if (objectTypeIndication === 105 || objectTypeIndication === 107) {
|
|
track.info.codec = "mp3";
|
|
} else if (objectTypeIndication === 221) {
|
|
track.info.codec = "vorbis";
|
|
} else {
|
|
console.warn(
|
|
`Unsupported audio codec (objectTypeIndication ${objectTypeIndication}) - discarding track.`
|
|
);
|
|
}
|
|
slice.skip(1 + 3 + 4 + 4);
|
|
if (decoderConfigDescriptorLength > slice.filePos - payloadStart) {
|
|
const decoderSpecificInfoTag = readU8(slice);
|
|
assert(decoderSpecificInfoTag === 5);
|
|
const decoderSpecificInfoLength = readIsomVariableInteger(slice);
|
|
track.info.codecDescription = readBytes(slice, decoderSpecificInfoLength);
|
|
if (track.info.codec === "aac") {
|
|
const audioSpecificConfig = parseAacAudioSpecificConfig(track.info.codecDescription);
|
|
if (audioSpecificConfig.numberOfChannels !== null) {
|
|
track.info.numberOfChannels = audioSpecificConfig.numberOfChannels;
|
|
}
|
|
if (audioSpecificConfig.sampleRate !== null) {
|
|
track.info.sampleRate = audioSpecificConfig.sampleRate;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "enda":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.info?.type === "audio");
|
|
const littleEndian = readU16Be(slice) & 255;
|
|
if (littleEndian) {
|
|
if (track.info.codec === "pcm-s16be") {
|
|
track.info.codec = "pcm-s16";
|
|
} else if (track.info.codec === "pcm-s24be") {
|
|
track.info.codec = "pcm-s24";
|
|
} else if (track.info.codec === "pcm-s32be") {
|
|
track.info.codec = "pcm-s32";
|
|
} else if (track.info.codec === "pcm-f32be") {
|
|
track.info.codec = "pcm-f32";
|
|
} else if (track.info.codec === "pcm-f64be") {
|
|
track.info.codec = "pcm-f64";
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "pcmC":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.info?.type === "audio");
|
|
slice.skip(1 + 3);
|
|
const formatFlags = readU8(slice);
|
|
const isLittleEndian = Boolean(formatFlags & 1);
|
|
const pcmSampleSize = readU8(slice);
|
|
if (track.info.codec === "pcm-s16be") {
|
|
if (isLittleEndian) {
|
|
if (pcmSampleSize === 16) {
|
|
track.info.codec = "pcm-s16";
|
|
} else if (pcmSampleSize === 24) {
|
|
track.info.codec = "pcm-s24";
|
|
} else if (pcmSampleSize === 32) {
|
|
track.info.codec = "pcm-s32";
|
|
} else {
|
|
console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`);
|
|
track.info.codec = null;
|
|
}
|
|
} else {
|
|
if (pcmSampleSize === 16) {
|
|
track.info.codec = "pcm-s16be";
|
|
} else if (pcmSampleSize === 24) {
|
|
track.info.codec = "pcm-s24be";
|
|
} else if (pcmSampleSize === 32) {
|
|
track.info.codec = "pcm-s32be";
|
|
} else {
|
|
console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`);
|
|
track.info.codec = null;
|
|
}
|
|
}
|
|
} else if (track.info.codec === "pcm-f32be") {
|
|
if (isLittleEndian) {
|
|
if (pcmSampleSize === 32) {
|
|
track.info.codec = "pcm-f32";
|
|
} else if (pcmSampleSize === 64) {
|
|
track.info.codec = "pcm-f64";
|
|
} else {
|
|
console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`);
|
|
track.info.codec = null;
|
|
}
|
|
} else {
|
|
if (pcmSampleSize === 32) {
|
|
track.info.codec = "pcm-f32be";
|
|
} else if (pcmSampleSize === 64) {
|
|
track.info.codec = "pcm-f64be";
|
|
} else {
|
|
console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`);
|
|
track.info.codec = null;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
;
|
|
case "dOps":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.info?.type === "audio");
|
|
slice.skip(1);
|
|
const outputChannelCount = readU8(slice);
|
|
const preSkip = readU16Be(slice);
|
|
const inputSampleRate = readU32Be(slice);
|
|
const outputGain = readI16Be(slice);
|
|
const channelMappingFamily = readU8(slice);
|
|
let channelMappingTable;
|
|
if (channelMappingFamily !== 0) {
|
|
channelMappingTable = readBytes(slice, 2 + outputChannelCount);
|
|
} else {
|
|
channelMappingTable = new Uint8Array(0);
|
|
}
|
|
const description = new Uint8Array(8 + 1 + 1 + 2 + 4 + 2 + 1 + channelMappingTable.byteLength);
|
|
const view2 = new DataView(description.buffer);
|
|
view2.setUint32(0, 1332770163, false);
|
|
view2.setUint32(4, 1214603620, false);
|
|
view2.setUint8(8, 1);
|
|
view2.setUint8(9, outputChannelCount);
|
|
view2.setUint16(10, preSkip, true);
|
|
view2.setUint32(12, inputSampleRate, true);
|
|
view2.setInt16(16, outputGain, true);
|
|
view2.setUint8(18, channelMappingFamily);
|
|
description.set(channelMappingTable, 19);
|
|
track.info.codecDescription = description;
|
|
track.info.numberOfChannels = outputChannelCount;
|
|
}
|
|
;
|
|
break;
|
|
case "dfLa":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.info?.type === "audio");
|
|
slice.skip(4);
|
|
const BLOCK_TYPE_MASK = 127;
|
|
const LAST_METADATA_BLOCK_FLAG_MASK = 128;
|
|
const startPos2 = slice.filePos;
|
|
while (slice.filePos < boxEndPos) {
|
|
const flagAndType = readU8(slice);
|
|
const metadataBlockLength = readU24Be(slice);
|
|
const type = flagAndType & BLOCK_TYPE_MASK;
|
|
if (type === 0 /* STREAMINFO */) {
|
|
slice.skip(10);
|
|
const word = readU32Be(slice);
|
|
const sampleRate = word >>> 12;
|
|
const numberOfChannels = (word >> 9 & 7) + 1;
|
|
track.info.sampleRate = sampleRate;
|
|
track.info.numberOfChannels = numberOfChannels;
|
|
slice.skip(20);
|
|
} else {
|
|
slice.skip(metadataBlockLength);
|
|
}
|
|
if (flagAndType & LAST_METADATA_BLOCK_FLAG_MASK) {
|
|
break;
|
|
}
|
|
}
|
|
const endPos = slice.filePos;
|
|
slice.filePos = startPos2;
|
|
const bytes2 = readBytes(slice, endPos - startPos2);
|
|
const description = new Uint8Array(4 + bytes2.byteLength);
|
|
const view2 = new DataView(description.buffer);
|
|
view2.setUint32(0, 1716281667, false);
|
|
description.set(bytes2, 4);
|
|
track.info.codecDescription = description;
|
|
}
|
|
;
|
|
break;
|
|
case "dac3":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.info?.type === "audio");
|
|
const bytes2 = readBytes(slice, 3);
|
|
const bitstream = new Bitstream(bytes2);
|
|
const fscod = bitstream.readBits(2);
|
|
bitstream.skipBits(5 + 3);
|
|
const acmod = bitstream.readBits(3);
|
|
const lfeon = bitstream.readBits(1);
|
|
if (fscod < 3) {
|
|
track.info.sampleRate = AC3_SAMPLE_RATES[fscod];
|
|
}
|
|
track.info.numberOfChannels = AC3_ACMOD_CHANNEL_COUNTS[acmod] + lfeon;
|
|
}
|
|
;
|
|
break;
|
|
case "dec3":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.info?.type === "audio");
|
|
const bytes2 = readBytes(slice, boxInfo.contentSize);
|
|
const config = parseEac3Config(bytes2);
|
|
if (!config) {
|
|
console.warn("Invalid dec3 box contents, ignoring.");
|
|
break;
|
|
}
|
|
const sampleRate = getEac3SampleRate(config);
|
|
if (sampleRate !== null) {
|
|
track.info.sampleRate = sampleRate;
|
|
}
|
|
track.info.numberOfChannels = getEac3ChannelCount(config);
|
|
}
|
|
;
|
|
break;
|
|
case "stts":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
slice.skip(4);
|
|
const entryCount = readU32Be(slice);
|
|
let currentIndex = 0;
|
|
let currentTimestamp = 0;
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const sampleCount = readU32Be(slice);
|
|
const sampleDelta = readU32Be(slice);
|
|
track.sampleTable.sampleTimingEntries.push({
|
|
startIndex: currentIndex,
|
|
startDecodeTimestamp: currentTimestamp,
|
|
count: sampleCount,
|
|
delta: sampleDelta
|
|
});
|
|
currentIndex += sampleCount;
|
|
currentTimestamp += sampleCount * sampleDelta;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "ctts":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
slice.skip(1 + 3);
|
|
const entryCount = readU32Be(slice);
|
|
let sampleIndex = 0;
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const sampleCount = readU32Be(slice);
|
|
const sampleOffset = readI32Be(slice);
|
|
track.sampleTable.sampleCompositionTimeOffsets.push({
|
|
startIndex: sampleIndex,
|
|
count: sampleCount,
|
|
offset: sampleOffset
|
|
});
|
|
sampleIndex += sampleCount;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "stsz":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
slice.skip(4);
|
|
const sampleSize = readU32Be(slice);
|
|
const sampleCount = readU32Be(slice);
|
|
if (sampleSize === 0) {
|
|
for (let i = 0; i < sampleCount; i++) {
|
|
const sampleSize2 = readU32Be(slice);
|
|
track.sampleTable.sampleSizes.push(sampleSize2);
|
|
}
|
|
} else {
|
|
track.sampleTable.sampleSizes.push(sampleSize);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "stz2":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
slice.skip(4);
|
|
slice.skip(3);
|
|
const fieldSize = readU8(slice);
|
|
const sampleCount = readU32Be(slice);
|
|
const bytes2 = readBytes(slice, Math.ceil(sampleCount * fieldSize / 8));
|
|
const bitstream = new Bitstream(bytes2);
|
|
for (let i = 0; i < sampleCount; i++) {
|
|
const sampleSize = bitstream.readBits(fieldSize);
|
|
track.sampleTable.sampleSizes.push(sampleSize);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "stss":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
slice.skip(4);
|
|
track.sampleTable.keySampleIndices = [];
|
|
const entryCount = readU32Be(slice);
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const sampleIndex = readU32Be(slice) - 1;
|
|
track.sampleTable.keySampleIndices.push(sampleIndex);
|
|
}
|
|
if (track.sampleTable.keySampleIndices[0] !== 0) {
|
|
track.sampleTable.keySampleIndices.unshift(0);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "stsc":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
slice.skip(4);
|
|
const entryCount = readU32Be(slice);
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const startChunkIndex = readU32Be(slice) - 1;
|
|
const samplesPerChunk = readU32Be(slice);
|
|
const sampleDescriptionIndex = readU32Be(slice);
|
|
track.sampleTable.sampleToChunk.push({
|
|
startSampleIndex: -1,
|
|
startChunkIndex,
|
|
samplesPerChunk,
|
|
sampleDescriptionIndex
|
|
});
|
|
}
|
|
let startSampleIndex = 0;
|
|
for (let i = 0; i < track.sampleTable.sampleToChunk.length; i++) {
|
|
track.sampleTable.sampleToChunk[i].startSampleIndex = startSampleIndex;
|
|
if (i < track.sampleTable.sampleToChunk.length - 1) {
|
|
const nextChunk = track.sampleTable.sampleToChunk[i + 1];
|
|
const chunkCount = nextChunk.startChunkIndex - track.sampleTable.sampleToChunk[i].startChunkIndex;
|
|
startSampleIndex += chunkCount * track.sampleTable.sampleToChunk[i].samplesPerChunk;
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "stco":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
slice.skip(4);
|
|
const entryCount = readU32Be(slice);
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const chunkOffset = readU32Be(slice);
|
|
track.sampleTable.chunkOffsets.push(chunkOffset);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "co64":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
slice.skip(4);
|
|
const entryCount = readU32Be(slice);
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const chunkOffset = readU64Be(slice);
|
|
track.sampleTable.chunkOffsets.push(chunkOffset);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "mvex":
|
|
{
|
|
this.isFragmented = true;
|
|
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
|
|
}
|
|
;
|
|
break;
|
|
case "mehd":
|
|
{
|
|
const version = readU8(slice);
|
|
slice.skip(3);
|
|
const fragmentDuration = version === 1 ? readU64Be(slice) : readU32Be(slice);
|
|
this.movieDurationInTimescale = fragmentDuration;
|
|
}
|
|
;
|
|
break;
|
|
case "trex":
|
|
{
|
|
slice.skip(4);
|
|
const trackId = readU32Be(slice);
|
|
const defaultSampleDescriptionIndex = readU32Be(slice);
|
|
const defaultSampleDuration = readU32Be(slice);
|
|
const defaultSampleSize = readU32Be(slice);
|
|
const defaultSampleFlags = readU32Be(slice);
|
|
this.fragmentTrackDefaults.push({
|
|
trackId,
|
|
defaultSampleDescriptionIndex,
|
|
defaultSampleDuration,
|
|
defaultSampleSize,
|
|
defaultSampleFlags
|
|
});
|
|
}
|
|
;
|
|
break;
|
|
case "tfra":
|
|
{
|
|
const version = readU8(slice);
|
|
slice.skip(3);
|
|
const trackId = readU32Be(slice);
|
|
const track = this.tracks.find((x) => x.id === trackId);
|
|
if (!track) {
|
|
break;
|
|
}
|
|
const word = readU32Be(slice);
|
|
const lengthSizeOfTrafNum = (word & 48) >> 4;
|
|
const lengthSizeOfTrunNum = (word & 12) >> 2;
|
|
const lengthSizeOfSampleNum = word & 3;
|
|
const functions = [readU8, readU16Be, readU24Be, readU32Be];
|
|
const readTrafNum = functions[lengthSizeOfTrafNum];
|
|
const readTrunNum = functions[lengthSizeOfTrunNum];
|
|
const readSampleNum = functions[lengthSizeOfSampleNum];
|
|
const numberOfEntries = readU32Be(slice);
|
|
for (let i = 0; i < numberOfEntries; i++) {
|
|
const time = version === 1 ? readU64Be(slice) : readU32Be(slice);
|
|
const moofOffset = version === 1 ? readU64Be(slice) : readU32Be(slice);
|
|
readTrafNum(slice);
|
|
readTrunNum(slice);
|
|
readSampleNum(slice);
|
|
track.fragmentLookupTable.push({
|
|
timestamp: time,
|
|
moofOffset
|
|
});
|
|
}
|
|
track.fragmentLookupTable.sort((a, b) => a.timestamp - b.timestamp);
|
|
for (let i = 0; i < track.fragmentLookupTable.length - 1; i++) {
|
|
const entry1 = track.fragmentLookupTable[i];
|
|
const entry2 = track.fragmentLookupTable[i + 1];
|
|
if (entry1.timestamp === entry2.timestamp) {
|
|
track.fragmentLookupTable.splice(i + 1, 1);
|
|
i--;
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "moof":
|
|
{
|
|
this.currentFragment = {
|
|
moofOffset: startPos,
|
|
moofSize: boxInfo.totalSize,
|
|
implicitBaseDataOffset: startPos,
|
|
trackData: /* @__PURE__ */ new Map()
|
|
};
|
|
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
|
|
this.lastReadFragment = this.currentFragment;
|
|
this.currentFragment = null;
|
|
}
|
|
;
|
|
break;
|
|
case "traf":
|
|
{
|
|
assert(this.currentFragment);
|
|
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
|
|
if (this.currentTrack) {
|
|
const trackData = this.currentFragment.trackData.get(this.currentTrack.id);
|
|
if (trackData) {
|
|
const { currentFragmentState } = this.currentTrack;
|
|
assert(currentFragmentState);
|
|
if (currentFragmentState.startTimestamp !== null) {
|
|
offsetFragmentTrackDataByTimestamp(trackData, currentFragmentState.startTimestamp);
|
|
trackData.startTimestampIsFinal = true;
|
|
}
|
|
}
|
|
this.currentTrack.currentFragmentState = null;
|
|
this.currentTrack = null;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "tfhd":
|
|
{
|
|
assert(this.currentFragment);
|
|
slice.skip(1);
|
|
const flags = readU24Be(slice);
|
|
const baseDataOffsetPresent = Boolean(flags & 1);
|
|
const sampleDescriptionIndexPresent = Boolean(flags & 2);
|
|
const defaultSampleDurationPresent = Boolean(flags & 8);
|
|
const defaultSampleSizePresent = Boolean(flags & 16);
|
|
const defaultSampleFlagsPresent = Boolean(flags & 32);
|
|
const durationIsEmpty = Boolean(flags & 65536);
|
|
const defaultBaseIsMoof = Boolean(flags & 131072);
|
|
const trackId = readU32Be(slice);
|
|
const track = this.tracks.find((x) => x.id === trackId);
|
|
if (!track) {
|
|
break;
|
|
}
|
|
const defaults = this.fragmentTrackDefaults.find((x) => x.trackId === trackId);
|
|
this.currentTrack = track;
|
|
track.currentFragmentState = {
|
|
baseDataOffset: this.currentFragment.implicitBaseDataOffset,
|
|
sampleDescriptionIndex: defaults?.defaultSampleDescriptionIndex ?? null,
|
|
defaultSampleDuration: defaults?.defaultSampleDuration ?? null,
|
|
defaultSampleSize: defaults?.defaultSampleSize ?? null,
|
|
defaultSampleFlags: defaults?.defaultSampleFlags ?? null,
|
|
startTimestamp: null
|
|
};
|
|
if (baseDataOffsetPresent) {
|
|
track.currentFragmentState.baseDataOffset = readU64Be(slice);
|
|
} else if (defaultBaseIsMoof) {
|
|
track.currentFragmentState.baseDataOffset = this.currentFragment.moofOffset;
|
|
}
|
|
if (sampleDescriptionIndexPresent) {
|
|
track.currentFragmentState.sampleDescriptionIndex = readU32Be(slice);
|
|
}
|
|
if (defaultSampleDurationPresent) {
|
|
track.currentFragmentState.defaultSampleDuration = readU32Be(slice);
|
|
}
|
|
if (defaultSampleSizePresent) {
|
|
track.currentFragmentState.defaultSampleSize = readU32Be(slice);
|
|
}
|
|
if (defaultSampleFlagsPresent) {
|
|
track.currentFragmentState.defaultSampleFlags = readU32Be(slice);
|
|
}
|
|
if (durationIsEmpty) {
|
|
track.currentFragmentState.defaultSampleDuration = 0;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "tfdt":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.currentFragmentState);
|
|
const version = readU8(slice);
|
|
slice.skip(3);
|
|
const baseMediaDecodeTime = version === 0 ? readU32Be(slice) : readU64Be(slice);
|
|
track.currentFragmentState.startTimestamp = baseMediaDecodeTime;
|
|
}
|
|
;
|
|
break;
|
|
case "trun":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(this.currentFragment);
|
|
assert(track.currentFragmentState);
|
|
if (this.currentFragment.trackData.has(track.id)) {
|
|
console.warn("Can't have two trun boxes for the same track in one fragment. Ignoring...");
|
|
break;
|
|
}
|
|
const version = readU8(slice);
|
|
const flags = readU24Be(slice);
|
|
const dataOffsetPresent = Boolean(flags & 1);
|
|
const firstSampleFlagsPresent = Boolean(flags & 4);
|
|
const sampleDurationPresent = Boolean(flags & 256);
|
|
const sampleSizePresent = Boolean(flags & 512);
|
|
const sampleFlagsPresent = Boolean(flags & 1024);
|
|
const sampleCompositionTimeOffsetsPresent = Boolean(flags & 2048);
|
|
const sampleCount = readU32Be(slice);
|
|
let dataOffset = track.currentFragmentState.baseDataOffset;
|
|
if (dataOffsetPresent) {
|
|
dataOffset += readI32Be(slice);
|
|
}
|
|
let firstSampleFlags = null;
|
|
if (firstSampleFlagsPresent) {
|
|
firstSampleFlags = readU32Be(slice);
|
|
}
|
|
let currentOffset = dataOffset;
|
|
if (sampleCount === 0) {
|
|
this.currentFragment.implicitBaseDataOffset = currentOffset;
|
|
break;
|
|
}
|
|
let currentTimestamp = 0;
|
|
const trackData = {
|
|
track,
|
|
startTimestamp: 0,
|
|
endTimestamp: 0,
|
|
firstKeyFrameTimestamp: null,
|
|
samples: [],
|
|
presentationTimestamps: [],
|
|
startTimestampIsFinal: false
|
|
};
|
|
this.currentFragment.trackData.set(track.id, trackData);
|
|
for (let i = 0; i < sampleCount; i++) {
|
|
let sampleDuration;
|
|
if (sampleDurationPresent) {
|
|
sampleDuration = readU32Be(slice);
|
|
} else {
|
|
assert(track.currentFragmentState.defaultSampleDuration !== null);
|
|
sampleDuration = track.currentFragmentState.defaultSampleDuration;
|
|
}
|
|
let sampleSize;
|
|
if (sampleSizePresent) {
|
|
sampleSize = readU32Be(slice);
|
|
} else {
|
|
assert(track.currentFragmentState.defaultSampleSize !== null);
|
|
sampleSize = track.currentFragmentState.defaultSampleSize;
|
|
}
|
|
let sampleFlags;
|
|
if (sampleFlagsPresent) {
|
|
sampleFlags = readU32Be(slice);
|
|
} else {
|
|
assert(track.currentFragmentState.defaultSampleFlags !== null);
|
|
sampleFlags = track.currentFragmentState.defaultSampleFlags;
|
|
}
|
|
if (i === 0 && firstSampleFlags !== null) {
|
|
sampleFlags = firstSampleFlags;
|
|
}
|
|
let sampleCompositionTimeOffset = 0;
|
|
if (sampleCompositionTimeOffsetsPresent) {
|
|
if (version === 0) {
|
|
sampleCompositionTimeOffset = readU32Be(slice);
|
|
} else {
|
|
sampleCompositionTimeOffset = readI32Be(slice);
|
|
}
|
|
}
|
|
const isKeyFrame = !(sampleFlags & 65536);
|
|
trackData.samples.push({
|
|
presentationTimestamp: currentTimestamp + sampleCompositionTimeOffset,
|
|
duration: sampleDuration,
|
|
byteOffset: currentOffset,
|
|
byteSize: sampleSize,
|
|
isKeyFrame
|
|
});
|
|
currentOffset += sampleSize;
|
|
currentTimestamp += sampleDuration;
|
|
}
|
|
trackData.presentationTimestamps = trackData.samples.map((x, i) => ({ presentationTimestamp: x.presentationTimestamp, sampleIndex: i })).sort((a, b) => a.presentationTimestamp - b.presentationTimestamp);
|
|
for (let i = 0; i < trackData.presentationTimestamps.length; i++) {
|
|
const currentEntry = trackData.presentationTimestamps[i];
|
|
const currentSample = trackData.samples[currentEntry.sampleIndex];
|
|
if (trackData.firstKeyFrameTimestamp === null && currentSample.isKeyFrame) {
|
|
trackData.firstKeyFrameTimestamp = currentSample.presentationTimestamp;
|
|
}
|
|
if (i < trackData.presentationTimestamps.length - 1) {
|
|
const nextEntry = trackData.presentationTimestamps[i + 1];
|
|
currentSample.duration = nextEntry.presentationTimestamp - currentEntry.presentationTimestamp;
|
|
}
|
|
}
|
|
const firstSample = trackData.samples[trackData.presentationTimestamps[0].sampleIndex];
|
|
const lastSample = trackData.samples[last(trackData.presentationTimestamps).sampleIndex];
|
|
trackData.startTimestamp = firstSample.presentationTimestamp;
|
|
trackData.endTimestamp = lastSample.presentationTimestamp + lastSample.duration;
|
|
this.currentFragment.implicitBaseDataOffset = currentOffset;
|
|
}
|
|
;
|
|
break;
|
|
// Metadata section
|
|
// https://exiftool.org/TagNames/QuickTime.html
|
|
// https://mp4workshop.com/about
|
|
case "udta":
|
|
{
|
|
const iterator = this.iterateContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
|
|
for (const { boxInfo: boxInfo2, slice: slice2 } of iterator) {
|
|
if (boxInfo2.name !== "meta" && !this.currentTrack) {
|
|
const startPos2 = slice2.filePos;
|
|
this.metadataTags.raw ??= {};
|
|
if (boxInfo2.name[0] === "\xA9") {
|
|
this.metadataTags.raw[boxInfo2.name] ??= readMetadataStringShort(slice2);
|
|
} else {
|
|
this.metadataTags.raw[boxInfo2.name] ??= readBytes(slice2, boxInfo2.contentSize);
|
|
}
|
|
slice2.filePos = startPos2;
|
|
}
|
|
switch (boxInfo2.name) {
|
|
case "meta":
|
|
{
|
|
slice2.skip(-boxInfo2.headerSize);
|
|
this.traverseBox(slice2);
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9nam":
|
|
case "name":
|
|
{
|
|
if (this.currentTrack) {
|
|
this.currentTrack.name = textDecoder.decode(readBytes(slice2, boxInfo2.contentSize));
|
|
} else {
|
|
this.metadataTags.title ??= readMetadataStringShort(slice2);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9des":
|
|
{
|
|
if (!this.currentTrack) {
|
|
this.metadataTags.description ??= readMetadataStringShort(slice2);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9ART":
|
|
{
|
|
if (!this.currentTrack) {
|
|
this.metadataTags.artist ??= readMetadataStringShort(slice2);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9alb":
|
|
{
|
|
if (!this.currentTrack) {
|
|
this.metadataTags.album ??= readMetadataStringShort(slice2);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "albr":
|
|
{
|
|
if (!this.currentTrack) {
|
|
this.metadataTags.albumArtist ??= readMetadataStringShort(slice2);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9gen":
|
|
{
|
|
if (!this.currentTrack) {
|
|
this.metadataTags.genre ??= readMetadataStringShort(slice2);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9day":
|
|
{
|
|
if (!this.currentTrack) {
|
|
const date = new Date(readMetadataStringShort(slice2));
|
|
if (!Number.isNaN(date.getTime())) {
|
|
this.metadataTags.date ??= date;
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9cmt":
|
|
{
|
|
if (!this.currentTrack) {
|
|
this.metadataTags.comment ??= readMetadataStringShort(slice2);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9lyr":
|
|
{
|
|
if (!this.currentTrack) {
|
|
this.metadataTags.lyrics ??= readMetadataStringShort(slice2);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "meta":
|
|
{
|
|
if (this.currentTrack) {
|
|
break;
|
|
}
|
|
const word = readU32Be(slice);
|
|
const isQuickTime = word !== 0;
|
|
this.currentMetadataKeys = /* @__PURE__ */ new Map();
|
|
if (isQuickTime) {
|
|
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
|
|
} else {
|
|
this.readContiguousBoxes(slice.slice(contentStartPos + 4, boxInfo.contentSize - 4));
|
|
}
|
|
this.currentMetadataKeys = null;
|
|
}
|
|
;
|
|
break;
|
|
case "keys":
|
|
{
|
|
if (!this.currentMetadataKeys) {
|
|
break;
|
|
}
|
|
slice.skip(4);
|
|
const entryCount = readU32Be(slice);
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const keySize = readU32Be(slice);
|
|
slice.skip(4);
|
|
const keyName = textDecoder.decode(readBytes(slice, keySize - 8));
|
|
this.currentMetadataKeys.set(i + 1, keyName);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "ilst":
|
|
{
|
|
if (!this.currentMetadataKeys) {
|
|
break;
|
|
}
|
|
const iterator = this.iterateContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
|
|
for (const { boxInfo: boxInfo2, slice: slice2 } of iterator) {
|
|
let metadataKey = boxInfo2.name;
|
|
const nameAsNumber = (metadataKey.charCodeAt(0) << 24) + (metadataKey.charCodeAt(1) << 16) + (metadataKey.charCodeAt(2) << 8) + metadataKey.charCodeAt(3);
|
|
if (this.currentMetadataKeys.has(nameAsNumber)) {
|
|
metadataKey = this.currentMetadataKeys.get(nameAsNumber);
|
|
}
|
|
const data = readDataBox(slice2);
|
|
this.metadataTags.raw ??= {};
|
|
this.metadataTags.raw[metadataKey] ??= data;
|
|
switch (metadataKey) {
|
|
case "\xA9nam":
|
|
case "titl":
|
|
case "com.apple.quicktime.title":
|
|
case "title":
|
|
{
|
|
if (typeof data === "string") {
|
|
this.metadataTags.title ??= data;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9des":
|
|
case "desc":
|
|
case "dscp":
|
|
case "com.apple.quicktime.description":
|
|
case "description":
|
|
{
|
|
if (typeof data === "string") {
|
|
this.metadataTags.description ??= data;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9ART":
|
|
case "com.apple.quicktime.artist":
|
|
case "artist":
|
|
{
|
|
if (typeof data === "string") {
|
|
this.metadataTags.artist ??= data;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9alb":
|
|
case "albm":
|
|
case "com.apple.quicktime.album":
|
|
case "album":
|
|
{
|
|
if (typeof data === "string") {
|
|
this.metadataTags.album ??= data;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "aART":
|
|
case "album_artist":
|
|
{
|
|
if (typeof data === "string") {
|
|
this.metadataTags.albumArtist ??= data;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9cmt":
|
|
case "com.apple.quicktime.comment":
|
|
case "comment":
|
|
{
|
|
if (typeof data === "string") {
|
|
this.metadataTags.comment ??= data;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9gen":
|
|
case "gnre":
|
|
case "com.apple.quicktime.genre":
|
|
case "genre":
|
|
{
|
|
if (typeof data === "string") {
|
|
this.metadataTags.genre ??= data;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9lyr":
|
|
case "lyrics":
|
|
{
|
|
if (typeof data === "string") {
|
|
this.metadataTags.lyrics ??= data;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "\xA9day":
|
|
case "rldt":
|
|
case "com.apple.quicktime.creationdate":
|
|
case "date":
|
|
{
|
|
if (typeof data === "string") {
|
|
const date = new Date(data);
|
|
if (!Number.isNaN(date.getTime())) {
|
|
this.metadataTags.date ??= date;
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "covr":
|
|
case "com.apple.quicktime.artwork":
|
|
{
|
|
if (data instanceof RichImageData) {
|
|
this.metadataTags.images ??= [];
|
|
this.metadataTags.images.push({
|
|
data: data.data,
|
|
kind: "coverFront",
|
|
mimeType: data.mimeType
|
|
});
|
|
} else if (data instanceof Uint8Array) {
|
|
this.metadataTags.images ??= [];
|
|
this.metadataTags.images.push({
|
|
data,
|
|
kind: "coverFront",
|
|
mimeType: "image/*"
|
|
});
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "track":
|
|
{
|
|
if (typeof data === "string") {
|
|
const parts = data.split("/");
|
|
const trackNum = Number.parseInt(parts[0], 10);
|
|
const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);
|
|
if (Number.isInteger(trackNum) && trackNum > 0) {
|
|
this.metadataTags.trackNumber ??= trackNum;
|
|
}
|
|
if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {
|
|
this.metadataTags.tracksTotal ??= tracksTotal;
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "trkn":
|
|
{
|
|
if (data instanceof Uint8Array && data.length >= 6) {
|
|
const view2 = toDataView(data);
|
|
const trackNumber = view2.getUint16(2, false);
|
|
const tracksTotal = view2.getUint16(4, false);
|
|
if (trackNumber > 0) {
|
|
this.metadataTags.trackNumber ??= trackNumber;
|
|
}
|
|
if (tracksTotal > 0) {
|
|
this.metadataTags.tracksTotal ??= tracksTotal;
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "disc":
|
|
case "disk":
|
|
{
|
|
if (data instanceof Uint8Array && data.length >= 6) {
|
|
const view2 = toDataView(data);
|
|
const discNumber = view2.getUint16(2, false);
|
|
const discNumberMax = view2.getUint16(4, false);
|
|
if (discNumber > 0) {
|
|
this.metadataTags.discNumber ??= discNumber;
|
|
}
|
|
if (discNumberMax > 0) {
|
|
this.metadataTags.discsTotal ??= discNumberMax;
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
}
|
|
slice.filePos = boxEndPos;
|
|
return true;
|
|
}
|
|
};
|
|
var IsobmffTrackBacking = class {
|
|
constructor(internalTrack) {
|
|
this.internalTrack = internalTrack;
|
|
this.packetToSampleIndex = /* @__PURE__ */ new WeakMap();
|
|
this.packetToFragmentLocation = /* @__PURE__ */ new WeakMap();
|
|
}
|
|
getId() {
|
|
return this.internalTrack.id;
|
|
}
|
|
getNumber() {
|
|
const demuxer = this.internalTrack.demuxer;
|
|
const inputTrack = this.internalTrack.inputTrack;
|
|
const trackType = inputTrack.type;
|
|
let number = 0;
|
|
for (const track of demuxer.tracks) {
|
|
if (track.inputTrack.type === trackType) {
|
|
number++;
|
|
}
|
|
if (track === this.internalTrack) {
|
|
break;
|
|
}
|
|
}
|
|
return number;
|
|
}
|
|
getCodec() {
|
|
throw new Error("Not implemented on base class.");
|
|
}
|
|
getInternalCodecId() {
|
|
return this.internalTrack.internalCodecId;
|
|
}
|
|
getName() {
|
|
return this.internalTrack.name;
|
|
}
|
|
getLanguageCode() {
|
|
return this.internalTrack.languageCode;
|
|
}
|
|
getTimeResolution() {
|
|
return this.internalTrack.timescale;
|
|
}
|
|
getDisposition() {
|
|
return this.internalTrack.disposition;
|
|
}
|
|
async computeDuration() {
|
|
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
|
|
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
|
|
}
|
|
async getFirstTimestamp() {
|
|
const firstPacket = await this.getFirstPacket({ metadataOnly: true });
|
|
return firstPacket?.timestamp ?? 0;
|
|
}
|
|
async getFirstPacket(options) {
|
|
const regularPacket = await this.fetchPacketForSampleIndex(0, options);
|
|
if (regularPacket || !this.internalTrack.demuxer.isFragmented) {
|
|
return regularPacket;
|
|
}
|
|
return this.performFragmentedLookup(
|
|
null,
|
|
(fragment) => {
|
|
const trackData = fragment.trackData.get(this.internalTrack.id);
|
|
if (trackData) {
|
|
return {
|
|
sampleIndex: 0,
|
|
correctSampleFound: true
|
|
};
|
|
}
|
|
return {
|
|
sampleIndex: -1,
|
|
correctSampleFound: false
|
|
};
|
|
},
|
|
-Infinity,
|
|
// Use -Infinity as a search timestamp to avoid using the lookup entries
|
|
Infinity,
|
|
options
|
|
);
|
|
}
|
|
mapTimestampIntoTimescale(timestamp) {
|
|
return roundIfAlmostInteger(timestamp * this.internalTrack.timescale) + this.internalTrack.editListOffset;
|
|
}
|
|
async getPacket(timestamp, options) {
|
|
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
|
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
|
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
|
const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);
|
|
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
|
|
return regularPacket;
|
|
}
|
|
return this.performFragmentedLookup(
|
|
null,
|
|
(fragment) => {
|
|
const trackData = fragment.trackData.get(this.internalTrack.id);
|
|
if (!trackData) {
|
|
return { sampleIndex: -1, correctSampleFound: false };
|
|
}
|
|
const index = binarySearchLessOrEqual(
|
|
trackData.presentationTimestamps,
|
|
timestampInTimescale,
|
|
(x) => x.presentationTimestamp
|
|
);
|
|
const sampleIndex2 = index !== -1 ? trackData.presentationTimestamps[index].sampleIndex : -1;
|
|
const correctSampleFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
|
|
return { sampleIndex: sampleIndex2, correctSampleFound };
|
|
},
|
|
timestampInTimescale,
|
|
timestampInTimescale,
|
|
options
|
|
);
|
|
}
|
|
async getNextPacket(packet, options) {
|
|
const regularSampleIndex = this.packetToSampleIndex.get(packet);
|
|
if (regularSampleIndex !== void 0) {
|
|
return this.fetchPacketForSampleIndex(regularSampleIndex + 1, options);
|
|
}
|
|
const locationInFragment = this.packetToFragmentLocation.get(packet);
|
|
if (locationInFragment === void 0) {
|
|
throw new Error("Packet was not created from this track.");
|
|
}
|
|
return this.performFragmentedLookup(
|
|
locationInFragment.fragment,
|
|
(fragment) => {
|
|
if (fragment === locationInFragment.fragment) {
|
|
const trackData = fragment.trackData.get(this.internalTrack.id);
|
|
if (locationInFragment.sampleIndex + 1 < trackData.samples.length) {
|
|
return {
|
|
sampleIndex: locationInFragment.sampleIndex + 1,
|
|
correctSampleFound: true
|
|
};
|
|
}
|
|
} else {
|
|
const trackData = fragment.trackData.get(this.internalTrack.id);
|
|
if (trackData) {
|
|
return {
|
|
sampleIndex: 0,
|
|
correctSampleFound: true
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
sampleIndex: -1,
|
|
correctSampleFound: false
|
|
};
|
|
},
|
|
-Infinity,
|
|
// Use -Infinity as a search timestamp to avoid using the lookup entries
|
|
Infinity,
|
|
options
|
|
);
|
|
}
|
|
async getKeyPacket(timestamp, options) {
|
|
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
|
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
|
const sampleIndex = getKeyframeSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
|
const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);
|
|
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
|
|
return regularPacket;
|
|
}
|
|
return this.performFragmentedLookup(
|
|
null,
|
|
(fragment) => {
|
|
const trackData = fragment.trackData.get(this.internalTrack.id);
|
|
if (!trackData) {
|
|
return { sampleIndex: -1, correctSampleFound: false };
|
|
}
|
|
const index = findLastIndex(trackData.presentationTimestamps, (x) => {
|
|
const sample = trackData.samples[x.sampleIndex];
|
|
return sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale;
|
|
});
|
|
const sampleIndex2 = index !== -1 ? trackData.presentationTimestamps[index].sampleIndex : -1;
|
|
const correctSampleFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
|
|
return { sampleIndex: sampleIndex2, correctSampleFound };
|
|
},
|
|
timestampInTimescale,
|
|
timestampInTimescale,
|
|
options
|
|
);
|
|
}
|
|
async getNextKeyPacket(packet, options) {
|
|
const regularSampleIndex = this.packetToSampleIndex.get(packet);
|
|
if (regularSampleIndex !== void 0) {
|
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
|
const nextKeyFrameSampleIndex = getNextKeyframeIndexForSample(sampleTable, regularSampleIndex);
|
|
return this.fetchPacketForSampleIndex(nextKeyFrameSampleIndex, options);
|
|
}
|
|
const locationInFragment = this.packetToFragmentLocation.get(packet);
|
|
if (locationInFragment === void 0) {
|
|
throw new Error("Packet was not created from this track.");
|
|
}
|
|
return this.performFragmentedLookup(
|
|
locationInFragment.fragment,
|
|
(fragment) => {
|
|
if (fragment === locationInFragment.fragment) {
|
|
const trackData = fragment.trackData.get(this.internalTrack.id);
|
|
const nextKeyFrameIndex = trackData.samples.findIndex(
|
|
(x, i) => x.isKeyFrame && i > locationInFragment.sampleIndex
|
|
);
|
|
if (nextKeyFrameIndex !== -1) {
|
|
return {
|
|
sampleIndex: nextKeyFrameIndex,
|
|
correctSampleFound: true
|
|
};
|
|
}
|
|
} else {
|
|
const trackData = fragment.trackData.get(this.internalTrack.id);
|
|
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
|
|
const keyFrameIndex = trackData.samples.findIndex((x) => x.isKeyFrame);
|
|
assert(keyFrameIndex !== -1);
|
|
return {
|
|
sampleIndex: keyFrameIndex,
|
|
correctSampleFound: true
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
sampleIndex: -1,
|
|
correctSampleFound: false
|
|
};
|
|
},
|
|
-Infinity,
|
|
// Use -Infinity as a search timestamp to avoid using the lookup entries
|
|
Infinity,
|
|
options
|
|
);
|
|
}
|
|
async fetchPacketForSampleIndex(sampleIndex, options) {
|
|
if (sampleIndex === -1) {
|
|
return null;
|
|
}
|
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
|
const sampleInfo = getSampleInfo(sampleTable, sampleIndex);
|
|
if (!sampleInfo) {
|
|
return null;
|
|
}
|
|
let data;
|
|
if (options.metadataOnly) {
|
|
data = PLACEHOLDER_DATA;
|
|
} else {
|
|
let slice = this.internalTrack.demuxer.reader.requestSlice(
|
|
sampleInfo.sampleOffset,
|
|
sampleInfo.sampleSize
|
|
);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
assert(slice);
|
|
data = readBytes(slice, sampleInfo.sampleSize);
|
|
}
|
|
const timestamp = (sampleInfo.presentationTimestamp - this.internalTrack.editListOffset) / this.internalTrack.timescale;
|
|
const duration = sampleInfo.duration / this.internalTrack.timescale;
|
|
const packet = new EncodedPacket(
|
|
data,
|
|
sampleInfo.isKeyFrame ? "key" : "delta",
|
|
timestamp,
|
|
duration,
|
|
sampleIndex,
|
|
sampleInfo.sampleSize
|
|
);
|
|
this.packetToSampleIndex.set(packet, sampleIndex);
|
|
return packet;
|
|
}
|
|
async fetchPacketInFragment(fragment, sampleIndex, options) {
|
|
if (sampleIndex === -1) {
|
|
return null;
|
|
}
|
|
const trackData = fragment.trackData.get(this.internalTrack.id);
|
|
const fragmentSample = trackData.samples[sampleIndex];
|
|
assert(fragmentSample);
|
|
let data;
|
|
if (options.metadataOnly) {
|
|
data = PLACEHOLDER_DATA;
|
|
} else {
|
|
let slice = this.internalTrack.demuxer.reader.requestSlice(
|
|
fragmentSample.byteOffset,
|
|
fragmentSample.byteSize
|
|
);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
assert(slice);
|
|
data = readBytes(slice, fragmentSample.byteSize);
|
|
}
|
|
const timestamp = (fragmentSample.presentationTimestamp - this.internalTrack.editListOffset) / this.internalTrack.timescale;
|
|
const duration = fragmentSample.duration / this.internalTrack.timescale;
|
|
const packet = new EncodedPacket(
|
|
data,
|
|
fragmentSample.isKeyFrame ? "key" : "delta",
|
|
timestamp,
|
|
duration,
|
|
fragment.moofOffset + sampleIndex,
|
|
fragmentSample.byteSize
|
|
);
|
|
this.packetToFragmentLocation.set(packet, { fragment, sampleIndex });
|
|
return packet;
|
|
}
|
|
/** Looks for a packet in the fragments while trying to load as few fragments as possible to retrieve it. */
|
|
async performFragmentedLookup(startFragment, getMatchInFragment, searchTimestamp, latestTimestamp, options) {
|
|
const demuxer = this.internalTrack.demuxer;
|
|
let currentFragment = null;
|
|
let bestFragment = null;
|
|
let bestSampleIndex = -1;
|
|
if (startFragment) {
|
|
const { sampleIndex, correctSampleFound } = getMatchInFragment(startFragment);
|
|
if (correctSampleFound) {
|
|
return this.fetchPacketInFragment(startFragment, sampleIndex, options);
|
|
}
|
|
if (sampleIndex !== -1) {
|
|
bestFragment = startFragment;
|
|
bestSampleIndex = sampleIndex;
|
|
}
|
|
}
|
|
const lookupEntryIndex = binarySearchLessOrEqual(
|
|
this.internalTrack.fragmentLookupTable,
|
|
searchTimestamp,
|
|
(x) => x.timestamp
|
|
);
|
|
const lookupEntry = lookupEntryIndex !== -1 ? this.internalTrack.fragmentLookupTable[lookupEntryIndex] : null;
|
|
const positionCacheIndex = binarySearchLessOrEqual(
|
|
this.internalTrack.fragmentPositionCache,
|
|
searchTimestamp,
|
|
(x) => x.startTimestamp
|
|
);
|
|
const positionCacheEntry = positionCacheIndex !== -1 ? this.internalTrack.fragmentPositionCache[positionCacheIndex] : null;
|
|
const lookupEntryPosition = Math.max(
|
|
lookupEntry?.moofOffset ?? 0,
|
|
positionCacheEntry?.moofOffset ?? 0
|
|
) || null;
|
|
let currentPos;
|
|
if (!startFragment) {
|
|
currentPos = lookupEntryPosition ?? 0;
|
|
} else {
|
|
if (lookupEntryPosition === null || startFragment.moofOffset >= lookupEntryPosition) {
|
|
currentPos = startFragment.moofOffset + startFragment.moofSize;
|
|
currentFragment = startFragment;
|
|
} else {
|
|
currentPos = lookupEntryPosition;
|
|
}
|
|
}
|
|
while (true) {
|
|
if (currentFragment) {
|
|
const trackData = currentFragment.trackData.get(this.internalTrack.id);
|
|
if (trackData && trackData.startTimestamp > latestTimestamp) {
|
|
break;
|
|
}
|
|
}
|
|
let slice = demuxer.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) break;
|
|
const boxStartPos = currentPos;
|
|
const boxInfo = readBoxHeader(slice);
|
|
if (!boxInfo) {
|
|
break;
|
|
}
|
|
if (boxInfo.name === "moof") {
|
|
currentFragment = await demuxer.readFragment(boxStartPos);
|
|
const { sampleIndex, correctSampleFound } = getMatchInFragment(currentFragment);
|
|
if (correctSampleFound) {
|
|
return this.fetchPacketInFragment(currentFragment, sampleIndex, options);
|
|
}
|
|
if (sampleIndex !== -1) {
|
|
bestFragment = currentFragment;
|
|
bestSampleIndex = sampleIndex;
|
|
}
|
|
}
|
|
currentPos = boxStartPos + boxInfo.totalSize;
|
|
}
|
|
if (lookupEntry && (!bestFragment || bestFragment.moofOffset < lookupEntry.moofOffset)) {
|
|
const previousLookupEntry = this.internalTrack.fragmentLookupTable[lookupEntryIndex - 1];
|
|
assert(!previousLookupEntry || previousLookupEntry.timestamp < lookupEntry.timestamp);
|
|
const newSearchTimestamp = previousLookupEntry?.timestamp ?? -Infinity;
|
|
return this.performFragmentedLookup(
|
|
null,
|
|
getMatchInFragment,
|
|
newSearchTimestamp,
|
|
latestTimestamp,
|
|
options
|
|
);
|
|
}
|
|
if (bestFragment) {
|
|
return this.fetchPacketInFragment(bestFragment, bestSampleIndex, options);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
var IsobmffVideoTrackBacking = class extends IsobmffTrackBacking {
|
|
constructor(internalTrack) {
|
|
super(internalTrack);
|
|
this.decoderConfigPromise = null;
|
|
this.internalTrack = internalTrack;
|
|
}
|
|
getCodec() {
|
|
return this.internalTrack.info.codec;
|
|
}
|
|
getCodedWidth() {
|
|
return this.internalTrack.info.width;
|
|
}
|
|
getCodedHeight() {
|
|
return this.internalTrack.info.height;
|
|
}
|
|
getRotation() {
|
|
return this.internalTrack.rotation;
|
|
}
|
|
async getColorSpace() {
|
|
return {
|
|
primaries: this.internalTrack.info.colorSpace?.primaries,
|
|
transfer: this.internalTrack.info.colorSpace?.transfer,
|
|
matrix: this.internalTrack.info.colorSpace?.matrix,
|
|
fullRange: this.internalTrack.info.colorSpace?.fullRange
|
|
};
|
|
}
|
|
async canBeTransparent() {
|
|
return false;
|
|
}
|
|
async getDecoderConfig() {
|
|
if (!this.internalTrack.info.codec) {
|
|
return null;
|
|
}
|
|
return this.decoderConfigPromise ??= (async () => {
|
|
if (this.internalTrack.info.codec === "vp9" && !this.internalTrack.info.vp9CodecInfo) {
|
|
const firstPacket = await this.getFirstPacket({});
|
|
this.internalTrack.info.vp9CodecInfo = firstPacket && extractVp9CodecInfoFromPacket(firstPacket.data);
|
|
} else if (this.internalTrack.info.codec === "av1" && !this.internalTrack.info.av1CodecInfo) {
|
|
const firstPacket = await this.getFirstPacket({});
|
|
this.internalTrack.info.av1CodecInfo = firstPacket && extractAv1CodecInfoFromPacket(firstPacket.data);
|
|
}
|
|
return {
|
|
codec: extractVideoCodecString(this.internalTrack.info),
|
|
codedWidth: this.internalTrack.info.width,
|
|
codedHeight: this.internalTrack.info.height,
|
|
description: this.internalTrack.info.codecDescription ?? void 0,
|
|
colorSpace: this.internalTrack.info.colorSpace ?? void 0
|
|
};
|
|
})();
|
|
}
|
|
};
|
|
var IsobmffAudioTrackBacking = class extends IsobmffTrackBacking {
|
|
constructor(internalTrack) {
|
|
super(internalTrack);
|
|
this.decoderConfig = null;
|
|
this.internalTrack = internalTrack;
|
|
}
|
|
getCodec() {
|
|
return this.internalTrack.info.codec;
|
|
}
|
|
getNumberOfChannels() {
|
|
return this.internalTrack.info.numberOfChannels;
|
|
}
|
|
getSampleRate() {
|
|
return this.internalTrack.info.sampleRate;
|
|
}
|
|
async getDecoderConfig() {
|
|
if (!this.internalTrack.info.codec) {
|
|
return null;
|
|
}
|
|
return this.decoderConfig ??= {
|
|
codec: extractAudioCodecString(this.internalTrack.info),
|
|
numberOfChannels: this.internalTrack.info.numberOfChannels,
|
|
sampleRate: this.internalTrack.info.sampleRate,
|
|
description: this.internalTrack.info.codecDescription ?? void 0
|
|
};
|
|
}
|
|
};
|
|
var getSampleIndexForTimestamp = (sampleTable, timescaleUnits) => {
|
|
if (sampleTable.presentationTimestamps) {
|
|
const index = binarySearchLessOrEqual(
|
|
sampleTable.presentationTimestamps,
|
|
timescaleUnits,
|
|
(x) => x.presentationTimestamp
|
|
);
|
|
if (index === -1) {
|
|
return -1;
|
|
}
|
|
return sampleTable.presentationTimestamps[index].sampleIndex;
|
|
} else {
|
|
const index = binarySearchLessOrEqual(
|
|
sampleTable.sampleTimingEntries,
|
|
timescaleUnits,
|
|
(x) => x.startDecodeTimestamp
|
|
);
|
|
if (index === -1) {
|
|
return -1;
|
|
}
|
|
const entry = sampleTable.sampleTimingEntries[index];
|
|
return entry.startIndex + Math.min(
|
|
Math.floor((timescaleUnits - entry.startDecodeTimestamp) / entry.delta),
|
|
entry.count - 1
|
|
);
|
|
}
|
|
};
|
|
var getKeyframeSampleIndexForTimestamp = (sampleTable, timescaleUnits) => {
|
|
if (!sampleTable.keySampleIndices) {
|
|
return getSampleIndexForTimestamp(sampleTable, timescaleUnits);
|
|
}
|
|
if (sampleTable.presentationTimestamps) {
|
|
const index = binarySearchLessOrEqual(
|
|
sampleTable.presentationTimestamps,
|
|
timescaleUnits,
|
|
(x) => x.presentationTimestamp
|
|
);
|
|
if (index === -1) {
|
|
return -1;
|
|
}
|
|
for (let i = index; i >= 0; i--) {
|
|
const sampleIndex = sampleTable.presentationTimestamps[i].sampleIndex;
|
|
const isKeyFrame = binarySearchExact(sampleTable.keySampleIndices, sampleIndex, (x) => x) !== -1;
|
|
if (isKeyFrame) {
|
|
return sampleIndex;
|
|
}
|
|
}
|
|
return -1;
|
|
} else {
|
|
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timescaleUnits);
|
|
const index = binarySearchLessOrEqual(sampleTable.keySampleIndices, sampleIndex, (x) => x);
|
|
return sampleTable.keySampleIndices[index] ?? -1;
|
|
}
|
|
};
|
|
var getSampleInfo = (sampleTable, sampleIndex) => {
|
|
const timingEntryIndex = binarySearchLessOrEqual(sampleTable.sampleTimingEntries, sampleIndex, (x) => x.startIndex);
|
|
const timingEntry = sampleTable.sampleTimingEntries[timingEntryIndex];
|
|
if (!timingEntry || timingEntry.startIndex + timingEntry.count <= sampleIndex) {
|
|
return null;
|
|
}
|
|
const decodeTimestamp = timingEntry.startDecodeTimestamp + (sampleIndex - timingEntry.startIndex) * timingEntry.delta;
|
|
let presentationTimestamp = decodeTimestamp;
|
|
const offsetEntryIndex = binarySearchLessOrEqual(
|
|
sampleTable.sampleCompositionTimeOffsets,
|
|
sampleIndex,
|
|
(x) => x.startIndex
|
|
);
|
|
const offsetEntry = sampleTable.sampleCompositionTimeOffsets[offsetEntryIndex];
|
|
if (offsetEntry && sampleIndex - offsetEntry.startIndex < offsetEntry.count) {
|
|
presentationTimestamp += offsetEntry.offset;
|
|
}
|
|
const sampleSize = sampleTable.sampleSizes[Math.min(sampleIndex, sampleTable.sampleSizes.length - 1)];
|
|
const chunkEntryIndex = binarySearchLessOrEqual(sampleTable.sampleToChunk, sampleIndex, (x) => x.startSampleIndex);
|
|
const chunkEntry = sampleTable.sampleToChunk[chunkEntryIndex];
|
|
assert(chunkEntry);
|
|
const chunkIndex = chunkEntry.startChunkIndex + Math.floor((sampleIndex - chunkEntry.startSampleIndex) / chunkEntry.samplesPerChunk);
|
|
const chunkOffset = sampleTable.chunkOffsets[chunkIndex];
|
|
const startSampleIndexOfChunk = chunkEntry.startSampleIndex + (chunkIndex - chunkEntry.startChunkIndex) * chunkEntry.samplesPerChunk;
|
|
let chunkSize = 0;
|
|
let sampleOffset = chunkOffset;
|
|
if (sampleTable.sampleSizes.length === 1) {
|
|
sampleOffset += sampleSize * (sampleIndex - startSampleIndexOfChunk);
|
|
chunkSize += sampleSize * chunkEntry.samplesPerChunk;
|
|
} else {
|
|
for (let i = startSampleIndexOfChunk; i < startSampleIndexOfChunk + chunkEntry.samplesPerChunk; i++) {
|
|
const sampleSize2 = sampleTable.sampleSizes[i];
|
|
if (i < sampleIndex) {
|
|
sampleOffset += sampleSize2;
|
|
}
|
|
chunkSize += sampleSize2;
|
|
}
|
|
}
|
|
let duration = timingEntry.delta;
|
|
if (sampleTable.presentationTimestamps) {
|
|
const presentationIndex = sampleTable.presentationTimestampIndexMap[sampleIndex];
|
|
assert(presentationIndex !== void 0);
|
|
if (presentationIndex < sampleTable.presentationTimestamps.length - 1) {
|
|
const nextEntry = sampleTable.presentationTimestamps[presentationIndex + 1];
|
|
const nextPresentationTimestamp = nextEntry.presentationTimestamp;
|
|
duration = nextPresentationTimestamp - presentationTimestamp;
|
|
}
|
|
}
|
|
return {
|
|
presentationTimestamp,
|
|
duration,
|
|
sampleOffset,
|
|
sampleSize,
|
|
chunkOffset,
|
|
chunkSize,
|
|
isKeyFrame: sampleTable.keySampleIndices ? binarySearchExact(sampleTable.keySampleIndices, sampleIndex, (x) => x) !== -1 : true
|
|
};
|
|
};
|
|
var getNextKeyframeIndexForSample = (sampleTable, sampleIndex) => {
|
|
if (!sampleTable.keySampleIndices) {
|
|
return sampleIndex + 1;
|
|
}
|
|
const index = binarySearchLessOrEqual(sampleTable.keySampleIndices, sampleIndex, (x) => x);
|
|
return sampleTable.keySampleIndices[index + 1] ?? -1;
|
|
};
|
|
var offsetFragmentTrackDataByTimestamp = (trackData, timestamp) => {
|
|
trackData.startTimestamp += timestamp;
|
|
trackData.endTimestamp += timestamp;
|
|
for (const sample of trackData.samples) {
|
|
sample.presentationTimestamp += timestamp;
|
|
}
|
|
for (const entry of trackData.presentationTimestamps) {
|
|
entry.presentationTimestamp += timestamp;
|
|
}
|
|
};
|
|
var extractRotationFromMatrix = (matrix) => {
|
|
const [m11, , , m21] = matrix;
|
|
const scaleX = Math.hypot(m11, m21);
|
|
const cosTheta = m11 / scaleX;
|
|
const sinTheta = m21 / scaleX;
|
|
const result = -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI);
|
|
if (!Number.isFinite(result)) {
|
|
return 0;
|
|
}
|
|
return result;
|
|
};
|
|
var sampleTableIsEmpty = (sampleTable) => {
|
|
return sampleTable.sampleSizes.length === 0;
|
|
};
|
|
|
|
// src/matroska/ebml.ts
|
|
var EBMLFloat32 = class {
|
|
constructor(value) {
|
|
this.value = value;
|
|
}
|
|
};
|
|
var EBMLFloat64 = class {
|
|
constructor(value) {
|
|
this.value = value;
|
|
}
|
|
};
|
|
var EBMLSignedInt = class {
|
|
constructor(value) {
|
|
this.value = value;
|
|
}
|
|
};
|
|
var EBMLUnicodeString = class {
|
|
constructor(value) {
|
|
this.value = value;
|
|
}
|
|
};
|
|
var LEVEL_0_EBML_IDS = [
|
|
440786851 /* EBML */,
|
|
408125543 /* Segment */
|
|
];
|
|
var LEVEL_1_EBML_IDS = [
|
|
290298740 /* SeekHead */,
|
|
357149030 /* Info */,
|
|
524531317 /* Cluster */,
|
|
374648427 /* Tracks */,
|
|
475249515 /* Cues */,
|
|
423732329 /* Attachments */,
|
|
272869232 /* Chapters */,
|
|
307544935 /* Tags */
|
|
];
|
|
var LEVEL_0_AND_1_EBML_IDS = [
|
|
...LEVEL_0_EBML_IDS,
|
|
...LEVEL_1_EBML_IDS
|
|
];
|
|
var measureUnsignedInt = (value) => {
|
|
if (value < 1 << 8) {
|
|
return 1;
|
|
} else if (value < 1 << 16) {
|
|
return 2;
|
|
} else if (value < 1 << 24) {
|
|
return 3;
|
|
} else if (value < 2 ** 32) {
|
|
return 4;
|
|
} else if (value < 2 ** 40) {
|
|
return 5;
|
|
} else {
|
|
return 6;
|
|
}
|
|
};
|
|
var measureUnsignedBigInt = (value) => {
|
|
if (value < 1n << 8n) {
|
|
return 1;
|
|
} else if (value < 1n << 16n) {
|
|
return 2;
|
|
} else if (value < 1n << 24n) {
|
|
return 3;
|
|
} else if (value < 1n << 32n) {
|
|
return 4;
|
|
} else if (value < 1n << 40n) {
|
|
return 5;
|
|
} else if (value < 1n << 48n) {
|
|
return 6;
|
|
} else if (value < 1n << 56n) {
|
|
return 7;
|
|
} else {
|
|
return 8;
|
|
}
|
|
};
|
|
var measureSignedInt = (value) => {
|
|
if (value >= -(1 << 6) && value < 1 << 6) {
|
|
return 1;
|
|
} else if (value >= -(1 << 13) && value < 1 << 13) {
|
|
return 2;
|
|
} else if (value >= -(1 << 20) && value < 1 << 20) {
|
|
return 3;
|
|
} else if (value >= -(1 << 27) && value < 1 << 27) {
|
|
return 4;
|
|
} else if (value >= -(2 ** 34) && value < 2 ** 34) {
|
|
return 5;
|
|
} else {
|
|
return 6;
|
|
}
|
|
};
|
|
var measureVarInt = (value) => {
|
|
if (value < (1 << 7) - 1) {
|
|
return 1;
|
|
} else if (value < (1 << 14) - 1) {
|
|
return 2;
|
|
} else if (value < (1 << 21) - 1) {
|
|
return 3;
|
|
} else if (value < (1 << 28) - 1) {
|
|
return 4;
|
|
} else if (value < 2 ** 35 - 1) {
|
|
return 5;
|
|
} else if (value < 2 ** 42 - 1) {
|
|
return 6;
|
|
} else {
|
|
throw new Error("EBML varint size not supported " + value);
|
|
}
|
|
};
|
|
var EBMLWriter = class {
|
|
constructor(writer) {
|
|
this.writer = writer;
|
|
this.helper = new Uint8Array(8);
|
|
this.helperView = new DataView(this.helper.buffer);
|
|
/**
|
|
* Stores the position from the start of the file to where EBML elements have been written. This is used to
|
|
* rewrite/edit elements that were already added before, and to measure sizes of things.
|
|
*/
|
|
this.offsets = /* @__PURE__ */ new WeakMap();
|
|
/** Same as offsets, but stores position where the element's data starts (after ID and size fields). */
|
|
this.dataOffsets = /* @__PURE__ */ new WeakMap();
|
|
}
|
|
writeByte(value) {
|
|
this.helperView.setUint8(0, value);
|
|
this.writer.write(this.helper.subarray(0, 1));
|
|
}
|
|
writeFloat32(value) {
|
|
this.helperView.setFloat32(0, value, false);
|
|
this.writer.write(this.helper.subarray(0, 4));
|
|
}
|
|
writeFloat64(value) {
|
|
this.helperView.setFloat64(0, value, false);
|
|
this.writer.write(this.helper);
|
|
}
|
|
writeUnsignedInt(value, width = measureUnsignedInt(value)) {
|
|
let pos = 0;
|
|
switch (width) {
|
|
case 6:
|
|
this.helperView.setUint8(pos++, value / 2 ** 40 | 0);
|
|
// eslint-disable-next-line no-fallthrough
|
|
case 5:
|
|
this.helperView.setUint8(pos++, value / 2 ** 32 | 0);
|
|
// eslint-disable-next-line no-fallthrough
|
|
case 4:
|
|
this.helperView.setUint8(pos++, value >> 24);
|
|
// eslint-disable-next-line no-fallthrough
|
|
case 3:
|
|
this.helperView.setUint8(pos++, value >> 16);
|
|
// eslint-disable-next-line no-fallthrough
|
|
case 2:
|
|
this.helperView.setUint8(pos++, value >> 8);
|
|
// eslint-disable-next-line no-fallthrough
|
|
case 1:
|
|
this.helperView.setUint8(pos++, value);
|
|
break;
|
|
default:
|
|
throw new Error("Bad unsigned int size " + width);
|
|
}
|
|
this.writer.write(this.helper.subarray(0, pos));
|
|
}
|
|
writeUnsignedBigInt(value, width = measureUnsignedBigInt(value)) {
|
|
let pos = 0;
|
|
for (let i = width - 1; i >= 0; i--) {
|
|
this.helperView.setUint8(pos++, Number(value >> BigInt(i * 8) & 0xffn));
|
|
}
|
|
this.writer.write(this.helper.subarray(0, pos));
|
|
}
|
|
writeSignedInt(value, width = measureSignedInt(value)) {
|
|
if (value < 0) {
|
|
value += 2 ** (width * 8);
|
|
}
|
|
this.writeUnsignedInt(value, width);
|
|
}
|
|
writeVarInt(value, width = measureVarInt(value)) {
|
|
let pos = 0;
|
|
switch (width) {
|
|
case 1:
|
|
this.helperView.setUint8(pos++, 1 << 7 | value);
|
|
break;
|
|
case 2:
|
|
this.helperView.setUint8(pos++, 1 << 6 | value >> 8);
|
|
this.helperView.setUint8(pos++, value);
|
|
break;
|
|
case 3:
|
|
this.helperView.setUint8(pos++, 1 << 5 | value >> 16);
|
|
this.helperView.setUint8(pos++, value >> 8);
|
|
this.helperView.setUint8(pos++, value);
|
|
break;
|
|
case 4:
|
|
this.helperView.setUint8(pos++, 1 << 4 | value >> 24);
|
|
this.helperView.setUint8(pos++, value >> 16);
|
|
this.helperView.setUint8(pos++, value >> 8);
|
|
this.helperView.setUint8(pos++, value);
|
|
break;
|
|
case 5:
|
|
this.helperView.setUint8(pos++, 1 << 3 | value / 2 ** 32 & 7);
|
|
this.helperView.setUint8(pos++, value >> 24);
|
|
this.helperView.setUint8(pos++, value >> 16);
|
|
this.helperView.setUint8(pos++, value >> 8);
|
|
this.helperView.setUint8(pos++, value);
|
|
break;
|
|
case 6:
|
|
this.helperView.setUint8(pos++, 1 << 2 | value / 2 ** 40 & 3);
|
|
this.helperView.setUint8(pos++, value / 2 ** 32 | 0);
|
|
this.helperView.setUint8(pos++, value >> 24);
|
|
this.helperView.setUint8(pos++, value >> 16);
|
|
this.helperView.setUint8(pos++, value >> 8);
|
|
this.helperView.setUint8(pos++, value);
|
|
break;
|
|
default:
|
|
throw new Error("Bad EBML varint size " + width);
|
|
}
|
|
this.writer.write(this.helper.subarray(0, pos));
|
|
}
|
|
writeAsciiString(str) {
|
|
this.writer.write(new Uint8Array(str.split("").map((x) => x.charCodeAt(0))));
|
|
}
|
|
writeEBML(data) {
|
|
if (data === null) return;
|
|
if (data instanceof Uint8Array) {
|
|
this.writer.write(data);
|
|
} else if (Array.isArray(data)) {
|
|
for (const elem of data) {
|
|
this.writeEBML(elem);
|
|
}
|
|
} else {
|
|
this.offsets.set(data, this.writer.getPos());
|
|
this.writeUnsignedInt(data.id);
|
|
if (Array.isArray(data.data)) {
|
|
const sizePos = this.writer.getPos();
|
|
const sizeSize = data.size === -1 ? 1 : data.size ?? 4;
|
|
if (data.size === -1) {
|
|
this.writeByte(255);
|
|
} else {
|
|
this.writer.seek(this.writer.getPos() + sizeSize);
|
|
}
|
|
const startPos = this.writer.getPos();
|
|
this.dataOffsets.set(data, startPos);
|
|
this.writeEBML(data.data);
|
|
if (data.size !== -1) {
|
|
const size = this.writer.getPos() - startPos;
|
|
const endPos = this.writer.getPos();
|
|
this.writer.seek(sizePos);
|
|
this.writeVarInt(size, sizeSize);
|
|
this.writer.seek(endPos);
|
|
}
|
|
} else if (typeof data.data === "number") {
|
|
const size = data.size ?? measureUnsignedInt(data.data);
|
|
this.writeVarInt(size);
|
|
this.writeUnsignedInt(data.data, size);
|
|
} else if (typeof data.data === "bigint") {
|
|
const size = data.size ?? measureUnsignedBigInt(data.data);
|
|
this.writeVarInt(size);
|
|
this.writeUnsignedBigInt(data.data, size);
|
|
} else if (typeof data.data === "string") {
|
|
this.writeVarInt(data.data.length);
|
|
this.writeAsciiString(data.data);
|
|
} else if (data.data instanceof Uint8Array) {
|
|
this.writeVarInt(data.data.byteLength, data.size);
|
|
this.writer.write(data.data);
|
|
} else if (data.data instanceof EBMLFloat32) {
|
|
this.writeVarInt(4);
|
|
this.writeFloat32(data.data.value);
|
|
} else if (data.data instanceof EBMLFloat64) {
|
|
this.writeVarInt(8);
|
|
this.writeFloat64(data.data.value);
|
|
} else if (data.data instanceof EBMLSignedInt) {
|
|
const size = data.size ?? measureSignedInt(data.data.value);
|
|
this.writeVarInt(size);
|
|
this.writeSignedInt(data.data.value, size);
|
|
} else if (data.data instanceof EBMLUnicodeString) {
|
|
const bytes2 = textEncoder.encode(data.data.value);
|
|
this.writeVarInt(bytes2.length);
|
|
this.writer.write(bytes2);
|
|
} else {
|
|
assertNever(data.data);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
var MAX_VAR_INT_SIZE = 8;
|
|
var MIN_HEADER_SIZE = 2;
|
|
var MAX_HEADER_SIZE = 2 * MAX_VAR_INT_SIZE;
|
|
var readVarIntSize = (slice) => {
|
|
if (slice.remainingLength < 1) {
|
|
return null;
|
|
}
|
|
const firstByte = readU8(slice);
|
|
slice.skip(-1);
|
|
if (firstByte === 0) {
|
|
return null;
|
|
}
|
|
let width = 1;
|
|
let mask = 128;
|
|
while ((firstByte & mask) === 0) {
|
|
width++;
|
|
mask >>= 1;
|
|
}
|
|
if (slice.remainingLength < width) {
|
|
return null;
|
|
}
|
|
return width;
|
|
};
|
|
var readVarInt = (slice) => {
|
|
if (slice.remainingLength < 1) {
|
|
return null;
|
|
}
|
|
const firstByte = readU8(slice);
|
|
if (firstByte === 0) {
|
|
return null;
|
|
}
|
|
let width = 1;
|
|
let mask = 1 << 7;
|
|
while ((firstByte & mask) === 0) {
|
|
width++;
|
|
mask >>= 1;
|
|
}
|
|
if (slice.remainingLength < width - 1) {
|
|
return null;
|
|
}
|
|
let value = firstByte & mask - 1;
|
|
for (let i = 1; i < width; i++) {
|
|
value *= 1 << 8;
|
|
value += readU8(slice);
|
|
}
|
|
return value;
|
|
};
|
|
var readUnsignedInt = (slice, width) => {
|
|
if (width < 1 || width > 8) {
|
|
throw new Error("Bad unsigned int size " + width);
|
|
}
|
|
let value = 0;
|
|
for (let i = 0; i < width; i++) {
|
|
value *= 1 << 8;
|
|
value += readU8(slice);
|
|
}
|
|
return value;
|
|
};
|
|
var readUnsignedBigInt = (slice, width) => {
|
|
if (width < 1) {
|
|
throw new Error("Bad unsigned int size " + width);
|
|
}
|
|
let value = 0n;
|
|
for (let i = 0; i < width; i++) {
|
|
value <<= 8n;
|
|
value += BigInt(readU8(slice));
|
|
}
|
|
return value;
|
|
};
|
|
var readElementId = (slice) => {
|
|
const size = readVarIntSize(slice);
|
|
if (size === null) {
|
|
return null;
|
|
}
|
|
if (slice.remainingLength < size) {
|
|
return null;
|
|
}
|
|
const id = readUnsignedInt(slice, size);
|
|
return id;
|
|
};
|
|
var readElementSize = (slice) => {
|
|
if (slice.remainingLength < 1) {
|
|
return null;
|
|
}
|
|
const firstByte = readU8(slice);
|
|
if (firstByte === 255) {
|
|
return void 0;
|
|
}
|
|
slice.skip(-1);
|
|
const size = readVarInt(slice);
|
|
if (size === null) {
|
|
return null;
|
|
}
|
|
if (size === 72057594037927940) {
|
|
return void 0;
|
|
}
|
|
return size;
|
|
};
|
|
var readElementHeader = (slice) => {
|
|
assert(slice.remainingLength >= MIN_HEADER_SIZE);
|
|
const id = readElementId(slice);
|
|
if (id === null) {
|
|
return null;
|
|
}
|
|
const size = readElementSize(slice);
|
|
if (size === null) {
|
|
return null;
|
|
}
|
|
return { id, size };
|
|
};
|
|
var readAsciiString = (slice, length) => {
|
|
const bytes2 = readBytes(slice, length);
|
|
let strLength = 0;
|
|
while (strLength < length && bytes2[strLength] !== 0) {
|
|
strLength += 1;
|
|
}
|
|
return String.fromCharCode(...bytes2.subarray(0, strLength));
|
|
};
|
|
var readUnicodeString = (slice, length) => {
|
|
const bytes2 = readBytes(slice, length);
|
|
let strLength = 0;
|
|
while (strLength < length && bytes2[strLength] !== 0) {
|
|
strLength += 1;
|
|
}
|
|
return textDecoder.decode(bytes2.subarray(0, strLength));
|
|
};
|
|
var readFloat = (slice, width) => {
|
|
if (width === 0) {
|
|
return 0;
|
|
}
|
|
if (width !== 4 && width !== 8) {
|
|
throw new Error("Bad float size " + width);
|
|
}
|
|
return width === 4 ? readF32Be(slice) : readF64Be(slice);
|
|
};
|
|
var searchForNextElementId = async (reader, startPos, ids, until) => {
|
|
const idsSet = new Set(ids);
|
|
let currentPos = startPos;
|
|
while (until === null || currentPos < until) {
|
|
let slice = reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) break;
|
|
const elementHeader = readElementHeader(slice);
|
|
if (!elementHeader) {
|
|
break;
|
|
}
|
|
if (idsSet.has(elementHeader.id)) {
|
|
return { pos: currentPos, found: true };
|
|
}
|
|
assertDefinedSize(elementHeader.size);
|
|
currentPos = slice.filePos + elementHeader.size;
|
|
}
|
|
return { pos: until !== null && until > currentPos ? until : currentPos, found: false };
|
|
};
|
|
var resync = async (reader, startPos, ids, until) => {
|
|
const CHUNK_SIZE = 2 ** 16;
|
|
const idsSet = new Set(ids);
|
|
let currentPos = startPos;
|
|
while (currentPos < until) {
|
|
let slice = reader.requestSliceRange(currentPos, 0, Math.min(CHUNK_SIZE, until - currentPos));
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) break;
|
|
if (slice.length < MAX_VAR_INT_SIZE) break;
|
|
for (let i = 0; i < slice.length - MAX_VAR_INT_SIZE; i++) {
|
|
slice.filePos = currentPos;
|
|
const elementId = readElementId(slice);
|
|
if (elementId !== null && idsSet.has(elementId)) {
|
|
return currentPos;
|
|
}
|
|
currentPos++;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
var CODEC_STRING_MAP = {
|
|
"avc": "V_MPEG4/ISO/AVC",
|
|
"hevc": "V_MPEGH/ISO/HEVC",
|
|
"vp8": "V_VP8",
|
|
"vp9": "V_VP9",
|
|
"av1": "V_AV1",
|
|
"aac": "A_AAC",
|
|
"mp3": "A_MPEG/L3",
|
|
"opus": "A_OPUS",
|
|
"vorbis": "A_VORBIS",
|
|
"flac": "A_FLAC",
|
|
"ac3": "A_AC3",
|
|
"eac3": "A_EAC3",
|
|
"pcm-u8": "A_PCM/INT/LIT",
|
|
"pcm-s16": "A_PCM/INT/LIT",
|
|
"pcm-s16be": "A_PCM/INT/BIG",
|
|
"pcm-s24": "A_PCM/INT/LIT",
|
|
"pcm-s24be": "A_PCM/INT/BIG",
|
|
"pcm-s32": "A_PCM/INT/LIT",
|
|
"pcm-s32be": "A_PCM/INT/BIG",
|
|
"pcm-f32": "A_PCM/FLOAT/IEEE",
|
|
"pcm-f64": "A_PCM/FLOAT/IEEE",
|
|
"webvtt": "S_TEXT/WEBVTT"
|
|
};
|
|
function assertDefinedSize(size) {
|
|
if (size === void 0) {
|
|
throw new Error("Undefined element size is used in a place where it is not supported.");
|
|
}
|
|
}
|
|
|
|
// src/matroska/matroska-misc.ts
|
|
var buildMatroskaMimeType = (info) => {
|
|
const base = info.hasVideo ? "video/" : info.hasAudio ? "audio/" : "application/";
|
|
let string = base + (info.isWebM ? "webm" : "x-matroska");
|
|
if (info.codecStrings.length > 0) {
|
|
const uniqueCodecMimeTypes = [...new Set(info.codecStrings.filter(Boolean))];
|
|
string += `; codecs="${uniqueCodecMimeTypes.join(", ")}"`;
|
|
}
|
|
return string;
|
|
};
|
|
|
|
// src/matroska/matroska-demuxer.ts
|
|
var METADATA_ELEMENTS = [
|
|
{ id: 290298740 /* SeekHead */, flag: "seekHeadSeen" },
|
|
{ id: 357149030 /* Info */, flag: "infoSeen" },
|
|
{ id: 374648427 /* Tracks */, flag: "tracksSeen" },
|
|
{ id: 475249515 /* Cues */, flag: "cuesSeen" }
|
|
];
|
|
var MAX_RESYNC_LENGTH = 10 * 2 ** 20;
|
|
var MatroskaDemuxer = class extends Demuxer {
|
|
constructor(input) {
|
|
super(input);
|
|
this.readMetadataPromise = null;
|
|
this.segments = [];
|
|
this.currentSegment = null;
|
|
this.currentTrack = null;
|
|
this.currentCluster = null;
|
|
this.currentBlock = null;
|
|
this.currentBlockAdditional = null;
|
|
this.currentCueTime = null;
|
|
this.currentDecodingInstruction = null;
|
|
this.currentTagTargetIsMovie = true;
|
|
this.currentSimpleTagName = null;
|
|
this.currentAttachedFile = null;
|
|
this.isWebM = false;
|
|
this.reader = input._reader;
|
|
}
|
|
async computeDuration() {
|
|
const tracks = await this.getTracks();
|
|
const trackDurations = await Promise.all(tracks.map((x) => x.computeDuration()));
|
|
return Math.max(0, ...trackDurations);
|
|
}
|
|
async getTracks() {
|
|
await this.readMetadata();
|
|
return this.segments.flatMap((segment) => segment.tracks.map((track) => track.inputTrack));
|
|
}
|
|
async getMimeType() {
|
|
await this.readMetadata();
|
|
const tracks = await this.getTracks();
|
|
const codecStrings = await Promise.all(tracks.map((x) => x.getCodecParameterString()));
|
|
return buildMatroskaMimeType({
|
|
isWebM: this.isWebM,
|
|
hasVideo: this.segments.some((segment) => segment.tracks.some((x) => x.info?.type === "video")),
|
|
hasAudio: this.segments.some((segment) => segment.tracks.some((x) => x.info?.type === "audio")),
|
|
codecStrings: codecStrings.filter(Boolean)
|
|
});
|
|
}
|
|
async getMetadataTags() {
|
|
await this.readMetadata();
|
|
for (const segment of this.segments) {
|
|
if (!segment.metadataTagsCollected) {
|
|
if (this.reader.fileSize !== null) {
|
|
await this.loadSegmentMetadata(segment);
|
|
} else {
|
|
}
|
|
segment.metadataTagsCollected = true;
|
|
}
|
|
}
|
|
let metadataTags = {};
|
|
for (const segment of this.segments) {
|
|
metadataTags = { ...metadataTags, ...segment.metadataTags };
|
|
}
|
|
return metadataTags;
|
|
}
|
|
readMetadata() {
|
|
return this.readMetadataPromise ??= (async () => {
|
|
let currentPos = 0;
|
|
while (true) {
|
|
let slice = this.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) break;
|
|
const header = readElementHeader(slice);
|
|
if (!header) {
|
|
break;
|
|
}
|
|
const id = header.id;
|
|
let size = header.size;
|
|
const dataStartPos = slice.filePos;
|
|
if (id === 440786851 /* EBML */) {
|
|
assertDefinedSize(size);
|
|
let slice2 = this.reader.requestSlice(dataStartPos, size);
|
|
if (slice2 instanceof Promise) slice2 = await slice2;
|
|
if (!slice2) break;
|
|
this.readContiguousElements(slice2);
|
|
} else if (id === 408125543 /* Segment */) {
|
|
await this.readSegment(dataStartPos, size);
|
|
if (size === void 0) {
|
|
break;
|
|
}
|
|
if (this.reader.fileSize === null) {
|
|
break;
|
|
}
|
|
} else if (id === 524531317 /* Cluster */) {
|
|
if (this.reader.fileSize === null) {
|
|
break;
|
|
}
|
|
if (size === void 0) {
|
|
const nextElementPos = await searchForNextElementId(
|
|
this.reader,
|
|
dataStartPos,
|
|
LEVEL_0_AND_1_EBML_IDS,
|
|
this.reader.fileSize
|
|
);
|
|
size = nextElementPos.pos - dataStartPos;
|
|
}
|
|
const lastSegment = last(this.segments);
|
|
if (lastSegment) {
|
|
lastSegment.elementEndPos = dataStartPos + size;
|
|
}
|
|
}
|
|
assertDefinedSize(size);
|
|
currentPos = dataStartPos + size;
|
|
}
|
|
})();
|
|
}
|
|
async readSegment(segmentDataStart, dataSize) {
|
|
this.currentSegment = {
|
|
seekHeadSeen: false,
|
|
infoSeen: false,
|
|
tracksSeen: false,
|
|
cuesSeen: false,
|
|
tagsSeen: false,
|
|
attachmentsSeen: false,
|
|
timestampScale: -1,
|
|
timestampFactor: -1,
|
|
duration: -1,
|
|
seekEntries: [],
|
|
tracks: [],
|
|
cuePoints: [],
|
|
dataStartPos: segmentDataStart,
|
|
elementEndPos: dataSize === void 0 ? null : segmentDataStart + dataSize,
|
|
clusterSeekStartPos: segmentDataStart,
|
|
lastReadCluster: null,
|
|
metadataTags: {},
|
|
metadataTagsCollected: false
|
|
};
|
|
this.segments.push(this.currentSegment);
|
|
let currentPos = segmentDataStart;
|
|
while (this.currentSegment.elementEndPos === null || currentPos < this.currentSegment.elementEndPos) {
|
|
let slice = this.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) break;
|
|
const elementStartPos = currentPos;
|
|
const header = readElementHeader(slice);
|
|
if (!header || !LEVEL_1_EBML_IDS.includes(header.id) && header.id !== 236 /* Void */) {
|
|
const nextPos = await resync(
|
|
this.reader,
|
|
elementStartPos,
|
|
LEVEL_1_EBML_IDS,
|
|
Math.min(this.currentSegment.elementEndPos ?? Infinity, elementStartPos + MAX_RESYNC_LENGTH)
|
|
);
|
|
if (nextPos) {
|
|
currentPos = nextPos;
|
|
continue;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
const { id, size } = header;
|
|
const dataStartPos = slice.filePos;
|
|
const metadataElementIndex = METADATA_ELEMENTS.findIndex((x) => x.id === id);
|
|
if (metadataElementIndex !== -1) {
|
|
const field = METADATA_ELEMENTS[metadataElementIndex].flag;
|
|
this.currentSegment[field] = true;
|
|
assertDefinedSize(size);
|
|
let slice2 = this.reader.requestSlice(dataStartPos, size);
|
|
if (slice2 instanceof Promise) slice2 = await slice2;
|
|
if (slice2) {
|
|
this.readContiguousElements(slice2);
|
|
}
|
|
} else if (id === 307544935 /* Tags */ || id === 423732329 /* Attachments */) {
|
|
if (id === 307544935 /* Tags */) {
|
|
this.currentSegment.tagsSeen = true;
|
|
} else {
|
|
this.currentSegment.attachmentsSeen = true;
|
|
}
|
|
assertDefinedSize(size);
|
|
let slice2 = this.reader.requestSlice(dataStartPos, size);
|
|
if (slice2 instanceof Promise) slice2 = await slice2;
|
|
if (slice2) {
|
|
this.readContiguousElements(slice2);
|
|
}
|
|
} else if (id === 524531317 /* Cluster */) {
|
|
this.currentSegment.clusterSeekStartPos = elementStartPos;
|
|
break;
|
|
}
|
|
if (size === void 0) {
|
|
break;
|
|
} else {
|
|
currentPos = dataStartPos + size;
|
|
}
|
|
}
|
|
this.currentSegment.seekEntries.sort((a, b) => a.segmentPosition - b.segmentPosition);
|
|
if (this.reader.fileSize !== null) {
|
|
for (const seekEntry of this.currentSegment.seekEntries) {
|
|
const target = METADATA_ELEMENTS.find((x) => x.id === seekEntry.id);
|
|
if (!target) {
|
|
continue;
|
|
}
|
|
if (this.currentSegment[target.flag]) continue;
|
|
let slice = this.reader.requestSliceRange(
|
|
segmentDataStart + seekEntry.segmentPosition,
|
|
MIN_HEADER_SIZE,
|
|
MAX_HEADER_SIZE
|
|
);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) continue;
|
|
const header = readElementHeader(slice);
|
|
if (!header) continue;
|
|
const { id, size } = header;
|
|
if (id !== target.id) continue;
|
|
assertDefinedSize(size);
|
|
this.currentSegment[target.flag] = true;
|
|
let dataSlice = this.reader.requestSlice(slice.filePos, size);
|
|
if (dataSlice instanceof Promise) dataSlice = await dataSlice;
|
|
if (!dataSlice) continue;
|
|
this.readContiguousElements(dataSlice);
|
|
}
|
|
}
|
|
if (this.currentSegment.timestampScale === -1) {
|
|
this.currentSegment.timestampScale = 1e6;
|
|
this.currentSegment.timestampFactor = 1e9 / 1e6;
|
|
}
|
|
for (const track of this.currentSegment.tracks) {
|
|
if (track.defaultDurationNs !== null) {
|
|
track.defaultDuration = this.currentSegment.timestampFactor * track.defaultDurationNs / 1e9;
|
|
}
|
|
}
|
|
this.currentSegment.tracks.sort((a, b) => Number(b.disposition.default) - Number(a.disposition.default));
|
|
const idToTrack = new Map(this.currentSegment.tracks.map((x) => [x.id, x]));
|
|
for (const cuePoint of this.currentSegment.cuePoints) {
|
|
const track = idToTrack.get(cuePoint.trackId);
|
|
if (track) {
|
|
track.cuePoints.push(cuePoint);
|
|
}
|
|
}
|
|
for (const track of this.currentSegment.tracks) {
|
|
track.cuePoints.sort((a, b) => a.time - b.time);
|
|
for (let i = 0; i < track.cuePoints.length - 1; i++) {
|
|
const cuePoint1 = track.cuePoints[i];
|
|
const cuePoint2 = track.cuePoints[i + 1];
|
|
if (cuePoint1.time === cuePoint2.time) {
|
|
track.cuePoints.splice(i + 1, 1);
|
|
i--;
|
|
}
|
|
}
|
|
}
|
|
let trackWithMostCuePoints = null;
|
|
let maxCuePointCount = -Infinity;
|
|
for (const track of this.currentSegment.tracks) {
|
|
if (track.cuePoints.length > maxCuePointCount) {
|
|
maxCuePointCount = track.cuePoints.length;
|
|
trackWithMostCuePoints = track;
|
|
}
|
|
}
|
|
for (const track of this.currentSegment.tracks) {
|
|
if (track.cuePoints.length === 0) {
|
|
track.cuePoints = trackWithMostCuePoints.cuePoints;
|
|
}
|
|
}
|
|
this.currentSegment = null;
|
|
}
|
|
async readCluster(startPos, segment) {
|
|
if (segment.lastReadCluster?.elementStartPos === startPos) {
|
|
return segment.lastReadCluster;
|
|
}
|
|
let headerSlice = this.reader.requestSliceRange(startPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
|
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
|
|
assert(headerSlice);
|
|
const elementStartPos = startPos;
|
|
const elementHeader = readElementHeader(headerSlice);
|
|
assert(elementHeader);
|
|
const id = elementHeader.id;
|
|
assert(id === 524531317 /* Cluster */);
|
|
let size = elementHeader.size;
|
|
const dataStartPos = headerSlice.filePos;
|
|
if (size === void 0) {
|
|
const nextElementPos = await searchForNextElementId(
|
|
this.reader,
|
|
dataStartPos,
|
|
LEVEL_0_AND_1_EBML_IDS,
|
|
segment.elementEndPos
|
|
);
|
|
size = nextElementPos.pos - dataStartPos;
|
|
}
|
|
let dataSlice = this.reader.requestSlice(dataStartPos, size);
|
|
if (dataSlice instanceof Promise) dataSlice = await dataSlice;
|
|
const cluster = {
|
|
segment,
|
|
elementStartPos,
|
|
elementEndPos: dataStartPos + size,
|
|
dataStartPos,
|
|
timestamp: -1,
|
|
trackData: /* @__PURE__ */ new Map()
|
|
};
|
|
this.currentCluster = cluster;
|
|
if (dataSlice) {
|
|
const endPos = this.readContiguousElements(dataSlice, LEVEL_0_AND_1_EBML_IDS);
|
|
cluster.elementEndPos = endPos;
|
|
}
|
|
for (const [, trackData] of cluster.trackData) {
|
|
const track = trackData.track;
|
|
assert(trackData.blocks.length > 0);
|
|
let hasLacedBlocks = false;
|
|
for (let i = 0; i < trackData.blocks.length; i++) {
|
|
const block = trackData.blocks[i];
|
|
block.timestamp += cluster.timestamp;
|
|
hasLacedBlocks ||= block.lacing !== 0 /* None */;
|
|
}
|
|
trackData.presentationTimestamps = trackData.blocks.map((block, i) => ({ timestamp: block.timestamp, blockIndex: i })).sort((a, b) => a.timestamp - b.timestamp);
|
|
for (let i = 0; i < trackData.presentationTimestamps.length; i++) {
|
|
const currentEntry = trackData.presentationTimestamps[i];
|
|
const currentBlock = trackData.blocks[currentEntry.blockIndex];
|
|
if (trackData.firstKeyFrameTimestamp === null && currentBlock.isKeyFrame) {
|
|
trackData.firstKeyFrameTimestamp = currentBlock.timestamp;
|
|
}
|
|
if (i < trackData.presentationTimestamps.length - 1) {
|
|
const nextEntry = trackData.presentationTimestamps[i + 1];
|
|
currentBlock.duration = nextEntry.timestamp - currentBlock.timestamp;
|
|
} else if (currentBlock.duration === 0) {
|
|
if (track.defaultDuration != null) {
|
|
if (currentBlock.lacing === 0 /* None */) {
|
|
currentBlock.duration = track.defaultDuration;
|
|
} else {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (hasLacedBlocks) {
|
|
this.expandLacedBlocks(trackData.blocks, track);
|
|
trackData.presentationTimestamps = trackData.blocks.map((block, i) => ({ timestamp: block.timestamp, blockIndex: i })).sort((a, b) => a.timestamp - b.timestamp);
|
|
}
|
|
const firstBlock = trackData.blocks[trackData.presentationTimestamps[0].blockIndex];
|
|
const lastBlock = trackData.blocks[last(trackData.presentationTimestamps).blockIndex];
|
|
trackData.startTimestamp = firstBlock.timestamp;
|
|
trackData.endTimestamp = lastBlock.timestamp + lastBlock.duration;
|
|
const insertionIndex = binarySearchLessOrEqual(
|
|
track.clusterPositionCache,
|
|
trackData.startTimestamp,
|
|
(x) => x.startTimestamp
|
|
);
|
|
if (insertionIndex === -1 || track.clusterPositionCache[insertionIndex].elementStartPos !== elementStartPos) {
|
|
track.clusterPositionCache.splice(insertionIndex + 1, 0, {
|
|
elementStartPos: cluster.elementStartPos,
|
|
startTimestamp: trackData.startTimestamp
|
|
});
|
|
}
|
|
}
|
|
segment.lastReadCluster = cluster;
|
|
return cluster;
|
|
}
|
|
getTrackDataInCluster(cluster, trackNumber) {
|
|
let trackData = cluster.trackData.get(trackNumber);
|
|
if (!trackData) {
|
|
const track = cluster.segment.tracks.find((x) => x.id === trackNumber);
|
|
if (!track) {
|
|
return null;
|
|
}
|
|
trackData = {
|
|
track,
|
|
startTimestamp: 0,
|
|
endTimestamp: 0,
|
|
firstKeyFrameTimestamp: null,
|
|
blocks: [],
|
|
presentationTimestamps: []
|
|
};
|
|
cluster.trackData.set(trackNumber, trackData);
|
|
}
|
|
return trackData;
|
|
}
|
|
expandLacedBlocks(blocks, track) {
|
|
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
|
|
const originalBlock = blocks[blockIndex];
|
|
if (originalBlock.lacing === 0 /* None */) {
|
|
continue;
|
|
}
|
|
if (!originalBlock.decoded) {
|
|
originalBlock.data = this.decodeBlockData(track, originalBlock.data);
|
|
originalBlock.decoded = true;
|
|
}
|
|
const slice = FileSlice4.tempFromBytes(originalBlock.data);
|
|
const frameSizes = [];
|
|
const frameCount = readU8(slice) + 1;
|
|
switch (originalBlock.lacing) {
|
|
case 1 /* Xiph */:
|
|
{
|
|
let totalUsedSize = 0;
|
|
for (let i = 0; i < frameCount - 1; i++) {
|
|
let frameSize = 0;
|
|
while (slice.bufferPos < slice.length) {
|
|
const value = readU8(slice);
|
|
frameSize += value;
|
|
if (value < 255) {
|
|
frameSizes.push(frameSize);
|
|
totalUsedSize += frameSize;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
frameSizes.push(slice.length - (slice.bufferPos + totalUsedSize));
|
|
}
|
|
;
|
|
break;
|
|
case 2 /* FixedSize */:
|
|
{
|
|
const totalDataSize = slice.length - 1;
|
|
const frameSize = Math.floor(totalDataSize / frameCount);
|
|
for (let i = 0; i < frameCount; i++) {
|
|
frameSizes.push(frameSize);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 3 /* Ebml */:
|
|
{
|
|
const firstResult = readVarInt(slice);
|
|
assert(firstResult !== null);
|
|
let currentSize = firstResult;
|
|
frameSizes.push(currentSize);
|
|
let totalUsedSize = currentSize;
|
|
for (let i = 1; i < frameCount - 1; i++) {
|
|
const startPos = slice.bufferPos;
|
|
const diffResult = readVarInt(slice);
|
|
assert(diffResult !== null);
|
|
const unsignedDiff = diffResult;
|
|
const width = slice.bufferPos - startPos;
|
|
const bias = (1 << width * 7 - 1) - 1;
|
|
const diff = unsignedDiff - bias;
|
|
currentSize += diff;
|
|
frameSizes.push(currentSize);
|
|
totalUsedSize += currentSize;
|
|
}
|
|
frameSizes.push(slice.length - (slice.bufferPos + totalUsedSize));
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
assert(false);
|
|
}
|
|
assert(frameSizes.length === frameCount);
|
|
blocks.splice(blockIndex, 1);
|
|
const blockDuration = originalBlock.duration || frameCount * (track.defaultDuration ?? 0);
|
|
for (let i = 0; i < frameCount; i++) {
|
|
const frameSize = frameSizes[i];
|
|
const frameData = readBytes(slice, frameSize);
|
|
const frameTimestamp = originalBlock.timestamp + blockDuration * i / frameCount;
|
|
const frameDuration = blockDuration / frameCount;
|
|
blocks.splice(blockIndex + i, 0, {
|
|
timestamp: frameTimestamp,
|
|
duration: frameDuration,
|
|
isKeyFrame: originalBlock.isKeyFrame,
|
|
data: frameData,
|
|
lacing: 0 /* None */,
|
|
decoded: true,
|
|
mainAdditional: originalBlock.mainAdditional
|
|
});
|
|
}
|
|
blockIndex += frameCount;
|
|
blockIndex--;
|
|
}
|
|
}
|
|
async loadSegmentMetadata(segment) {
|
|
for (const seekEntry of segment.seekEntries) {
|
|
if (seekEntry.id === 307544935 /* Tags */ && !segment.tagsSeen) {
|
|
} else if (seekEntry.id === 423732329 /* Attachments */ && !segment.attachmentsSeen) {
|
|
} else {
|
|
continue;
|
|
}
|
|
let slice = this.reader.requestSliceRange(
|
|
segment.dataStartPos + seekEntry.segmentPosition,
|
|
MIN_HEADER_SIZE,
|
|
MAX_HEADER_SIZE
|
|
);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) continue;
|
|
const header = readElementHeader(slice);
|
|
if (!header || header.id !== seekEntry.id) continue;
|
|
const { size } = header;
|
|
assertDefinedSize(size);
|
|
assert(!this.currentSegment);
|
|
this.currentSegment = segment;
|
|
let dataSlice = this.reader.requestSlice(slice.filePos, size);
|
|
if (dataSlice instanceof Promise) dataSlice = await dataSlice;
|
|
if (dataSlice) {
|
|
this.readContiguousElements(dataSlice);
|
|
}
|
|
this.currentSegment = null;
|
|
if (seekEntry.id === 307544935 /* Tags */) {
|
|
segment.tagsSeen = true;
|
|
} else if (seekEntry.id === 423732329 /* Attachments */) {
|
|
segment.attachmentsSeen = true;
|
|
}
|
|
}
|
|
}
|
|
readContiguousElements(slice, stopIds) {
|
|
while (slice.remainingLength >= MIN_HEADER_SIZE) {
|
|
const startPos = slice.filePos;
|
|
const foundElement = this.traverseElement(slice, stopIds);
|
|
if (!foundElement) {
|
|
return startPos;
|
|
}
|
|
}
|
|
return slice.filePos;
|
|
}
|
|
traverseElement(slice, stopIds) {
|
|
const header = readElementHeader(slice);
|
|
if (!header) {
|
|
return false;
|
|
}
|
|
if (stopIds && stopIds.includes(header.id)) {
|
|
return false;
|
|
}
|
|
const { id, size } = header;
|
|
const dataStartPos = slice.filePos;
|
|
assertDefinedSize(size);
|
|
switch (id) {
|
|
case 17026 /* DocType */:
|
|
{
|
|
this.isWebM = readAsciiString(slice, size) === "webm";
|
|
}
|
|
;
|
|
break;
|
|
case 19899 /* Seek */:
|
|
{
|
|
if (!this.currentSegment) break;
|
|
const seekEntry = { id: -1, segmentPosition: -1 };
|
|
this.currentSegment.seekEntries.push(seekEntry);
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
if (seekEntry.id === -1 || seekEntry.segmentPosition === -1) {
|
|
this.currentSegment.seekEntries.pop();
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 21419 /* SeekID */:
|
|
{
|
|
const lastSeekEntry = this.currentSegment?.seekEntries[this.currentSegment.seekEntries.length - 1];
|
|
if (!lastSeekEntry) break;
|
|
lastSeekEntry.id = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 21420 /* SeekPosition */:
|
|
{
|
|
const lastSeekEntry = this.currentSegment?.seekEntries[this.currentSegment.seekEntries.length - 1];
|
|
if (!lastSeekEntry) break;
|
|
lastSeekEntry.segmentPosition = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 2807729 /* TimestampScale */:
|
|
{
|
|
if (!this.currentSegment) break;
|
|
this.currentSegment.timestampScale = readUnsignedInt(slice, size);
|
|
this.currentSegment.timestampFactor = 1e9 / this.currentSegment.timestampScale;
|
|
}
|
|
;
|
|
break;
|
|
case 17545 /* Duration */:
|
|
{
|
|
if (!this.currentSegment) break;
|
|
this.currentSegment.duration = readFloat(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 174 /* TrackEntry */:
|
|
{
|
|
if (!this.currentSegment) break;
|
|
this.currentTrack = {
|
|
id: -1,
|
|
segment: this.currentSegment,
|
|
demuxer: this,
|
|
clusterPositionCache: [],
|
|
cuePoints: [],
|
|
disposition: {
|
|
...DEFAULT_TRACK_DISPOSITION
|
|
},
|
|
inputTrack: null,
|
|
codecId: null,
|
|
codecPrivate: null,
|
|
defaultDuration: null,
|
|
defaultDurationNs: null,
|
|
name: null,
|
|
languageCode: UNDETERMINED_LANGUAGE,
|
|
decodingInstructions: [],
|
|
info: null
|
|
};
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
if (!this.currentTrack) {
|
|
break;
|
|
}
|
|
if (this.currentTrack.decodingInstructions.some((instruction) => {
|
|
return instruction.data?.type !== "decompress" || instruction.scope !== 1 /* Block */ || instruction.data.algorithm !== 3 /* HeaderStripping */;
|
|
})) {
|
|
console.warn(`Track #${this.currentTrack.id} has an unsupported content encoding; dropping.`);
|
|
this.currentTrack = null;
|
|
}
|
|
if (this.currentTrack && this.currentTrack.id !== -1 && this.currentTrack.codecId && this.currentTrack.info) {
|
|
const slashIndex = this.currentTrack.codecId.indexOf("/");
|
|
const codecIdWithoutSuffix = slashIndex === -1 ? this.currentTrack.codecId : this.currentTrack.codecId.slice(0, slashIndex);
|
|
if (this.currentTrack.info.type === "video" && this.currentTrack.info.width !== -1 && this.currentTrack.info.height !== -1) {
|
|
if (this.currentTrack.codecId === CODEC_STRING_MAP.avc) {
|
|
this.currentTrack.info.codec = "avc";
|
|
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
|
|
} else if (this.currentTrack.codecId === CODEC_STRING_MAP.hevc) {
|
|
this.currentTrack.info.codec = "hevc";
|
|
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
|
|
} else if (codecIdWithoutSuffix === CODEC_STRING_MAP.vp8) {
|
|
this.currentTrack.info.codec = "vp8";
|
|
} else if (codecIdWithoutSuffix === CODEC_STRING_MAP.vp9) {
|
|
this.currentTrack.info.codec = "vp9";
|
|
} else if (codecIdWithoutSuffix === CODEC_STRING_MAP.av1) {
|
|
this.currentTrack.info.codec = "av1";
|
|
}
|
|
const videoTrack = this.currentTrack;
|
|
const inputTrack = new InputVideoTrack(this.input, new MatroskaVideoTrackBacking(videoTrack));
|
|
this.currentTrack.inputTrack = inputTrack;
|
|
this.currentSegment.tracks.push(this.currentTrack);
|
|
} else if (this.currentTrack.info.type === "audio" && this.currentTrack.info.numberOfChannels !== -1 && this.currentTrack.info.sampleRate !== -1) {
|
|
if (codecIdWithoutSuffix === CODEC_STRING_MAP.aac) {
|
|
this.currentTrack.info.codec = "aac";
|
|
this.currentTrack.info.aacCodecInfo = {
|
|
isMpeg2: this.currentTrack.codecId.includes("MPEG2"),
|
|
objectType: null
|
|
};
|
|
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
|
|
} else if (this.currentTrack.codecId === CODEC_STRING_MAP.mp3) {
|
|
this.currentTrack.info.codec = "mp3";
|
|
} else if (codecIdWithoutSuffix === CODEC_STRING_MAP.opus) {
|
|
this.currentTrack.info.codec = "opus";
|
|
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
|
|
this.currentTrack.info.sampleRate = OPUS_SAMPLE_RATE;
|
|
} else if (codecIdWithoutSuffix === CODEC_STRING_MAP.vorbis) {
|
|
this.currentTrack.info.codec = "vorbis";
|
|
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
|
|
} else if (codecIdWithoutSuffix === CODEC_STRING_MAP.flac) {
|
|
this.currentTrack.info.codec = "flac";
|
|
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
|
|
} else if (codecIdWithoutSuffix === CODEC_STRING_MAP.ac3) {
|
|
this.currentTrack.info.codec = "ac3";
|
|
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
|
|
} else if (codecIdWithoutSuffix === CODEC_STRING_MAP.eac3) {
|
|
this.currentTrack.info.codec = "eac3";
|
|
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
|
|
} else if (this.currentTrack.codecId === "A_PCM/INT/LIT") {
|
|
if (this.currentTrack.info.bitDepth === 8) {
|
|
this.currentTrack.info.codec = "pcm-u8";
|
|
} else if (this.currentTrack.info.bitDepth === 16) {
|
|
this.currentTrack.info.codec = "pcm-s16";
|
|
} else if (this.currentTrack.info.bitDepth === 24) {
|
|
this.currentTrack.info.codec = "pcm-s24";
|
|
} else if (this.currentTrack.info.bitDepth === 32) {
|
|
this.currentTrack.info.codec = "pcm-s32";
|
|
}
|
|
} else if (this.currentTrack.codecId === "A_PCM/INT/BIG") {
|
|
if (this.currentTrack.info.bitDepth === 8) {
|
|
this.currentTrack.info.codec = "pcm-u8";
|
|
} else if (this.currentTrack.info.bitDepth === 16) {
|
|
this.currentTrack.info.codec = "pcm-s16be";
|
|
} else if (this.currentTrack.info.bitDepth === 24) {
|
|
this.currentTrack.info.codec = "pcm-s24be";
|
|
} else if (this.currentTrack.info.bitDepth === 32) {
|
|
this.currentTrack.info.codec = "pcm-s32be";
|
|
}
|
|
} else if (this.currentTrack.codecId === "A_PCM/FLOAT/IEEE") {
|
|
if (this.currentTrack.info.bitDepth === 32) {
|
|
this.currentTrack.info.codec = "pcm-f32";
|
|
} else if (this.currentTrack.info.bitDepth === 64) {
|
|
this.currentTrack.info.codec = "pcm-f64";
|
|
}
|
|
}
|
|
const audioTrack = this.currentTrack;
|
|
const inputTrack = new InputAudioTrack(this.input, new MatroskaAudioTrackBacking(audioTrack));
|
|
this.currentTrack.inputTrack = inputTrack;
|
|
this.currentSegment.tracks.push(this.currentTrack);
|
|
}
|
|
}
|
|
this.currentTrack = null;
|
|
}
|
|
;
|
|
break;
|
|
case 215 /* TrackNumber */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
this.currentTrack.id = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 131 /* TrackType */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
const type = readUnsignedInt(slice, size);
|
|
if (type === 1) {
|
|
this.currentTrack.info = {
|
|
type: "video",
|
|
width: -1,
|
|
height: -1,
|
|
rotation: 0,
|
|
codec: null,
|
|
codecDescription: null,
|
|
colorSpace: null,
|
|
alphaMode: false
|
|
};
|
|
} else if (type === 2) {
|
|
this.currentTrack.info = {
|
|
type: "audio",
|
|
numberOfChannels: -1,
|
|
sampleRate: -1,
|
|
bitDepth: -1,
|
|
codec: null,
|
|
codecDescription: null,
|
|
aacCodecInfo: null
|
|
};
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 185 /* FlagEnabled */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
const enabled = readUnsignedInt(slice, size);
|
|
if (!enabled) {
|
|
this.currentTrack = null;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 136 /* FlagDefault */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
this.currentTrack.disposition.default = !!readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 21930 /* FlagForced */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
this.currentTrack.disposition.forced = !!readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 21934 /* FlagOriginal */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
this.currentTrack.disposition.original = !!readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 21931 /* FlagHearingImpaired */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
this.currentTrack.disposition.hearingImpaired = !!readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 21932 /* FlagVisualImpaired */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
this.currentTrack.disposition.visuallyImpaired = !!readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 21935 /* FlagCommentary */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
this.currentTrack.disposition.commentary = !!readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 134 /* CodecID */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
this.currentTrack.codecId = readAsciiString(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 25506 /* CodecPrivate */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
this.currentTrack.codecPrivate = readBytes(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 2352003 /* DefaultDuration */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
this.currentTrack.defaultDurationNs = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 21358 /* Name */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
this.currentTrack.name = readUnicodeString(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 2274716 /* Language */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
if (this.currentTrack.languageCode !== UNDETERMINED_LANGUAGE) {
|
|
break;
|
|
}
|
|
this.currentTrack.languageCode = readAsciiString(slice, size);
|
|
if (!isIso639Dash2LanguageCode(this.currentTrack.languageCode)) {
|
|
this.currentTrack.languageCode = UNDETERMINED_LANGUAGE;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 2274717 /* LanguageBCP47 */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
const bcp47 = readAsciiString(slice, size);
|
|
const languageSubtag = bcp47.split("-")[0];
|
|
if (languageSubtag) {
|
|
this.currentTrack.languageCode = languageSubtag;
|
|
} else {
|
|
this.currentTrack.languageCode = UNDETERMINED_LANGUAGE;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 224 /* Video */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "video") break;
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
}
|
|
;
|
|
break;
|
|
case 176 /* PixelWidth */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "video") break;
|
|
this.currentTrack.info.width = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 186 /* PixelHeight */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "video") break;
|
|
this.currentTrack.info.height = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 21440 /* AlphaMode */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "video") break;
|
|
this.currentTrack.info.alphaMode = readUnsignedInt(slice, size) === 1;
|
|
}
|
|
;
|
|
break;
|
|
case 21936 /* Colour */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "video") break;
|
|
this.currentTrack.info.colorSpace = {};
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
}
|
|
;
|
|
break;
|
|
case 21937 /* MatrixCoefficients */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "video" || !this.currentTrack.info.colorSpace) break;
|
|
const matrixCoefficients = readUnsignedInt(slice, size);
|
|
const mapped = MATRIX_COEFFICIENTS_MAP_INVERSE[matrixCoefficients] ?? null;
|
|
this.currentTrack.info.colorSpace.matrix = mapped;
|
|
}
|
|
;
|
|
break;
|
|
case 21945 /* Range */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "video" || !this.currentTrack.info.colorSpace) break;
|
|
this.currentTrack.info.colorSpace.fullRange = readUnsignedInt(slice, size) === 2;
|
|
}
|
|
;
|
|
break;
|
|
case 21946 /* TransferCharacteristics */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "video" || !this.currentTrack.info.colorSpace) break;
|
|
const transferCharacteristics = readUnsignedInt(slice, size);
|
|
const mapped = TRANSFER_CHARACTERISTICS_MAP_INVERSE[transferCharacteristics] ?? null;
|
|
this.currentTrack.info.colorSpace.transfer = mapped;
|
|
}
|
|
;
|
|
break;
|
|
case 21947 /* Primaries */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "video" || !this.currentTrack.info.colorSpace) break;
|
|
const primaries = readUnsignedInt(slice, size);
|
|
const mapped = COLOR_PRIMARIES_MAP_INVERSE[primaries] ?? null;
|
|
this.currentTrack.info.colorSpace.primaries = mapped;
|
|
}
|
|
;
|
|
break;
|
|
case 30320 /* Projection */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "video") break;
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
}
|
|
;
|
|
break;
|
|
case 30325 /* ProjectionPoseRoll */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "video") break;
|
|
const rotation = readFloat(slice, size);
|
|
const flippedRotation = -rotation;
|
|
try {
|
|
this.currentTrack.info.rotation = normalizeRotation(flippedRotation);
|
|
} catch {
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 225 /* Audio */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "audio") break;
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
}
|
|
;
|
|
break;
|
|
case 181 /* SamplingFrequency */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "audio") break;
|
|
this.currentTrack.info.sampleRate = readFloat(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 159 /* Channels */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "audio") break;
|
|
this.currentTrack.info.numberOfChannels = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 25188 /* BitDepth */:
|
|
{
|
|
if (this.currentTrack?.info?.type !== "audio") break;
|
|
this.currentTrack.info.bitDepth = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 187 /* CuePoint */:
|
|
{
|
|
if (!this.currentSegment) break;
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
this.currentCueTime = null;
|
|
}
|
|
;
|
|
break;
|
|
case 179 /* CueTime */:
|
|
{
|
|
this.currentCueTime = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 183 /* CueTrackPositions */:
|
|
{
|
|
if (this.currentCueTime === null) break;
|
|
assert(this.currentSegment);
|
|
const cuePoint = { time: this.currentCueTime, trackId: -1, clusterPosition: -1 };
|
|
this.currentSegment.cuePoints.push(cuePoint);
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
if (cuePoint.trackId === -1 || cuePoint.clusterPosition === -1) {
|
|
this.currentSegment.cuePoints.pop();
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 247 /* CueTrack */:
|
|
{
|
|
const lastCuePoint = this.currentSegment?.cuePoints[this.currentSegment.cuePoints.length - 1];
|
|
if (!lastCuePoint) break;
|
|
lastCuePoint.trackId = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 241 /* CueClusterPosition */:
|
|
{
|
|
const lastCuePoint = this.currentSegment?.cuePoints[this.currentSegment.cuePoints.length - 1];
|
|
if (!lastCuePoint) break;
|
|
assert(this.currentSegment);
|
|
lastCuePoint.clusterPosition = this.currentSegment.dataStartPos + readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 231 /* Timestamp */:
|
|
{
|
|
if (!this.currentCluster) break;
|
|
this.currentCluster.timestamp = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 163 /* SimpleBlock */:
|
|
{
|
|
if (!this.currentCluster) break;
|
|
const trackNumber = readVarInt(slice);
|
|
if (trackNumber === null) break;
|
|
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
|
|
if (!trackData) break;
|
|
const relativeTimestamp = readI16Be(slice);
|
|
const flags = readU8(slice);
|
|
const lacing = flags >> 1 & 3;
|
|
let isKeyFrame = !!(flags & 128);
|
|
if (trackData.track.info?.type === "audio" && trackData.track.info.codec) {
|
|
isKeyFrame = true;
|
|
}
|
|
const blockData = readBytes(slice, size - (slice.filePos - dataStartPos));
|
|
const hasDecodingInstructions = trackData.track.decodingInstructions.length > 0;
|
|
trackData.blocks.push({
|
|
timestamp: relativeTimestamp,
|
|
// We'll add the cluster's timestamp to this later
|
|
duration: 0,
|
|
// Will set later
|
|
isKeyFrame,
|
|
data: blockData,
|
|
lacing,
|
|
decoded: !hasDecodingInstructions,
|
|
mainAdditional: null
|
|
});
|
|
}
|
|
;
|
|
break;
|
|
case 160 /* BlockGroup */:
|
|
{
|
|
if (!this.currentCluster) break;
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
this.currentBlock = null;
|
|
}
|
|
;
|
|
break;
|
|
case 161 /* Block */:
|
|
{
|
|
if (!this.currentCluster) break;
|
|
const trackNumber = readVarInt(slice);
|
|
if (trackNumber === null) break;
|
|
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
|
|
if (!trackData) break;
|
|
const relativeTimestamp = readI16Be(slice);
|
|
const flags = readU8(slice);
|
|
const lacing = flags >> 1 & 3;
|
|
const blockData = readBytes(slice, size - (slice.filePos - dataStartPos));
|
|
const hasDecodingInstructions = trackData.track.decodingInstructions.length > 0;
|
|
this.currentBlock = {
|
|
timestamp: relativeTimestamp,
|
|
// We'll add the cluster's timestamp to this later
|
|
duration: 0,
|
|
// Will set later
|
|
isKeyFrame: true,
|
|
data: blockData,
|
|
lacing,
|
|
decoded: !hasDecodingInstructions,
|
|
mainAdditional: null
|
|
};
|
|
trackData.blocks.push(this.currentBlock);
|
|
}
|
|
;
|
|
break;
|
|
case 30113 /* BlockAdditions */:
|
|
{
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
}
|
|
;
|
|
break;
|
|
case 166 /* BlockMore */:
|
|
{
|
|
if (!this.currentBlock) break;
|
|
this.currentBlockAdditional = {
|
|
addId: 1,
|
|
data: null
|
|
};
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
if (this.currentBlockAdditional.data && this.currentBlockAdditional.addId === 1) {
|
|
this.currentBlock.mainAdditional = this.currentBlockAdditional.data;
|
|
}
|
|
this.currentBlockAdditional = null;
|
|
}
|
|
;
|
|
break;
|
|
case 165 /* BlockAdditional */:
|
|
{
|
|
if (!this.currentBlockAdditional) break;
|
|
this.currentBlockAdditional.data = readBytes(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 238 /* BlockAddID */:
|
|
{
|
|
if (!this.currentBlockAdditional) break;
|
|
this.currentBlockAdditional.addId = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 155 /* BlockDuration */:
|
|
{
|
|
if (!this.currentBlock) break;
|
|
this.currentBlock.duration = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 251 /* ReferenceBlock */:
|
|
{
|
|
if (!this.currentBlock) break;
|
|
this.currentBlock.isKeyFrame = false;
|
|
}
|
|
;
|
|
break;
|
|
case 29555 /* Tag */:
|
|
{
|
|
this.currentTagTargetIsMovie = true;
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
}
|
|
;
|
|
break;
|
|
case 25536 /* Targets */:
|
|
{
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
}
|
|
;
|
|
break;
|
|
case 26826 /* TargetTypeValue */:
|
|
{
|
|
const targetTypeValue = readUnsignedInt(slice, size);
|
|
if (targetTypeValue !== 50) {
|
|
this.currentTagTargetIsMovie = false;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 25541 /* TagTrackUID */:
|
|
case 25545 /* TagEditionUID */:
|
|
case 25540 /* TagChapterUID */:
|
|
case 25542 /* TagAttachmentUID */:
|
|
{
|
|
this.currentTagTargetIsMovie = false;
|
|
}
|
|
;
|
|
break;
|
|
case 26568 /* SimpleTag */:
|
|
{
|
|
if (!this.currentTagTargetIsMovie) break;
|
|
this.currentSimpleTagName = null;
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
}
|
|
;
|
|
break;
|
|
case 17827 /* TagName */:
|
|
{
|
|
this.currentSimpleTagName = readUnicodeString(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 17543 /* TagString */:
|
|
{
|
|
if (!this.currentSimpleTagName) break;
|
|
const value = readUnicodeString(slice, size);
|
|
this.processTagValue(this.currentSimpleTagName, value);
|
|
}
|
|
;
|
|
break;
|
|
case 17541 /* TagBinary */:
|
|
{
|
|
if (!this.currentSimpleTagName) break;
|
|
const value = readBytes(slice, size);
|
|
this.processTagValue(this.currentSimpleTagName, value);
|
|
}
|
|
;
|
|
break;
|
|
case 24999 /* AttachedFile */:
|
|
{
|
|
if (!this.currentSegment) break;
|
|
this.currentAttachedFile = {
|
|
fileUid: null,
|
|
fileName: null,
|
|
fileMediaType: null,
|
|
fileData: null,
|
|
fileDescription: null
|
|
};
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
const tags = this.currentSegment.metadataTags;
|
|
if (this.currentAttachedFile.fileUid && this.currentAttachedFile.fileData) {
|
|
tags.raw ??= {};
|
|
tags.raw[this.currentAttachedFile.fileUid.toString()] = new AttachedFile(
|
|
this.currentAttachedFile.fileData,
|
|
this.currentAttachedFile.fileMediaType ?? void 0,
|
|
this.currentAttachedFile.fileName ?? void 0,
|
|
this.currentAttachedFile.fileDescription ?? void 0
|
|
);
|
|
}
|
|
if (this.currentAttachedFile.fileMediaType?.startsWith("image/") && this.currentAttachedFile.fileData) {
|
|
const fileName = this.currentAttachedFile.fileName;
|
|
let kind = "unknown";
|
|
if (fileName) {
|
|
const lowerName = fileName.toLowerCase();
|
|
if (lowerName.startsWith("cover.")) {
|
|
kind = "coverFront";
|
|
} else if (lowerName.startsWith("back.")) {
|
|
kind = "coverBack";
|
|
}
|
|
}
|
|
tags.images ??= [];
|
|
tags.images.push({
|
|
data: this.currentAttachedFile.fileData,
|
|
mimeType: this.currentAttachedFile.fileMediaType,
|
|
kind,
|
|
name: this.currentAttachedFile.fileName ?? void 0,
|
|
description: this.currentAttachedFile.fileDescription ?? void 0
|
|
});
|
|
}
|
|
this.currentAttachedFile = null;
|
|
}
|
|
;
|
|
break;
|
|
case 18094 /* FileUID */:
|
|
{
|
|
if (!this.currentAttachedFile) break;
|
|
this.currentAttachedFile.fileUid = readUnsignedBigInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 18030 /* FileName */:
|
|
{
|
|
if (!this.currentAttachedFile) break;
|
|
this.currentAttachedFile.fileName = readUnicodeString(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 18016 /* FileMediaType */:
|
|
{
|
|
if (!this.currentAttachedFile) break;
|
|
this.currentAttachedFile.fileMediaType = readAsciiString(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 18012 /* FileData */:
|
|
{
|
|
if (!this.currentAttachedFile) break;
|
|
this.currentAttachedFile.fileData = readBytes(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 18046 /* FileDescription */:
|
|
{
|
|
if (!this.currentAttachedFile) break;
|
|
this.currentAttachedFile.fileDescription = readUnicodeString(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 28032 /* ContentEncodings */:
|
|
{
|
|
if (!this.currentTrack) break;
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
this.currentTrack.decodingInstructions.sort((a, b) => b.order - a.order);
|
|
}
|
|
;
|
|
break;
|
|
case 25152 /* ContentEncoding */:
|
|
{
|
|
this.currentDecodingInstruction = {
|
|
order: 0,
|
|
scope: 1 /* Block */,
|
|
data: null
|
|
};
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
if (this.currentDecodingInstruction.data) {
|
|
this.currentTrack.decodingInstructions.push(this.currentDecodingInstruction);
|
|
}
|
|
this.currentDecodingInstruction = null;
|
|
}
|
|
;
|
|
break;
|
|
case 20529 /* ContentEncodingOrder */:
|
|
{
|
|
if (!this.currentDecodingInstruction) break;
|
|
this.currentDecodingInstruction.order = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 20530 /* ContentEncodingScope */:
|
|
{
|
|
if (!this.currentDecodingInstruction) break;
|
|
this.currentDecodingInstruction.scope = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 20532 /* ContentCompression */:
|
|
{
|
|
if (!this.currentDecodingInstruction) break;
|
|
this.currentDecodingInstruction.data = {
|
|
type: "decompress",
|
|
algorithm: 0 /* Zlib */,
|
|
settings: null
|
|
};
|
|
this.readContiguousElements(slice.slice(dataStartPos, size));
|
|
}
|
|
;
|
|
break;
|
|
case 16980 /* ContentCompAlgo */:
|
|
{
|
|
if (this.currentDecodingInstruction?.data?.type !== "decompress") break;
|
|
this.currentDecodingInstruction.data.algorithm = readUnsignedInt(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 16981 /* ContentCompSettings */:
|
|
{
|
|
if (this.currentDecodingInstruction?.data?.type !== "decompress") break;
|
|
this.currentDecodingInstruction.data.settings = readBytes(slice, size);
|
|
}
|
|
;
|
|
break;
|
|
case 20533 /* ContentEncryption */:
|
|
{
|
|
if (!this.currentDecodingInstruction) break;
|
|
this.currentDecodingInstruction.data = {
|
|
type: "decrypt"
|
|
};
|
|
}
|
|
;
|
|
break;
|
|
}
|
|
slice.filePos = dataStartPos + size;
|
|
return true;
|
|
}
|
|
decodeBlockData(track, rawData) {
|
|
assert(track.decodingInstructions.length > 0);
|
|
let currentData = rawData;
|
|
for (const instruction of track.decodingInstructions) {
|
|
assert(instruction.data);
|
|
switch (instruction.data.type) {
|
|
case "decompress":
|
|
{
|
|
switch (instruction.data.algorithm) {
|
|
case 3 /* HeaderStripping */:
|
|
{
|
|
if (instruction.data.settings && instruction.data.settings.length > 0) {
|
|
const prefix = instruction.data.settings;
|
|
const newData = new Uint8Array(prefix.length + currentData.length);
|
|
newData.set(prefix, 0);
|
|
newData.set(currentData, prefix.length);
|
|
currentData = newData;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
{
|
|
}
|
|
;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
{
|
|
}
|
|
;
|
|
}
|
|
}
|
|
return currentData;
|
|
}
|
|
processTagValue(name, value) {
|
|
if (!this.currentSegment?.metadataTags) return;
|
|
const metadataTags = this.currentSegment.metadataTags;
|
|
metadataTags.raw ??= {};
|
|
metadataTags.raw[name] ??= value;
|
|
if (typeof value === "string") {
|
|
switch (name.toLowerCase()) {
|
|
case "title":
|
|
{
|
|
metadataTags.title ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "description":
|
|
{
|
|
metadataTags.description ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "artist":
|
|
{
|
|
metadataTags.artist ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "album":
|
|
{
|
|
metadataTags.album ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "album_artist":
|
|
{
|
|
metadataTags.albumArtist ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "genre":
|
|
{
|
|
metadataTags.genre ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "comment":
|
|
{
|
|
metadataTags.comment ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "lyrics":
|
|
{
|
|
metadataTags.lyrics ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "date":
|
|
{
|
|
const date = new Date(value);
|
|
if (!Number.isNaN(date.getTime())) {
|
|
metadataTags.date ??= date;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "track_number":
|
|
case "part_number":
|
|
{
|
|
const parts = value.split("/");
|
|
const trackNum = Number.parseInt(parts[0], 10);
|
|
const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);
|
|
if (Number.isInteger(trackNum) && trackNum > 0) {
|
|
metadataTags.trackNumber ??= trackNum;
|
|
}
|
|
if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {
|
|
metadataTags.tracksTotal ??= tracksTotal;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "disc_number":
|
|
case "disc":
|
|
{
|
|
const discParts = value.split("/");
|
|
const discNum = Number.parseInt(discParts[0], 10);
|
|
const discsTotal = discParts[1] && Number.parseInt(discParts[1], 10);
|
|
if (Number.isInteger(discNum) && discNum > 0) {
|
|
metadataTags.discNumber ??= discNum;
|
|
}
|
|
if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) {
|
|
metadataTags.discsTotal ??= discsTotal;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
var MatroskaTrackBacking = class {
|
|
constructor(internalTrack) {
|
|
this.internalTrack = internalTrack;
|
|
this.packetToClusterLocation = /* @__PURE__ */ new WeakMap();
|
|
}
|
|
getId() {
|
|
return this.internalTrack.id;
|
|
}
|
|
getNumber() {
|
|
const demuxer = this.internalTrack.demuxer;
|
|
const inputTrack = this.internalTrack.inputTrack;
|
|
const trackType = inputTrack.type;
|
|
let number = 0;
|
|
for (const segment of demuxer.segments) {
|
|
for (const track of segment.tracks) {
|
|
if (track.inputTrack.type === trackType) {
|
|
number++;
|
|
}
|
|
if (track === this.internalTrack) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return number;
|
|
}
|
|
getCodec() {
|
|
throw new Error("Not implemented on base class.");
|
|
}
|
|
getInternalCodecId() {
|
|
return this.internalTrack.codecId;
|
|
}
|
|
async computeDuration() {
|
|
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
|
|
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
|
|
}
|
|
getName() {
|
|
return this.internalTrack.name;
|
|
}
|
|
getLanguageCode() {
|
|
return this.internalTrack.languageCode;
|
|
}
|
|
async getFirstTimestamp() {
|
|
const firstPacket = await this.getFirstPacket({ metadataOnly: true });
|
|
return firstPacket?.timestamp ?? 0;
|
|
}
|
|
getTimeResolution() {
|
|
return this.internalTrack.segment.timestampFactor;
|
|
}
|
|
getDisposition() {
|
|
return this.internalTrack.disposition;
|
|
}
|
|
async getFirstPacket(options) {
|
|
return this.performClusterLookup(
|
|
null,
|
|
(cluster) => {
|
|
const trackData = cluster.trackData.get(this.internalTrack.id);
|
|
if (trackData) {
|
|
return {
|
|
blockIndex: 0,
|
|
correctBlockFound: true
|
|
};
|
|
}
|
|
return {
|
|
blockIndex: -1,
|
|
correctBlockFound: false
|
|
};
|
|
},
|
|
-Infinity,
|
|
// Use -Infinity as a search timestamp to avoid using the cues
|
|
Infinity,
|
|
options
|
|
);
|
|
}
|
|
intoTimescale(timestamp) {
|
|
return roundIfAlmostInteger(timestamp * this.internalTrack.segment.timestampFactor);
|
|
}
|
|
async getPacket(timestamp, options) {
|
|
const timestampInTimescale = this.intoTimescale(timestamp);
|
|
return this.performClusterLookup(
|
|
null,
|
|
(cluster) => {
|
|
const trackData = cluster.trackData.get(this.internalTrack.id);
|
|
if (!trackData) {
|
|
return { blockIndex: -1, correctBlockFound: false };
|
|
}
|
|
const index = binarySearchLessOrEqual(
|
|
trackData.presentationTimestamps,
|
|
timestampInTimescale,
|
|
(x) => x.timestamp
|
|
);
|
|
const blockIndex = index !== -1 ? trackData.presentationTimestamps[index].blockIndex : -1;
|
|
const correctBlockFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
|
|
return { blockIndex, correctBlockFound };
|
|
},
|
|
timestampInTimescale,
|
|
timestampInTimescale,
|
|
options
|
|
);
|
|
}
|
|
async getNextPacket(packet, options) {
|
|
const locationInCluster = this.packetToClusterLocation.get(packet);
|
|
if (locationInCluster === void 0) {
|
|
throw new Error("Packet was not created from this track.");
|
|
}
|
|
return this.performClusterLookup(
|
|
locationInCluster.cluster,
|
|
(cluster) => {
|
|
if (cluster === locationInCluster.cluster) {
|
|
const trackData = cluster.trackData.get(this.internalTrack.id);
|
|
if (locationInCluster.blockIndex + 1 < trackData.blocks.length) {
|
|
return {
|
|
blockIndex: locationInCluster.blockIndex + 1,
|
|
correctBlockFound: true
|
|
};
|
|
}
|
|
} else {
|
|
const trackData = cluster.trackData.get(this.internalTrack.id);
|
|
if (trackData) {
|
|
return {
|
|
blockIndex: 0,
|
|
correctBlockFound: true
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
blockIndex: -1,
|
|
correctBlockFound: false
|
|
};
|
|
},
|
|
-Infinity,
|
|
// Use -Infinity as a search timestamp to avoid using the cues
|
|
Infinity,
|
|
options
|
|
);
|
|
}
|
|
async getKeyPacket(timestamp, options) {
|
|
const timestampInTimescale = this.intoTimescale(timestamp);
|
|
return this.performClusterLookup(
|
|
null,
|
|
(cluster) => {
|
|
const trackData = cluster.trackData.get(this.internalTrack.id);
|
|
if (!trackData) {
|
|
return { blockIndex: -1, correctBlockFound: false };
|
|
}
|
|
const index = findLastIndex(trackData.presentationTimestamps, (x) => {
|
|
const block = trackData.blocks[x.blockIndex];
|
|
return block.isKeyFrame && x.timestamp <= timestampInTimescale;
|
|
});
|
|
const blockIndex = index !== -1 ? trackData.presentationTimestamps[index].blockIndex : -1;
|
|
const correctBlockFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
|
|
return { blockIndex, correctBlockFound };
|
|
},
|
|
timestampInTimescale,
|
|
timestampInTimescale,
|
|
options
|
|
);
|
|
}
|
|
async getNextKeyPacket(packet, options) {
|
|
const locationInCluster = this.packetToClusterLocation.get(packet);
|
|
if (locationInCluster === void 0) {
|
|
throw new Error("Packet was not created from this track.");
|
|
}
|
|
return this.performClusterLookup(
|
|
locationInCluster.cluster,
|
|
(cluster) => {
|
|
if (cluster === locationInCluster.cluster) {
|
|
const trackData = cluster.trackData.get(this.internalTrack.id);
|
|
const nextKeyFrameIndex = trackData.blocks.findIndex(
|
|
(x, i) => x.isKeyFrame && i > locationInCluster.blockIndex
|
|
);
|
|
if (nextKeyFrameIndex !== -1) {
|
|
return {
|
|
blockIndex: nextKeyFrameIndex,
|
|
correctBlockFound: true
|
|
};
|
|
}
|
|
} else {
|
|
const trackData = cluster.trackData.get(this.internalTrack.id);
|
|
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
|
|
const keyFrameIndex = trackData.blocks.findIndex((x) => x.isKeyFrame);
|
|
assert(keyFrameIndex !== -1);
|
|
return {
|
|
blockIndex: keyFrameIndex,
|
|
correctBlockFound: true
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
blockIndex: -1,
|
|
correctBlockFound: false
|
|
};
|
|
},
|
|
-Infinity,
|
|
// Use -Infinity as a search timestamp to avoid using the cues
|
|
Infinity,
|
|
options
|
|
);
|
|
}
|
|
async fetchPacketInCluster(cluster, blockIndex, options) {
|
|
if (blockIndex === -1) {
|
|
return null;
|
|
}
|
|
const trackData = cluster.trackData.get(this.internalTrack.id);
|
|
const block = trackData.blocks[blockIndex];
|
|
assert(block);
|
|
if (!block.decoded) {
|
|
block.data = this.internalTrack.demuxer.decodeBlockData(this.internalTrack, block.data);
|
|
block.decoded = true;
|
|
}
|
|
const data = options.metadataOnly ? PLACEHOLDER_DATA : block.data;
|
|
const timestamp = block.timestamp / this.internalTrack.segment.timestampFactor;
|
|
const duration = block.duration / this.internalTrack.segment.timestampFactor;
|
|
const sideData = {};
|
|
if (block.mainAdditional && this.internalTrack.info?.type === "video" && this.internalTrack.info.alphaMode) {
|
|
sideData.alpha = options.metadataOnly ? PLACEHOLDER_DATA : block.mainAdditional;
|
|
sideData.alphaByteLength = block.mainAdditional.byteLength;
|
|
}
|
|
const packet = new EncodedPacket(
|
|
data,
|
|
block.isKeyFrame ? "key" : "delta",
|
|
timestamp,
|
|
duration,
|
|
cluster.dataStartPos + blockIndex,
|
|
block.data.byteLength,
|
|
sideData
|
|
);
|
|
this.packetToClusterLocation.set(packet, { cluster, blockIndex });
|
|
return packet;
|
|
}
|
|
/** Looks for a packet in the clusters while trying to load as few clusters as possible to retrieve it. */
|
|
async performClusterLookup(startCluster, getMatchInCluster, searchTimestamp, latestTimestamp, options) {
|
|
const { demuxer, segment } = this.internalTrack;
|
|
let currentCluster = null;
|
|
let bestCluster = null;
|
|
let bestBlockIndex = -1;
|
|
if (startCluster) {
|
|
const { blockIndex, correctBlockFound } = getMatchInCluster(startCluster);
|
|
if (correctBlockFound) {
|
|
return this.fetchPacketInCluster(startCluster, blockIndex, options);
|
|
}
|
|
if (blockIndex !== -1) {
|
|
bestCluster = startCluster;
|
|
bestBlockIndex = blockIndex;
|
|
}
|
|
}
|
|
const cuePointIndex = binarySearchLessOrEqual(
|
|
this.internalTrack.cuePoints,
|
|
searchTimestamp,
|
|
(x) => x.time
|
|
);
|
|
const cuePoint = cuePointIndex !== -1 ? this.internalTrack.cuePoints[cuePointIndex] : null;
|
|
const positionCacheIndex = binarySearchLessOrEqual(
|
|
this.internalTrack.clusterPositionCache,
|
|
searchTimestamp,
|
|
(x) => x.startTimestamp
|
|
);
|
|
const positionCacheEntry = positionCacheIndex !== -1 ? this.internalTrack.clusterPositionCache[positionCacheIndex] : null;
|
|
const lookupEntryPosition = Math.max(
|
|
cuePoint?.clusterPosition ?? 0,
|
|
positionCacheEntry?.elementStartPos ?? 0
|
|
) || null;
|
|
let currentPos;
|
|
if (!startCluster) {
|
|
currentPos = lookupEntryPosition ?? segment.clusterSeekStartPos;
|
|
} else {
|
|
if (lookupEntryPosition === null || startCluster.elementStartPos >= lookupEntryPosition) {
|
|
currentPos = startCluster.elementEndPos;
|
|
currentCluster = startCluster;
|
|
} else {
|
|
currentPos = lookupEntryPosition;
|
|
}
|
|
}
|
|
while (segment.elementEndPos === null || currentPos <= segment.elementEndPos - MIN_HEADER_SIZE) {
|
|
if (currentCluster) {
|
|
const trackData = currentCluster.trackData.get(this.internalTrack.id);
|
|
if (trackData && trackData.startTimestamp > latestTimestamp) {
|
|
break;
|
|
}
|
|
}
|
|
let slice = demuxer.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) break;
|
|
const elementStartPos = currentPos;
|
|
const elementHeader = readElementHeader(slice);
|
|
if (!elementHeader || !LEVEL_1_EBML_IDS.includes(elementHeader.id) && elementHeader.id !== 236 /* Void */) {
|
|
const nextPos = await resync(
|
|
demuxer.reader,
|
|
elementStartPos,
|
|
LEVEL_1_EBML_IDS,
|
|
Math.min(segment.elementEndPos ?? Infinity, elementStartPos + MAX_RESYNC_LENGTH)
|
|
);
|
|
if (nextPos) {
|
|
currentPos = nextPos;
|
|
continue;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
const id = elementHeader.id;
|
|
let size = elementHeader.size;
|
|
const dataStartPos = slice.filePos;
|
|
if (id === 524531317 /* Cluster */) {
|
|
currentCluster = await demuxer.readCluster(elementStartPos, segment);
|
|
size = currentCluster.elementEndPos - dataStartPos;
|
|
const { blockIndex, correctBlockFound } = getMatchInCluster(currentCluster);
|
|
if (correctBlockFound) {
|
|
return this.fetchPacketInCluster(currentCluster, blockIndex, options);
|
|
}
|
|
if (blockIndex !== -1) {
|
|
bestCluster = currentCluster;
|
|
bestBlockIndex = blockIndex;
|
|
}
|
|
}
|
|
if (size === void 0) {
|
|
assert(id !== 524531317 /* Cluster */);
|
|
const nextElementPos = await searchForNextElementId(
|
|
demuxer.reader,
|
|
dataStartPos,
|
|
LEVEL_0_AND_1_EBML_IDS,
|
|
segment.elementEndPos
|
|
);
|
|
size = nextElementPos.pos - dataStartPos;
|
|
}
|
|
const endPos = dataStartPos + size;
|
|
if (segment.elementEndPos === null) {
|
|
let slice2 = demuxer.reader.requestSliceRange(endPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
|
if (slice2 instanceof Promise) slice2 = await slice2;
|
|
if (!slice2) break;
|
|
const elementId = readElementId(slice2);
|
|
if (elementId === 408125543 /* Segment */) {
|
|
segment.elementEndPos = endPos;
|
|
break;
|
|
}
|
|
}
|
|
currentPos = endPos;
|
|
}
|
|
if (cuePoint && (!bestCluster || bestCluster.elementStartPos < cuePoint.clusterPosition)) {
|
|
const previousCuePoint = this.internalTrack.cuePoints[cuePointIndex - 1];
|
|
assert(!previousCuePoint || previousCuePoint.time < cuePoint.time);
|
|
const newSearchTimestamp = previousCuePoint?.time ?? -Infinity;
|
|
return this.performClusterLookup(null, getMatchInCluster, newSearchTimestamp, latestTimestamp, options);
|
|
}
|
|
if (bestCluster) {
|
|
return this.fetchPacketInCluster(bestCluster, bestBlockIndex, options);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
var MatroskaVideoTrackBacking = class extends MatroskaTrackBacking {
|
|
constructor(internalTrack) {
|
|
super(internalTrack);
|
|
this.decoderConfigPromise = null;
|
|
this.internalTrack = internalTrack;
|
|
}
|
|
getCodec() {
|
|
return this.internalTrack.info.codec;
|
|
}
|
|
getCodedWidth() {
|
|
return this.internalTrack.info.width;
|
|
}
|
|
getCodedHeight() {
|
|
return this.internalTrack.info.height;
|
|
}
|
|
getRotation() {
|
|
return this.internalTrack.info.rotation;
|
|
}
|
|
async getColorSpace() {
|
|
return {
|
|
primaries: this.internalTrack.info.colorSpace?.primaries,
|
|
transfer: this.internalTrack.info.colorSpace?.transfer,
|
|
matrix: this.internalTrack.info.colorSpace?.matrix,
|
|
fullRange: this.internalTrack.info.colorSpace?.fullRange
|
|
};
|
|
}
|
|
async canBeTransparent() {
|
|
return this.internalTrack.info.alphaMode;
|
|
}
|
|
async getDecoderConfig() {
|
|
if (!this.internalTrack.info.codec) {
|
|
return null;
|
|
}
|
|
return this.decoderConfigPromise ??= (async () => {
|
|
let firstPacket = null;
|
|
const needsPacketForAdditionalInfo = this.internalTrack.info.codec === "vp9" || this.internalTrack.info.codec === "av1" || this.internalTrack.info.codec === "avc" && !this.internalTrack.info.codecDescription || this.internalTrack.info.codec === "hevc" && !this.internalTrack.info.codecDescription;
|
|
if (needsPacketForAdditionalInfo) {
|
|
firstPacket = await this.getFirstPacket({});
|
|
}
|
|
return {
|
|
codec: extractVideoCodecString({
|
|
width: this.internalTrack.info.width,
|
|
height: this.internalTrack.info.height,
|
|
codec: this.internalTrack.info.codec,
|
|
codecDescription: this.internalTrack.info.codecDescription,
|
|
colorSpace: this.internalTrack.info.colorSpace,
|
|
avcType: 1,
|
|
// We don't know better (or do we?) so just assume 'avc1'
|
|
avcCodecInfo: this.internalTrack.info.codec === "avc" && firstPacket ? extractAvcDecoderConfigurationRecord(firstPacket.data) : null,
|
|
hevcCodecInfo: this.internalTrack.info.codec === "hevc" && firstPacket ? extractHevcDecoderConfigurationRecord(firstPacket.data) : null,
|
|
vp9CodecInfo: this.internalTrack.info.codec === "vp9" && firstPacket ? extractVp9CodecInfoFromPacket(firstPacket.data) : null,
|
|
av1CodecInfo: this.internalTrack.info.codec === "av1" && firstPacket ? extractAv1CodecInfoFromPacket(firstPacket.data) : null
|
|
}),
|
|
codedWidth: this.internalTrack.info.width,
|
|
codedHeight: this.internalTrack.info.height,
|
|
description: this.internalTrack.info.codecDescription ?? void 0,
|
|
colorSpace: this.internalTrack.info.colorSpace ?? void 0
|
|
};
|
|
})();
|
|
}
|
|
};
|
|
var MatroskaAudioTrackBacking = class extends MatroskaTrackBacking {
|
|
constructor(internalTrack) {
|
|
super(internalTrack);
|
|
this.decoderConfig = null;
|
|
this.internalTrack = internalTrack;
|
|
}
|
|
getCodec() {
|
|
return this.internalTrack.info.codec;
|
|
}
|
|
getNumberOfChannels() {
|
|
return this.internalTrack.info.numberOfChannels;
|
|
}
|
|
getSampleRate() {
|
|
return this.internalTrack.info.sampleRate;
|
|
}
|
|
async getDecoderConfig() {
|
|
if (!this.internalTrack.info.codec) {
|
|
return null;
|
|
}
|
|
return this.decoderConfig ??= {
|
|
codec: extractAudioCodecString({
|
|
codec: this.internalTrack.info.codec,
|
|
codecDescription: this.internalTrack.info.codecDescription,
|
|
aacCodecInfo: this.internalTrack.info.aacCodecInfo
|
|
}),
|
|
numberOfChannels: this.internalTrack.info.numberOfChannels,
|
|
sampleRate: this.internalTrack.info.sampleRate,
|
|
description: this.internalTrack.info.codecDescription ?? void 0
|
|
};
|
|
}
|
|
};
|
|
|
|
// src/mp3/mp3-reader.ts
|
|
var readNextMp3FrameHeader = async (reader, startPos, until) => {
|
|
let currentPos = startPos;
|
|
while (until === null || currentPos < until) {
|
|
let slice = reader.requestSlice(currentPos, FRAME_HEADER_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) break;
|
|
const word = readU32Be(slice);
|
|
const result = readMp3FrameHeader(word, reader.fileSize !== null ? reader.fileSize - currentPos : null);
|
|
if (result.header) {
|
|
return { header: result.header, startPos: currentPos };
|
|
}
|
|
currentPos += result.bytesAdvanced;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// src/mp3/mp3-demuxer.ts
|
|
var Mp3Demuxer = class extends Demuxer {
|
|
constructor(input) {
|
|
super(input);
|
|
this.metadataPromise = null;
|
|
this.firstFrameHeader = null;
|
|
this.loadedSamples = [];
|
|
// All samples from the start of the file to lastLoadedPos
|
|
this.metadataTags = null;
|
|
this.tracks = [];
|
|
this.readingMutex = new AsyncMutex();
|
|
this.lastSampleLoaded = false;
|
|
this.lastLoadedPos = 0;
|
|
this.nextTimestampInSamples = 0;
|
|
this.reader = input._reader;
|
|
}
|
|
async readMetadata() {
|
|
return this.metadataPromise ??= (async () => {
|
|
while (!this.firstFrameHeader && !this.lastSampleLoaded) {
|
|
await this.advanceReader();
|
|
}
|
|
if (!this.firstFrameHeader) {
|
|
throw new Error("No valid MP3 frame found.");
|
|
}
|
|
this.tracks = [new InputAudioTrack(this.input, new Mp3AudioTrackBacking(this))];
|
|
})();
|
|
}
|
|
async advanceReader() {
|
|
if (this.lastLoadedPos === 0) {
|
|
while (true) {
|
|
let slice2 = this.reader.requestSlice(this.lastLoadedPos, ID3_V2_HEADER_SIZE);
|
|
if (slice2 instanceof Promise) slice2 = await slice2;
|
|
if (!slice2) {
|
|
this.lastSampleLoaded = true;
|
|
return;
|
|
}
|
|
const id3V2Header = readId3V2Header(slice2);
|
|
if (!id3V2Header) {
|
|
break;
|
|
}
|
|
this.lastLoadedPos = slice2.filePos + id3V2Header.size;
|
|
}
|
|
}
|
|
const result = await readNextMp3FrameHeader(this.reader, this.lastLoadedPos, this.reader.fileSize);
|
|
if (!result) {
|
|
this.lastSampleLoaded = true;
|
|
return;
|
|
}
|
|
const header = result.header;
|
|
this.lastLoadedPos = result.startPos + header.totalSize - 1;
|
|
const xingOffset = getXingOffset(header.mpegVersionId, header.channel);
|
|
let slice = this.reader.requestSlice(result.startPos + xingOffset, 4);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (slice) {
|
|
const word = readU32Be(slice);
|
|
const isXing = word === XING || word === INFO;
|
|
if (isXing) {
|
|
return;
|
|
}
|
|
}
|
|
if (!this.firstFrameHeader) {
|
|
this.firstFrameHeader = header;
|
|
}
|
|
if (header.sampleRate !== this.firstFrameHeader.sampleRate) {
|
|
console.warn(
|
|
`MP3 changed sample rate mid-file: ${this.firstFrameHeader.sampleRate} Hz to ${header.sampleRate} Hz. Might be a bug, so please report this file.`
|
|
);
|
|
}
|
|
const sampleDuration = header.audioSamplesInFrame / this.firstFrameHeader.sampleRate;
|
|
const sample = {
|
|
timestamp: this.nextTimestampInSamples / this.firstFrameHeader.sampleRate,
|
|
duration: sampleDuration,
|
|
dataStart: result.startPos,
|
|
dataSize: header.totalSize
|
|
};
|
|
this.loadedSamples.push(sample);
|
|
this.nextTimestampInSamples += header.audioSamplesInFrame;
|
|
return;
|
|
}
|
|
async getMimeType() {
|
|
return "audio/mpeg";
|
|
}
|
|
async getTracks() {
|
|
await this.readMetadata();
|
|
return this.tracks;
|
|
}
|
|
async computeDuration() {
|
|
await this.readMetadata();
|
|
const track = this.tracks[0];
|
|
assert(track);
|
|
return track.computeDuration();
|
|
}
|
|
async getMetadataTags() {
|
|
const release = await this.readingMutex.acquire();
|
|
try {
|
|
await this.readMetadata();
|
|
if (this.metadataTags) {
|
|
return this.metadataTags;
|
|
}
|
|
this.metadataTags = {};
|
|
let currentPos = 0;
|
|
let id3V2HeaderFound = false;
|
|
while (true) {
|
|
let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
|
|
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
|
|
if (!headerSlice) break;
|
|
const id3V2Header = readId3V2Header(headerSlice);
|
|
if (!id3V2Header) {
|
|
break;
|
|
}
|
|
id3V2HeaderFound = true;
|
|
let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size);
|
|
if (contentSlice instanceof Promise) contentSlice = await contentSlice;
|
|
if (!contentSlice) break;
|
|
parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
|
|
currentPos = headerSlice.filePos + id3V2Header.size;
|
|
}
|
|
if (!id3V2HeaderFound && this.reader.fileSize !== null && this.reader.fileSize >= ID3_V1_TAG_SIZE) {
|
|
let slice = this.reader.requestSlice(this.reader.fileSize - ID3_V1_TAG_SIZE, ID3_V1_TAG_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
assert(slice);
|
|
const tag = readAscii(slice, 3);
|
|
if (tag === "TAG") {
|
|
parseId3V1Tag(slice, this.metadataTags);
|
|
}
|
|
}
|
|
return this.metadataTags;
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
};
|
|
var Mp3AudioTrackBacking = class {
|
|
constructor(demuxer) {
|
|
this.demuxer = demuxer;
|
|
}
|
|
getId() {
|
|
return 1;
|
|
}
|
|
getNumber() {
|
|
return 1;
|
|
}
|
|
async getFirstTimestamp() {
|
|
return 0;
|
|
}
|
|
getTimeResolution() {
|
|
assert(this.demuxer.firstFrameHeader);
|
|
return this.demuxer.firstFrameHeader.sampleRate / this.demuxer.firstFrameHeader.audioSamplesInFrame;
|
|
}
|
|
async computeDuration() {
|
|
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
|
|
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
|
|
}
|
|
getName() {
|
|
return null;
|
|
}
|
|
getLanguageCode() {
|
|
return UNDETERMINED_LANGUAGE;
|
|
}
|
|
getCodec() {
|
|
return "mp3";
|
|
}
|
|
getInternalCodecId() {
|
|
return null;
|
|
}
|
|
getNumberOfChannels() {
|
|
assert(this.demuxer.firstFrameHeader);
|
|
return this.demuxer.firstFrameHeader.channel === 3 ? 1 : 2;
|
|
}
|
|
getSampleRate() {
|
|
assert(this.demuxer.firstFrameHeader);
|
|
return this.demuxer.firstFrameHeader.sampleRate;
|
|
}
|
|
getDisposition() {
|
|
return {
|
|
...DEFAULT_TRACK_DISPOSITION
|
|
};
|
|
}
|
|
async getDecoderConfig() {
|
|
assert(this.demuxer.firstFrameHeader);
|
|
return {
|
|
codec: "mp3",
|
|
numberOfChannels: this.demuxer.firstFrameHeader.channel === 3 ? 1 : 2,
|
|
sampleRate: this.demuxer.firstFrameHeader.sampleRate
|
|
};
|
|
}
|
|
async getPacketAtIndex(sampleIndex, options) {
|
|
if (sampleIndex === -1) {
|
|
return null;
|
|
}
|
|
const rawSample = this.demuxer.loadedSamples[sampleIndex];
|
|
if (!rawSample) {
|
|
return null;
|
|
}
|
|
let data;
|
|
if (options.metadataOnly) {
|
|
data = PLACEHOLDER_DATA;
|
|
} else {
|
|
let slice = this.demuxer.reader.requestSlice(rawSample.dataStart, rawSample.dataSize);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) {
|
|
return null;
|
|
}
|
|
data = readBytes(slice, rawSample.dataSize);
|
|
}
|
|
return new EncodedPacket(
|
|
data,
|
|
"key",
|
|
rawSample.timestamp,
|
|
rawSample.duration,
|
|
sampleIndex,
|
|
rawSample.dataSize
|
|
);
|
|
}
|
|
getFirstPacket(options) {
|
|
return this.getPacketAtIndex(0, options);
|
|
}
|
|
async getNextPacket(packet, options) {
|
|
const release = await this.demuxer.readingMutex.acquire();
|
|
try {
|
|
const sampleIndex = binarySearchExact(
|
|
this.demuxer.loadedSamples,
|
|
packet.timestamp,
|
|
(x) => x.timestamp
|
|
);
|
|
if (sampleIndex === -1) {
|
|
throw new Error("Packet was not created from this track.");
|
|
}
|
|
const nextIndex = sampleIndex + 1;
|
|
while (nextIndex >= this.demuxer.loadedSamples.length && !this.demuxer.lastSampleLoaded) {
|
|
await this.demuxer.advanceReader();
|
|
}
|
|
return this.getPacketAtIndex(nextIndex, options);
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async getPacket(timestamp, options) {
|
|
const release = await this.demuxer.readingMutex.acquire();
|
|
try {
|
|
while (true) {
|
|
const index = binarySearchLessOrEqual(
|
|
this.demuxer.loadedSamples,
|
|
timestamp,
|
|
(x) => x.timestamp
|
|
);
|
|
if (index === -1 && this.demuxer.loadedSamples.length > 0) {
|
|
return null;
|
|
}
|
|
if (this.demuxer.lastSampleLoaded) {
|
|
return this.getPacketAtIndex(index, options);
|
|
}
|
|
if (index >= 0 && index + 1 < this.demuxer.loadedSamples.length) {
|
|
return this.getPacketAtIndex(index, options);
|
|
}
|
|
await this.demuxer.advanceReader();
|
|
}
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
getKeyPacket(timestamp, options) {
|
|
return this.getPacket(timestamp, options);
|
|
}
|
|
getNextKeyPacket(packet, options) {
|
|
return this.getNextPacket(packet, options);
|
|
}
|
|
};
|
|
|
|
// src/ogg/ogg-misc.ts
|
|
var OGGS = 1399285583;
|
|
var OGG_CRC_POLYNOMIAL = 79764919;
|
|
var OGG_CRC_TABLE = new Uint32Array(256);
|
|
for (let n = 0; n < 256; n++) {
|
|
let crc = n << 24;
|
|
for (let k = 0; k < 8; k++) {
|
|
crc = crc & 2147483648 ? crc << 1 ^ OGG_CRC_POLYNOMIAL : crc << 1;
|
|
}
|
|
OGG_CRC_TABLE[n] = crc >>> 0 & 4294967295;
|
|
}
|
|
var computeOggPageCrc = (bytes2) => {
|
|
const view2 = toDataView(bytes2);
|
|
const originalChecksum = view2.getUint32(22, true);
|
|
view2.setUint32(22, 0, true);
|
|
let crc = 0;
|
|
for (let i = 0; i < bytes2.length; i++) {
|
|
const byte = bytes2[i];
|
|
crc = (crc << 8 ^ OGG_CRC_TABLE[crc >>> 24 ^ byte]) >>> 0;
|
|
}
|
|
view2.setUint32(22, originalChecksum, true);
|
|
return crc;
|
|
};
|
|
var extractSampleMetadata = (data, codecInfo, vorbisLastBlocksize) => {
|
|
let durationInSamples = 0;
|
|
let currentBlocksize = null;
|
|
if (data.length > 0) {
|
|
if (codecInfo.codec === "vorbis") {
|
|
assert(codecInfo.vorbisInfo);
|
|
const vorbisModeCount = codecInfo.vorbisInfo.modeBlockflags.length;
|
|
const bitCount = ilog(vorbisModeCount - 1);
|
|
const modeMask = (1 << bitCount) - 1 << 1;
|
|
const modeNumber = (data[0] & modeMask) >> 1;
|
|
if (modeNumber >= codecInfo.vorbisInfo.modeBlockflags.length) {
|
|
throw new Error("Invalid mode number.");
|
|
}
|
|
let prevBlocksize = vorbisLastBlocksize;
|
|
const blockflag = codecInfo.vorbisInfo.modeBlockflags[modeNumber];
|
|
currentBlocksize = codecInfo.vorbisInfo.blocksizes[blockflag];
|
|
if (blockflag === 1) {
|
|
const prevMask = (modeMask | 1) + 1;
|
|
const flag = data[0] & prevMask ? 1 : 0;
|
|
prevBlocksize = codecInfo.vorbisInfo.blocksizes[flag];
|
|
}
|
|
durationInSamples = prevBlocksize !== null ? prevBlocksize + currentBlocksize >> 2 : 0;
|
|
} else if (codecInfo.codec === "opus") {
|
|
const toc = parseOpusTocByte(data);
|
|
durationInSamples = toc.durationInSamples;
|
|
}
|
|
}
|
|
return {
|
|
durationInSamples,
|
|
vorbisBlockSize: currentBlocksize
|
|
};
|
|
};
|
|
var buildOggMimeType = (info) => {
|
|
let string = "audio/ogg";
|
|
if (info.codecStrings) {
|
|
const uniqueCodecMimeTypes = [...new Set(info.codecStrings)];
|
|
string += `; codecs="${uniqueCodecMimeTypes.join(", ")}"`;
|
|
}
|
|
return string;
|
|
};
|
|
|
|
// src/ogg/ogg-reader.ts
|
|
var MIN_PAGE_HEADER_SIZE = 27;
|
|
var MAX_PAGE_HEADER_SIZE = 27 + 255;
|
|
var MAX_PAGE_SIZE = MAX_PAGE_HEADER_SIZE + 255 * 255;
|
|
var readPageHeader = (slice) => {
|
|
const startPos = slice.filePos;
|
|
const capturePattern = readU32Le(slice);
|
|
if (capturePattern !== OGGS) {
|
|
return null;
|
|
}
|
|
slice.skip(1);
|
|
const headerType = readU8(slice);
|
|
const granulePosition = readI64Le(slice);
|
|
const serialNumber = readU32Le(slice);
|
|
const sequenceNumber = readU32Le(slice);
|
|
const checksum = readU32Le(slice);
|
|
const numberPageSegments = readU8(slice);
|
|
const lacingValues = new Uint8Array(numberPageSegments);
|
|
for (let i = 0; i < numberPageSegments; i++) {
|
|
lacingValues[i] = readU8(slice);
|
|
}
|
|
const headerSize = 27 + numberPageSegments;
|
|
const dataSize = lacingValues.reduce((a, b) => a + b, 0);
|
|
const totalSize = headerSize + dataSize;
|
|
return {
|
|
headerStartPos: startPos,
|
|
totalSize,
|
|
dataStartPos: startPos + headerSize,
|
|
dataSize,
|
|
headerType,
|
|
granulePosition,
|
|
serialNumber,
|
|
sequenceNumber,
|
|
checksum,
|
|
lacingValues
|
|
};
|
|
};
|
|
var findNextPageHeader = (slice, until) => {
|
|
while (slice.filePos < until - (4 - 1)) {
|
|
const word = readU32Le(slice);
|
|
const firstByte = word & 255;
|
|
const secondByte = word >>> 8 & 255;
|
|
const thirdByte = word >>> 16 & 255;
|
|
const fourthByte = word >>> 24 & 255;
|
|
const O = 79;
|
|
if (firstByte !== O && secondByte !== O && thirdByte !== O && fourthByte !== O) {
|
|
continue;
|
|
}
|
|
slice.skip(-4);
|
|
if (word === OGGS) {
|
|
return true;
|
|
}
|
|
slice.skip(1);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// src/ogg/ogg-demuxer.ts
|
|
var OggDemuxer = class extends Demuxer {
|
|
constructor(input) {
|
|
super(input);
|
|
this.metadataPromise = null;
|
|
this.bitstreams = [];
|
|
this.tracks = [];
|
|
this.metadataTags = {};
|
|
this.reader = input._reader;
|
|
}
|
|
async readMetadata() {
|
|
return this.metadataPromise ??= (async () => {
|
|
let currentPos = 0;
|
|
while (true) {
|
|
let slice = this.reader.requestSliceRange(currentPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) break;
|
|
const page = readPageHeader(slice);
|
|
if (!page) {
|
|
break;
|
|
}
|
|
const isBos = !!(page.headerType & 2);
|
|
if (!isBos) {
|
|
break;
|
|
}
|
|
this.bitstreams.push({
|
|
serialNumber: page.serialNumber,
|
|
bosPage: page,
|
|
description: null,
|
|
numberOfChannels: -1,
|
|
sampleRate: -1,
|
|
codecInfo: {
|
|
codec: null,
|
|
vorbisInfo: null,
|
|
opusInfo: null
|
|
},
|
|
lastMetadataPacket: null
|
|
});
|
|
currentPos = page.headerStartPos + page.totalSize;
|
|
}
|
|
for (const bitstream of this.bitstreams) {
|
|
const firstPacket = await this.readPacket(bitstream.bosPage, 0);
|
|
if (!firstPacket) {
|
|
continue;
|
|
}
|
|
if (
|
|
// Check for Vorbis
|
|
firstPacket.data.byteLength >= 7 && firstPacket.data[0] === 1 && firstPacket.data[1] === 118 && firstPacket.data[2] === 111 && firstPacket.data[3] === 114 && firstPacket.data[4] === 98 && firstPacket.data[5] === 105 && firstPacket.data[6] === 115
|
|
) {
|
|
await this.readVorbisMetadata(firstPacket, bitstream);
|
|
} else if (
|
|
// Check for Opus
|
|
firstPacket.data.byteLength >= 8 && firstPacket.data[0] === 79 && firstPacket.data[1] === 112 && firstPacket.data[2] === 117 && firstPacket.data[3] === 115 && firstPacket.data[4] === 72 && firstPacket.data[5] === 101 && firstPacket.data[6] === 97 && firstPacket.data[7] === 100
|
|
) {
|
|
await this.readOpusMetadata(firstPacket, bitstream);
|
|
}
|
|
if (bitstream.codecInfo.codec !== null) {
|
|
this.tracks.push(new InputAudioTrack(this.input, new OggAudioTrackBacking(bitstream, this)));
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
async readVorbisMetadata(firstPacket, bitstream) {
|
|
let nextPacketPosition = await this.findNextPacketStart(firstPacket);
|
|
if (!nextPacketPosition) {
|
|
return;
|
|
}
|
|
const secondPacket = await this.readPacket(nextPacketPosition.startPage, nextPacketPosition.startSegmentIndex);
|
|
if (!secondPacket) {
|
|
return;
|
|
}
|
|
nextPacketPosition = await this.findNextPacketStart(secondPacket);
|
|
if (!nextPacketPosition) {
|
|
return;
|
|
}
|
|
const thirdPacket = await this.readPacket(nextPacketPosition.startPage, nextPacketPosition.startSegmentIndex);
|
|
if (!thirdPacket) {
|
|
return;
|
|
}
|
|
if (secondPacket.data[0] !== 3 || thirdPacket.data[0] !== 5) {
|
|
return;
|
|
}
|
|
const lacingValues = [];
|
|
const addBytesToSegmentTable = (bytes2) => {
|
|
while (true) {
|
|
lacingValues.push(Math.min(255, bytes2));
|
|
if (bytes2 < 255) {
|
|
break;
|
|
}
|
|
bytes2 -= 255;
|
|
}
|
|
};
|
|
addBytesToSegmentTable(firstPacket.data.length);
|
|
addBytesToSegmentTable(secondPacket.data.length);
|
|
const description = new Uint8Array(
|
|
1 + lacingValues.length + firstPacket.data.length + secondPacket.data.length + thirdPacket.data.length
|
|
);
|
|
description[0] = 2;
|
|
description.set(
|
|
lacingValues,
|
|
1
|
|
);
|
|
description.set(
|
|
firstPacket.data,
|
|
1 + lacingValues.length
|
|
);
|
|
description.set(
|
|
secondPacket.data,
|
|
1 + lacingValues.length + firstPacket.data.length
|
|
);
|
|
description.set(
|
|
thirdPacket.data,
|
|
1 + lacingValues.length + firstPacket.data.length + secondPacket.data.length
|
|
);
|
|
bitstream.codecInfo.codec = "vorbis";
|
|
bitstream.description = description;
|
|
bitstream.lastMetadataPacket = thirdPacket;
|
|
const view2 = toDataView(firstPacket.data);
|
|
bitstream.numberOfChannels = view2.getUint8(11);
|
|
bitstream.sampleRate = view2.getUint32(12, true);
|
|
const blockSizeByte = view2.getUint8(28);
|
|
bitstream.codecInfo.vorbisInfo = {
|
|
blocksizes: [
|
|
1 << (blockSizeByte & 15),
|
|
1 << (blockSizeByte >> 4)
|
|
],
|
|
modeBlockflags: parseModesFromVorbisSetupPacket(thirdPacket.data).modeBlockflags
|
|
};
|
|
readVorbisComments(secondPacket.data.subarray(7), this.metadataTags);
|
|
}
|
|
async readOpusMetadata(firstPacket, bitstream) {
|
|
const nextPacketPosition = await this.findNextPacketStart(firstPacket);
|
|
if (!nextPacketPosition) {
|
|
return;
|
|
}
|
|
const secondPacket = await this.readPacket(
|
|
nextPacketPosition.startPage,
|
|
nextPacketPosition.startSegmentIndex
|
|
);
|
|
if (!secondPacket) {
|
|
return;
|
|
}
|
|
bitstream.codecInfo.codec = "opus";
|
|
bitstream.description = firstPacket.data;
|
|
bitstream.lastMetadataPacket = secondPacket;
|
|
const header = parseOpusIdentificationHeader(firstPacket.data);
|
|
bitstream.numberOfChannels = header.outputChannelCount;
|
|
bitstream.sampleRate = OPUS_SAMPLE_RATE;
|
|
bitstream.codecInfo.opusInfo = {
|
|
preSkip: header.preSkip
|
|
};
|
|
readVorbisComments(secondPacket.data.subarray(8), this.metadataTags);
|
|
}
|
|
async readPacket(startPage, startSegmentIndex) {
|
|
assert(startSegmentIndex < startPage.lacingValues.length);
|
|
let startDataOffset = 0;
|
|
for (let i = 0; i < startSegmentIndex; i++) {
|
|
startDataOffset += startPage.lacingValues[i];
|
|
}
|
|
let currentPage = startPage;
|
|
let currentDataOffset = startDataOffset;
|
|
let currentSegmentIndex = startSegmentIndex;
|
|
const chunks = [];
|
|
outer:
|
|
while (true) {
|
|
let pageSlice = this.reader.requestSlice(currentPage.dataStartPos, currentPage.dataSize);
|
|
if (pageSlice instanceof Promise) pageSlice = await pageSlice;
|
|
assert(pageSlice);
|
|
const pageData = readBytes(pageSlice, currentPage.dataSize);
|
|
while (true) {
|
|
if (currentSegmentIndex === currentPage.lacingValues.length) {
|
|
chunks.push(pageData.subarray(startDataOffset, currentDataOffset));
|
|
break;
|
|
}
|
|
const lacingValue = currentPage.lacingValues[currentSegmentIndex];
|
|
currentDataOffset += lacingValue;
|
|
if (lacingValue < 255) {
|
|
chunks.push(pageData.subarray(startDataOffset, currentDataOffset));
|
|
break outer;
|
|
}
|
|
currentSegmentIndex++;
|
|
}
|
|
let currentPos = currentPage.headerStartPos + currentPage.totalSize;
|
|
while (true) {
|
|
let headerSlice = this.reader.requestSliceRange(currentPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);
|
|
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
|
|
if (!headerSlice) {
|
|
return null;
|
|
}
|
|
const nextPage = readPageHeader(headerSlice);
|
|
if (!nextPage) {
|
|
return null;
|
|
}
|
|
currentPage = nextPage;
|
|
if (currentPage.serialNumber === startPage.serialNumber) {
|
|
break;
|
|
}
|
|
currentPos = currentPage.headerStartPos + currentPage.totalSize;
|
|
}
|
|
startDataOffset = 0;
|
|
currentDataOffset = 0;
|
|
currentSegmentIndex = 0;
|
|
}
|
|
const totalPacketSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
if (totalPacketSize === 0) {
|
|
return null;
|
|
}
|
|
const packetData = new Uint8Array(totalPacketSize);
|
|
let offset = 0;
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
const chunk = chunks[i];
|
|
packetData.set(chunk, offset);
|
|
offset += chunk.length;
|
|
}
|
|
return {
|
|
data: packetData,
|
|
endPage: currentPage,
|
|
endSegmentIndex: currentSegmentIndex
|
|
};
|
|
}
|
|
async findNextPacketStart(lastPacket) {
|
|
if (lastPacket.endSegmentIndex < lastPacket.endPage.lacingValues.length - 1) {
|
|
return { startPage: lastPacket.endPage, startSegmentIndex: lastPacket.endSegmentIndex + 1 };
|
|
}
|
|
const isEos = !!(lastPacket.endPage.headerType & 4);
|
|
if (isEos) {
|
|
return null;
|
|
}
|
|
let currentPos = lastPacket.endPage.headerStartPos + lastPacket.endPage.totalSize;
|
|
while (true) {
|
|
let slice = this.reader.requestSliceRange(currentPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) {
|
|
return null;
|
|
}
|
|
const nextPage = readPageHeader(slice);
|
|
if (!nextPage) {
|
|
return null;
|
|
}
|
|
if (nextPage.serialNumber === lastPacket.endPage.serialNumber) {
|
|
return { startPage: nextPage, startSegmentIndex: 0 };
|
|
}
|
|
currentPos = nextPage.headerStartPos + nextPage.totalSize;
|
|
}
|
|
}
|
|
async getMimeType() {
|
|
await this.readMetadata();
|
|
const codecStrings = await Promise.all(this.tracks.map((x) => x.getCodecParameterString()));
|
|
return buildOggMimeType({
|
|
codecStrings: codecStrings.filter(Boolean)
|
|
});
|
|
}
|
|
async getTracks() {
|
|
await this.readMetadata();
|
|
return this.tracks;
|
|
}
|
|
async computeDuration() {
|
|
const tracks = await this.getTracks();
|
|
const trackDurations = await Promise.all(tracks.map((x) => x.computeDuration()));
|
|
return Math.max(0, ...trackDurations);
|
|
}
|
|
async getMetadataTags() {
|
|
await this.readMetadata();
|
|
return this.metadataTags;
|
|
}
|
|
};
|
|
var OggAudioTrackBacking = class {
|
|
constructor(bitstream, demuxer) {
|
|
this.bitstream = bitstream;
|
|
this.demuxer = demuxer;
|
|
this.encodedPacketToMetadata = /* @__PURE__ */ new WeakMap();
|
|
this.sequentialScanCache = [];
|
|
this.sequentialScanMutex = new AsyncMutex();
|
|
this.internalSampleRate = bitstream.codecInfo.codec === "opus" ? OPUS_SAMPLE_RATE : bitstream.sampleRate;
|
|
}
|
|
getId() {
|
|
return this.bitstream.serialNumber;
|
|
}
|
|
getNumber() {
|
|
const index = this.demuxer.tracks.findIndex(
|
|
(t) => t._backing.bitstream === this.bitstream
|
|
);
|
|
assert(index !== -1);
|
|
return index + 1;
|
|
}
|
|
getNumberOfChannels() {
|
|
return this.bitstream.numberOfChannels;
|
|
}
|
|
getSampleRate() {
|
|
return this.bitstream.sampleRate;
|
|
}
|
|
getTimeResolution() {
|
|
return this.bitstream.sampleRate;
|
|
}
|
|
getCodec() {
|
|
return this.bitstream.codecInfo.codec;
|
|
}
|
|
getInternalCodecId() {
|
|
return null;
|
|
}
|
|
async getDecoderConfig() {
|
|
assert(this.bitstream.codecInfo.codec);
|
|
return {
|
|
codec: this.bitstream.codecInfo.codec,
|
|
numberOfChannels: this.bitstream.numberOfChannels,
|
|
sampleRate: this.bitstream.sampleRate,
|
|
description: this.bitstream.description ?? void 0
|
|
};
|
|
}
|
|
getName() {
|
|
return null;
|
|
}
|
|
getLanguageCode() {
|
|
return UNDETERMINED_LANGUAGE;
|
|
}
|
|
getDisposition() {
|
|
return {
|
|
...DEFAULT_TRACK_DISPOSITION
|
|
};
|
|
}
|
|
async getFirstTimestamp() {
|
|
return 0;
|
|
}
|
|
async computeDuration() {
|
|
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
|
|
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
|
|
}
|
|
granulePositionToTimestampInSamples(granulePosition) {
|
|
if (this.bitstream.codecInfo.codec === "opus") {
|
|
assert(this.bitstream.codecInfo.opusInfo);
|
|
return granulePosition - this.bitstream.codecInfo.opusInfo.preSkip;
|
|
}
|
|
return granulePosition;
|
|
}
|
|
createEncodedPacketFromOggPacket(packet, additional, options) {
|
|
if (!packet) {
|
|
return null;
|
|
}
|
|
const { durationInSamples, vorbisBlockSize } = extractSampleMetadata(
|
|
packet.data,
|
|
this.bitstream.codecInfo,
|
|
additional.vorbisLastBlocksize
|
|
);
|
|
const encodedPacket = new EncodedPacket(
|
|
options.metadataOnly ? PLACEHOLDER_DATA : packet.data,
|
|
"key",
|
|
Math.max(0, additional.timestampInSamples) / this.internalSampleRate,
|
|
durationInSamples / this.internalSampleRate,
|
|
packet.endPage.headerStartPos + packet.endSegmentIndex,
|
|
packet.data.byteLength
|
|
);
|
|
this.encodedPacketToMetadata.set(encodedPacket, {
|
|
packet,
|
|
timestampInSamples: additional.timestampInSamples,
|
|
durationInSamples,
|
|
vorbisLastBlockSize: additional.vorbisLastBlocksize,
|
|
vorbisBlockSize
|
|
});
|
|
return encodedPacket;
|
|
}
|
|
async getFirstPacket(options) {
|
|
assert(this.bitstream.lastMetadataPacket);
|
|
const packetPosition = await this.demuxer.findNextPacketStart(this.bitstream.lastMetadataPacket);
|
|
if (!packetPosition) {
|
|
return null;
|
|
}
|
|
let timestampInSamples = 0;
|
|
if (this.bitstream.codecInfo.codec === "opus") {
|
|
assert(this.bitstream.codecInfo.opusInfo);
|
|
timestampInSamples -= this.bitstream.codecInfo.opusInfo.preSkip;
|
|
}
|
|
const packet = await this.demuxer.readPacket(packetPosition.startPage, packetPosition.startSegmentIndex);
|
|
return this.createEncodedPacketFromOggPacket(
|
|
packet,
|
|
{
|
|
timestampInSamples,
|
|
vorbisLastBlocksize: null
|
|
},
|
|
options
|
|
);
|
|
}
|
|
async getNextPacket(prevPacket, options) {
|
|
const prevMetadata = this.encodedPacketToMetadata.get(prevPacket);
|
|
if (!prevMetadata) {
|
|
throw new Error("Packet was not created from this track.");
|
|
}
|
|
const packetPosition = await this.demuxer.findNextPacketStart(prevMetadata.packet);
|
|
if (!packetPosition) {
|
|
return null;
|
|
}
|
|
const timestampInSamples = prevMetadata.timestampInSamples + prevMetadata.durationInSamples;
|
|
const packet = await this.demuxer.readPacket(
|
|
packetPosition.startPage,
|
|
packetPosition.startSegmentIndex
|
|
);
|
|
return this.createEncodedPacketFromOggPacket(
|
|
packet,
|
|
{
|
|
timestampInSamples,
|
|
vorbisLastBlocksize: prevMetadata.vorbisBlockSize
|
|
},
|
|
options
|
|
);
|
|
}
|
|
async getPacket(timestamp, options) {
|
|
if (this.demuxer.reader.fileSize === null) {
|
|
return this.getPacketSequential(timestamp, options);
|
|
}
|
|
const timestampInSamples = roundIfAlmostInteger(timestamp * this.internalSampleRate);
|
|
if (timestampInSamples === 0) {
|
|
return this.getFirstPacket(options);
|
|
}
|
|
if (timestampInSamples < 0) {
|
|
return null;
|
|
}
|
|
assert(this.bitstream.lastMetadataPacket);
|
|
const startPosition = await this.demuxer.findNextPacketStart(this.bitstream.lastMetadataPacket);
|
|
if (!startPosition) {
|
|
return null;
|
|
}
|
|
let lowPage = startPosition.startPage;
|
|
let high = this.demuxer.reader.fileSize;
|
|
const lowPages = [lowPage];
|
|
outer:
|
|
while (lowPage.headerStartPos + lowPage.totalSize < high) {
|
|
const low = lowPage.headerStartPos;
|
|
const mid = Math.floor((low + high) / 2);
|
|
let searchStartPos = mid;
|
|
while (true) {
|
|
const until = Math.min(
|
|
searchStartPos + MAX_PAGE_SIZE,
|
|
high - MIN_PAGE_HEADER_SIZE
|
|
);
|
|
let searchSlice = this.demuxer.reader.requestSlice(searchStartPos, until - searchStartPos);
|
|
if (searchSlice instanceof Promise) searchSlice = await searchSlice;
|
|
assert(searchSlice);
|
|
const found = findNextPageHeader(searchSlice, until);
|
|
if (!found) {
|
|
high = mid + MIN_PAGE_HEADER_SIZE;
|
|
continue outer;
|
|
}
|
|
let headerSlice = this.demuxer.reader.requestSliceRange(
|
|
searchSlice.filePos,
|
|
MIN_PAGE_HEADER_SIZE,
|
|
MAX_PAGE_HEADER_SIZE
|
|
);
|
|
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
|
|
assert(headerSlice);
|
|
const page = readPageHeader(headerSlice);
|
|
assert(page);
|
|
let pageValid = false;
|
|
if (page.serialNumber === this.bitstream.serialNumber) {
|
|
pageValid = true;
|
|
} else {
|
|
let pageSlice = this.demuxer.reader.requestSlice(page.headerStartPos, page.totalSize);
|
|
if (pageSlice instanceof Promise) pageSlice = await pageSlice;
|
|
assert(pageSlice);
|
|
const bytes2 = readBytes(pageSlice, page.totalSize);
|
|
const crc = computeOggPageCrc(bytes2);
|
|
pageValid = crc === page.checksum;
|
|
}
|
|
if (!pageValid) {
|
|
searchStartPos = page.headerStartPos + 4;
|
|
continue;
|
|
}
|
|
if (pageValid && page.serialNumber !== this.bitstream.serialNumber) {
|
|
searchStartPos = page.headerStartPos + page.totalSize;
|
|
continue;
|
|
}
|
|
const isContinuationPage = page.granulePosition === -1;
|
|
if (isContinuationPage) {
|
|
searchStartPos = page.headerStartPos + page.totalSize;
|
|
continue;
|
|
}
|
|
if (this.granulePositionToTimestampInSamples(page.granulePosition) > timestampInSamples) {
|
|
high = page.headerStartPos;
|
|
} else {
|
|
lowPage = page;
|
|
lowPages.push(page);
|
|
}
|
|
continue outer;
|
|
}
|
|
}
|
|
let lowerPage = startPosition.startPage;
|
|
for (const otherLowPage of lowPages) {
|
|
if (otherLowPage.granulePosition === lowPage.granulePosition) {
|
|
break;
|
|
}
|
|
if (!lowerPage || otherLowPage.headerStartPos > lowerPage.headerStartPos) {
|
|
lowerPage = otherLowPage;
|
|
}
|
|
}
|
|
let currentPage = lowerPage;
|
|
const previousPages = [currentPage];
|
|
while (true) {
|
|
if (currentPage.serialNumber === this.bitstream.serialNumber && currentPage.granulePosition === lowPage.granulePosition) {
|
|
break;
|
|
}
|
|
const nextPos = currentPage.headerStartPos + currentPage.totalSize;
|
|
let slice = this.demuxer.reader.requestSliceRange(nextPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
assert(slice);
|
|
const nextPage = readPageHeader(slice);
|
|
assert(nextPage);
|
|
currentPage = nextPage;
|
|
if (currentPage.serialNumber === this.bitstream.serialNumber) {
|
|
previousPages.push(currentPage);
|
|
}
|
|
}
|
|
assert(currentPage.granulePosition !== -1);
|
|
let currentSegmentIndex = null;
|
|
let currentTimestampInSamples;
|
|
let currentTimestampIsCorrect;
|
|
let endPage = currentPage;
|
|
let endSegmentIndex = 0;
|
|
if (currentPage.headerStartPos === startPosition.startPage.headerStartPos) {
|
|
currentTimestampInSamples = this.granulePositionToTimestampInSamples(0);
|
|
currentTimestampIsCorrect = true;
|
|
currentSegmentIndex = 0;
|
|
} else {
|
|
currentTimestampInSamples = 0;
|
|
currentTimestampIsCorrect = false;
|
|
for (let i = currentPage.lacingValues.length - 1; i >= 0; i--) {
|
|
const value = currentPage.lacingValues[i];
|
|
if (value < 255) {
|
|
currentSegmentIndex = i + 1;
|
|
break;
|
|
}
|
|
}
|
|
if (currentSegmentIndex === null) {
|
|
throw new Error("Invalid page with granule position: no packets end on this page.");
|
|
}
|
|
endSegmentIndex = currentSegmentIndex - 1;
|
|
const pseudopacket = {
|
|
data: PLACEHOLDER_DATA,
|
|
endPage,
|
|
endSegmentIndex
|
|
};
|
|
const nextPosition = await this.demuxer.findNextPacketStart(pseudopacket);
|
|
if (nextPosition) {
|
|
const endPosition = findPreviousPacketEndPosition(previousPages, currentPage, currentSegmentIndex);
|
|
assert(endPosition);
|
|
const startPosition2 = findPacketStartPosition(
|
|
previousPages,
|
|
endPosition.page,
|
|
endPosition.segmentIndex
|
|
);
|
|
if (startPosition2) {
|
|
currentPage = startPosition2.page;
|
|
currentSegmentIndex = startPosition2.segmentIndex;
|
|
}
|
|
} else {
|
|
while (true) {
|
|
const endPosition = findPreviousPacketEndPosition(
|
|
previousPages,
|
|
currentPage,
|
|
currentSegmentIndex
|
|
);
|
|
if (!endPosition) {
|
|
break;
|
|
}
|
|
const startPosition2 = findPacketStartPosition(
|
|
previousPages,
|
|
endPosition.page,
|
|
endPosition.segmentIndex
|
|
);
|
|
if (!startPosition2) {
|
|
break;
|
|
}
|
|
currentPage = startPosition2.page;
|
|
currentSegmentIndex = startPosition2.segmentIndex;
|
|
if (endPosition.page.headerStartPos !== endPage.headerStartPos) {
|
|
endPage = endPosition.page;
|
|
endSegmentIndex = endPosition.segmentIndex;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let lastEncodedPacket = null;
|
|
let lastEncodedPacketMetadata = null;
|
|
while (currentPage !== null) {
|
|
assert(currentSegmentIndex !== null);
|
|
const packet = await this.demuxer.readPacket(currentPage, currentSegmentIndex);
|
|
if (!packet) {
|
|
break;
|
|
}
|
|
const skipPacket = currentPage.headerStartPos === startPosition.startPage.headerStartPos && currentSegmentIndex < startPosition.startSegmentIndex;
|
|
if (!skipPacket) {
|
|
let encodedPacket = this.createEncodedPacketFromOggPacket(
|
|
packet,
|
|
{
|
|
timestampInSamples: currentTimestampInSamples,
|
|
vorbisLastBlocksize: lastEncodedPacketMetadata?.vorbisBlockSize ?? null
|
|
},
|
|
options
|
|
);
|
|
assert(encodedPacket);
|
|
let encodedPacketMetadata = this.encodedPacketToMetadata.get(encodedPacket);
|
|
assert(encodedPacketMetadata);
|
|
if (!currentTimestampIsCorrect && packet.endPage.headerStartPos === endPage.headerStartPos && packet.endSegmentIndex === endSegmentIndex) {
|
|
currentTimestampInSamples = this.granulePositionToTimestampInSamples(
|
|
currentPage.granulePosition
|
|
);
|
|
currentTimestampIsCorrect = true;
|
|
encodedPacket = this.createEncodedPacketFromOggPacket(
|
|
packet,
|
|
{
|
|
timestampInSamples: currentTimestampInSamples - encodedPacketMetadata.durationInSamples,
|
|
vorbisLastBlocksize: lastEncodedPacketMetadata?.vorbisBlockSize ?? null
|
|
},
|
|
options
|
|
);
|
|
assert(encodedPacket);
|
|
encodedPacketMetadata = this.encodedPacketToMetadata.get(encodedPacket);
|
|
assert(encodedPacketMetadata);
|
|
} else {
|
|
currentTimestampInSamples += encodedPacketMetadata.durationInSamples;
|
|
}
|
|
lastEncodedPacket = encodedPacket;
|
|
lastEncodedPacketMetadata = encodedPacketMetadata;
|
|
if (currentTimestampIsCorrect && // Next timestamp will be too late
|
|
(Math.max(currentTimestampInSamples, 0) > timestampInSamples || Math.max(encodedPacketMetadata.timestampInSamples, 0) === timestampInSamples)) {
|
|
break;
|
|
}
|
|
}
|
|
const nextPosition = await this.demuxer.findNextPacketStart(packet);
|
|
if (!nextPosition) {
|
|
break;
|
|
}
|
|
currentPage = nextPosition.startPage;
|
|
currentSegmentIndex = nextPosition.startSegmentIndex;
|
|
}
|
|
return lastEncodedPacket;
|
|
}
|
|
// A slower but simpler and sequential algorithm for finding a packet in a file
|
|
async getPacketSequential(timestamp, options) {
|
|
const release = await this.sequentialScanMutex.acquire();
|
|
try {
|
|
const timestampInSamples = roundIfAlmostInteger(timestamp * this.internalSampleRate);
|
|
timestamp = timestampInSamples / this.internalSampleRate;
|
|
const index = binarySearchLessOrEqual(
|
|
this.sequentialScanCache,
|
|
timestampInSamples,
|
|
(x) => x.timestampInSamples
|
|
);
|
|
let currentPacket;
|
|
if (index !== -1) {
|
|
const cacheEntry = this.sequentialScanCache[index];
|
|
currentPacket = this.createEncodedPacketFromOggPacket(
|
|
cacheEntry.packet,
|
|
{
|
|
timestampInSamples: cacheEntry.timestampInSamples,
|
|
vorbisLastBlocksize: cacheEntry.vorbisLastBlockSize
|
|
},
|
|
options
|
|
);
|
|
} else {
|
|
currentPacket = await this.getFirstPacket(options);
|
|
}
|
|
let i = 0;
|
|
while (currentPacket && currentPacket.timestamp < timestamp) {
|
|
const nextPacket = await this.getNextPacket(currentPacket, options);
|
|
if (!nextPacket || nextPacket.timestamp > timestamp) {
|
|
break;
|
|
}
|
|
currentPacket = nextPacket;
|
|
i++;
|
|
if (i === 100) {
|
|
i = 0;
|
|
const metadata = this.encodedPacketToMetadata.get(currentPacket);
|
|
assert(metadata);
|
|
if (this.sequentialScanCache.length > 0) {
|
|
assert(last(this.sequentialScanCache).timestampInSamples <= metadata.timestampInSamples);
|
|
}
|
|
this.sequentialScanCache.push(metadata);
|
|
}
|
|
}
|
|
return currentPacket;
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
getKeyPacket(timestamp, options) {
|
|
return this.getPacket(timestamp, options);
|
|
}
|
|
getNextKeyPacket(packet, options) {
|
|
return this.getNextPacket(packet, options);
|
|
}
|
|
};
|
|
var findPacketStartPosition = (pageList, endPage, endSegmentIndex) => {
|
|
let page = endPage;
|
|
let segmentIndex = endSegmentIndex;
|
|
outer:
|
|
while (true) {
|
|
segmentIndex--;
|
|
for (segmentIndex; segmentIndex >= 0; segmentIndex--) {
|
|
const lacingValue = page.lacingValues[segmentIndex];
|
|
if (lacingValue < 255) {
|
|
segmentIndex++;
|
|
break outer;
|
|
}
|
|
}
|
|
assert(segmentIndex === -1);
|
|
const pageStartsWithFreshPacket = !(page.headerType & 1);
|
|
if (pageStartsWithFreshPacket) {
|
|
segmentIndex = 0;
|
|
break;
|
|
}
|
|
const previousPage = findLast(
|
|
pageList,
|
|
(x) => x.headerStartPos < page.headerStartPos
|
|
);
|
|
if (!previousPage) {
|
|
return null;
|
|
}
|
|
page = previousPage;
|
|
segmentIndex = page.lacingValues.length;
|
|
}
|
|
assert(segmentIndex !== -1);
|
|
if (segmentIndex === page.lacingValues.length) {
|
|
const nextPage = pageList[pageList.indexOf(page) + 1];
|
|
assert(nextPage);
|
|
page = nextPage;
|
|
segmentIndex = 0;
|
|
}
|
|
return { page, segmentIndex };
|
|
};
|
|
var findPreviousPacketEndPosition = (pageList, startPage, startSegmentIndex) => {
|
|
if (startSegmentIndex > 0) {
|
|
return { page: startPage, segmentIndex: startSegmentIndex - 1 };
|
|
}
|
|
const previousPage = findLast(
|
|
pageList,
|
|
(x) => x.headerStartPos < startPage.headerStartPos
|
|
);
|
|
if (!previousPage) {
|
|
return null;
|
|
}
|
|
return { page: previousPage, segmentIndex: previousPage.lacingValues.length - 1 };
|
|
};
|
|
|
|
// src/wave/wave-demuxer.ts
|
|
var WaveDemuxer = class extends Demuxer {
|
|
constructor(input) {
|
|
super(input);
|
|
this.metadataPromise = null;
|
|
this.dataStart = -1;
|
|
this.dataSize = -1;
|
|
this.audioInfo = null;
|
|
this.tracks = [];
|
|
this.lastKnownPacketIndex = 0;
|
|
this.metadataTags = {};
|
|
this.reader = input._reader;
|
|
}
|
|
async readMetadata() {
|
|
return this.metadataPromise ??= (async () => {
|
|
let slice = this.reader.requestSlice(0, 12);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
assert(slice);
|
|
const riffType = readAscii(slice, 4);
|
|
const littleEndian = riffType !== "RIFX";
|
|
const isRf64 = riffType === "RF64";
|
|
const outerChunkSize = readU32(slice, littleEndian);
|
|
let totalFileSize = isRf64 ? this.reader.fileSize : Math.min(outerChunkSize + 8, this.reader.fileSize ?? Infinity);
|
|
const format = readAscii(slice, 4);
|
|
if (format !== "WAVE") {
|
|
throw new Error("Invalid WAVE file - wrong format");
|
|
}
|
|
let chunksRead = 0;
|
|
let dataChunkSize = null;
|
|
let currentPos = slice.filePos;
|
|
while (totalFileSize === null || currentPos < totalFileSize) {
|
|
let slice2 = this.reader.requestSlice(currentPos, 8);
|
|
if (slice2 instanceof Promise) slice2 = await slice2;
|
|
if (!slice2) break;
|
|
const chunkId = readAscii(slice2, 4);
|
|
const chunkSize = readU32(slice2, littleEndian);
|
|
const startPos = slice2.filePos;
|
|
if (isRf64 && chunksRead === 0 && chunkId !== "ds64") {
|
|
throw new Error('Invalid RF64 file: First chunk must be "ds64".');
|
|
}
|
|
if (chunkId === "fmt ") {
|
|
await this.parseFmtChunk(startPos, chunkSize, littleEndian);
|
|
} else if (chunkId === "data") {
|
|
dataChunkSize ??= chunkSize;
|
|
this.dataStart = slice2.filePos;
|
|
this.dataSize = Math.min(dataChunkSize, (totalFileSize ?? Infinity) - this.dataStart);
|
|
if (this.reader.fileSize === null) {
|
|
break;
|
|
}
|
|
} else if (chunkId === "ds64") {
|
|
let ds64Slice = this.reader.requestSlice(startPos, chunkSize);
|
|
if (ds64Slice instanceof Promise) ds64Slice = await ds64Slice;
|
|
if (!ds64Slice) break;
|
|
const riffChunkSize = readU64(ds64Slice, littleEndian);
|
|
dataChunkSize = readU64(ds64Slice, littleEndian);
|
|
totalFileSize = Math.min(riffChunkSize + 8, this.reader.fileSize ?? Infinity);
|
|
} else if (chunkId === "LIST") {
|
|
await this.parseListChunk(startPos, chunkSize, littleEndian);
|
|
} else if (chunkId === "ID3 " || chunkId === "id3 ") {
|
|
await this.parseId3Chunk(startPos, chunkSize);
|
|
}
|
|
currentPos = startPos + chunkSize + (chunkSize & 1);
|
|
chunksRead++;
|
|
}
|
|
if (!this.audioInfo) {
|
|
throw new Error('Invalid WAVE file - missing "fmt " chunk');
|
|
}
|
|
if (this.dataStart === -1) {
|
|
throw new Error('Invalid WAVE file - missing "data" chunk');
|
|
}
|
|
const blockSize = this.audioInfo.blockSizeInBytes;
|
|
this.dataSize = Math.floor(this.dataSize / blockSize) * blockSize;
|
|
this.tracks.push(new InputAudioTrack(this.input, new WaveAudioTrackBacking(this)));
|
|
})();
|
|
}
|
|
async parseFmtChunk(startPos, size, littleEndian) {
|
|
let slice = this.reader.requestSlice(startPos, size);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) return;
|
|
let formatTag = readU16(slice, littleEndian);
|
|
const numChannels = readU16(slice, littleEndian);
|
|
const sampleRate = readU32(slice, littleEndian);
|
|
slice.skip(4);
|
|
const blockAlign = readU16(slice, littleEndian);
|
|
let bitsPerSample;
|
|
if (size === 14) {
|
|
bitsPerSample = 8;
|
|
} else {
|
|
bitsPerSample = readU16(slice, littleEndian);
|
|
}
|
|
if (size >= 18 && formatTag !== 357) {
|
|
const cbSize = readU16(slice, littleEndian);
|
|
const remainingSize = size - 18;
|
|
const extensionSize = Math.min(remainingSize, cbSize);
|
|
if (extensionSize >= 22 && formatTag === 65534 /* EXTENSIBLE */) {
|
|
slice.skip(2 + 4);
|
|
const subFormat = readBytes(slice, 16);
|
|
formatTag = subFormat[0] | subFormat[1] << 8;
|
|
}
|
|
}
|
|
if (formatTag === 7 /* MULAW */ || formatTag === 6 /* ALAW */) {
|
|
bitsPerSample = 8;
|
|
}
|
|
this.audioInfo = {
|
|
format: formatTag,
|
|
numberOfChannels: numChannels,
|
|
sampleRate,
|
|
sampleSizeInBytes: Math.ceil(bitsPerSample / 8),
|
|
blockSizeInBytes: blockAlign
|
|
};
|
|
}
|
|
async parseListChunk(startPos, size, littleEndian) {
|
|
let slice = this.reader.requestSlice(startPos, size);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) return;
|
|
const infoType = readAscii(slice, 4);
|
|
if (infoType !== "INFO" && infoType !== "INF0") {
|
|
return;
|
|
}
|
|
let currentPos = slice.filePos;
|
|
while (currentPos <= startPos + size - 8) {
|
|
slice.filePos = currentPos;
|
|
const chunkName = readAscii(slice, 4);
|
|
const chunkSize = readU32(slice, littleEndian);
|
|
const bytes2 = readBytes(slice, chunkSize);
|
|
let stringLength = 0;
|
|
for (let i = 0; i < bytes2.length; i++) {
|
|
if (bytes2[i] === 0) {
|
|
break;
|
|
}
|
|
stringLength++;
|
|
}
|
|
const value = String.fromCharCode(...bytes2.subarray(0, stringLength));
|
|
this.metadataTags.raw ??= {};
|
|
this.metadataTags.raw[chunkName] = value;
|
|
switch (chunkName) {
|
|
case "INAM":
|
|
case "TITL":
|
|
{
|
|
this.metadataTags.title ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "TIT3":
|
|
{
|
|
this.metadataTags.description ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "IART":
|
|
{
|
|
this.metadataTags.artist ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "IPRD":
|
|
{
|
|
this.metadataTags.album ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "IPRT":
|
|
case "ITRK":
|
|
case "TRCK":
|
|
{
|
|
const parts = value.split("/");
|
|
const trackNum = Number.parseInt(parts[0], 10);
|
|
const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);
|
|
if (Number.isInteger(trackNum) && trackNum > 0) {
|
|
this.metadataTags.trackNumber ??= trackNum;
|
|
}
|
|
if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {
|
|
this.metadataTags.tracksTotal ??= tracksTotal;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "ICRD":
|
|
case "IDIT":
|
|
{
|
|
const date = new Date(value);
|
|
if (!Number.isNaN(date.getTime())) {
|
|
this.metadataTags.date ??= date;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "YEAR":
|
|
{
|
|
const year = Number.parseInt(value, 10);
|
|
if (Number.isInteger(year) && year > 0) {
|
|
this.metadataTags.date ??= new Date(year, 0, 1);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "IGNR":
|
|
case "GENR":
|
|
{
|
|
this.metadataTags.genre ??= value;
|
|
}
|
|
;
|
|
break;
|
|
case "ICMT":
|
|
case "CMNT":
|
|
case "COMM":
|
|
{
|
|
this.metadataTags.comment ??= value;
|
|
}
|
|
;
|
|
break;
|
|
}
|
|
currentPos += 8 + chunkSize + (chunkSize & 1);
|
|
}
|
|
}
|
|
async parseId3Chunk(startPos, size) {
|
|
let slice = this.reader.requestSlice(startPos, size);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) return;
|
|
const id3V2Header = readId3V2Header(slice);
|
|
if (id3V2Header) {
|
|
const contentSlice = slice.slice(startPos + 10, id3V2Header.size);
|
|
parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
|
|
}
|
|
}
|
|
getCodec() {
|
|
assert(this.audioInfo);
|
|
if (this.audioInfo.format === 7 /* MULAW */) {
|
|
return "ulaw";
|
|
}
|
|
if (this.audioInfo.format === 6 /* ALAW */) {
|
|
return "alaw";
|
|
}
|
|
if (this.audioInfo.format === 1 /* PCM */) {
|
|
if (this.audioInfo.sampleSizeInBytes === 1) {
|
|
return "pcm-u8";
|
|
} else if (this.audioInfo.sampleSizeInBytes === 2) {
|
|
return "pcm-s16";
|
|
} else if (this.audioInfo.sampleSizeInBytes === 3) {
|
|
return "pcm-s24";
|
|
} else if (this.audioInfo.sampleSizeInBytes === 4) {
|
|
return "pcm-s32";
|
|
}
|
|
}
|
|
if (this.audioInfo.format === 3 /* IEEE_FLOAT */) {
|
|
if (this.audioInfo.sampleSizeInBytes === 4) {
|
|
return "pcm-f32";
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
async getMimeType() {
|
|
return "audio/wav";
|
|
}
|
|
async computeDuration() {
|
|
await this.readMetadata();
|
|
const track = this.tracks[0];
|
|
assert(track);
|
|
return track.computeDuration();
|
|
}
|
|
async getTracks() {
|
|
await this.readMetadata();
|
|
return this.tracks;
|
|
}
|
|
async getMetadataTags() {
|
|
await this.readMetadata();
|
|
return this.metadataTags;
|
|
}
|
|
};
|
|
var PACKET_SIZE_IN_FRAMES = 2048;
|
|
var WaveAudioTrackBacking = class {
|
|
constructor(demuxer) {
|
|
this.demuxer = demuxer;
|
|
}
|
|
getId() {
|
|
return 1;
|
|
}
|
|
getNumber() {
|
|
return 1;
|
|
}
|
|
getCodec() {
|
|
return this.demuxer.getCodec();
|
|
}
|
|
getInternalCodecId() {
|
|
assert(this.demuxer.audioInfo);
|
|
return this.demuxer.audioInfo.format;
|
|
}
|
|
async getDecoderConfig() {
|
|
const codec = this.demuxer.getCodec();
|
|
if (!codec) {
|
|
return null;
|
|
}
|
|
assert(this.demuxer.audioInfo);
|
|
return {
|
|
codec,
|
|
numberOfChannels: this.demuxer.audioInfo.numberOfChannels,
|
|
sampleRate: this.demuxer.audioInfo.sampleRate
|
|
};
|
|
}
|
|
async computeDuration() {
|
|
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
|
|
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
|
|
}
|
|
getNumberOfChannels() {
|
|
assert(this.demuxer.audioInfo);
|
|
return this.demuxer.audioInfo.numberOfChannels;
|
|
}
|
|
getSampleRate() {
|
|
assert(this.demuxer.audioInfo);
|
|
return this.demuxer.audioInfo.sampleRate;
|
|
}
|
|
getTimeResolution() {
|
|
assert(this.demuxer.audioInfo);
|
|
return this.demuxer.audioInfo.sampleRate;
|
|
}
|
|
getName() {
|
|
return null;
|
|
}
|
|
getLanguageCode() {
|
|
return UNDETERMINED_LANGUAGE;
|
|
}
|
|
getDisposition() {
|
|
return {
|
|
...DEFAULT_TRACK_DISPOSITION
|
|
};
|
|
}
|
|
async getFirstTimestamp() {
|
|
return 0;
|
|
}
|
|
async getPacketAtIndex(packetIndex, options) {
|
|
assert(packetIndex >= 0);
|
|
assert(this.demuxer.audioInfo);
|
|
const startOffset = packetIndex * PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes;
|
|
if (startOffset >= this.demuxer.dataSize) {
|
|
return null;
|
|
}
|
|
const sizeInBytes = Math.min(
|
|
PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes,
|
|
this.demuxer.dataSize - startOffset
|
|
);
|
|
if (this.demuxer.reader.fileSize === null) {
|
|
let slice = this.demuxer.reader.requestSlice(this.demuxer.dataStart + startOffset, sizeInBytes);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) {
|
|
return null;
|
|
}
|
|
}
|
|
let data;
|
|
if (options.metadataOnly) {
|
|
data = PLACEHOLDER_DATA;
|
|
} else {
|
|
let slice = this.demuxer.reader.requestSlice(this.demuxer.dataStart + startOffset, sizeInBytes);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
assert(slice);
|
|
data = readBytes(slice, sizeInBytes);
|
|
}
|
|
const timestamp = packetIndex * PACKET_SIZE_IN_FRAMES / this.demuxer.audioInfo.sampleRate;
|
|
const duration = sizeInBytes / this.demuxer.audioInfo.blockSizeInBytes / this.demuxer.audioInfo.sampleRate;
|
|
this.demuxer.lastKnownPacketIndex = Math.max(
|
|
packetIndex,
|
|
this.demuxer.lastKnownPacketIndex
|
|
);
|
|
return new EncodedPacket(
|
|
data,
|
|
"key",
|
|
timestamp,
|
|
duration,
|
|
packetIndex,
|
|
sizeInBytes
|
|
);
|
|
}
|
|
getFirstPacket(options) {
|
|
return this.getPacketAtIndex(0, options);
|
|
}
|
|
async getPacket(timestamp, options) {
|
|
assert(this.demuxer.audioInfo);
|
|
const packetIndex = Math.floor(Math.min(
|
|
timestamp * this.demuxer.audioInfo.sampleRate / PACKET_SIZE_IN_FRAMES,
|
|
(this.demuxer.dataSize - 1) / (PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes)
|
|
));
|
|
if (packetIndex < 0) {
|
|
return null;
|
|
}
|
|
const packet = await this.getPacketAtIndex(packetIndex, options);
|
|
if (packet) {
|
|
return packet;
|
|
}
|
|
if (packetIndex === 0) {
|
|
return null;
|
|
}
|
|
assert(this.demuxer.reader.fileSize === null);
|
|
let currentPacket = await this.getPacketAtIndex(this.demuxer.lastKnownPacketIndex, options);
|
|
while (currentPacket) {
|
|
const nextPacket = await this.getNextPacket(currentPacket, options);
|
|
if (!nextPacket) {
|
|
break;
|
|
}
|
|
currentPacket = nextPacket;
|
|
}
|
|
return currentPacket;
|
|
}
|
|
getNextPacket(packet, options) {
|
|
assert(this.demuxer.audioInfo);
|
|
const packetIndex = Math.round(packet.timestamp * this.demuxer.audioInfo.sampleRate / PACKET_SIZE_IN_FRAMES);
|
|
return this.getPacketAtIndex(packetIndex + 1, options);
|
|
}
|
|
getKeyPacket(timestamp, options) {
|
|
return this.getPacket(timestamp, options);
|
|
}
|
|
getNextKeyPacket(packet, options) {
|
|
return this.getNextPacket(packet, options);
|
|
}
|
|
};
|
|
|
|
// src/adts/adts-reader.ts
|
|
var MIN_ADTS_FRAME_HEADER_SIZE = 7;
|
|
var MAX_ADTS_FRAME_HEADER_SIZE = 9;
|
|
var readAdtsFrameHeader = (slice) => {
|
|
const startPos = slice.filePos;
|
|
const bytes2 = readBytes(slice, 9);
|
|
const bitstream = new Bitstream(bytes2);
|
|
const syncword = bitstream.readBits(12);
|
|
if (syncword !== 4095) {
|
|
return null;
|
|
}
|
|
bitstream.skipBits(1);
|
|
const layer = bitstream.readBits(2);
|
|
if (layer !== 0) {
|
|
return null;
|
|
}
|
|
const protectionAbsence = bitstream.readBits(1);
|
|
const objectType = bitstream.readBits(2) + 1;
|
|
const samplingFrequencyIndex = bitstream.readBits(4);
|
|
if (samplingFrequencyIndex === 15) {
|
|
return null;
|
|
}
|
|
bitstream.skipBits(1);
|
|
const channelConfiguration = bitstream.readBits(3);
|
|
if (channelConfiguration === 0) {
|
|
throw new Error("ADTS frames with channel configuration 0 are not supported.");
|
|
}
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
bitstream.skipBits(1);
|
|
const frameLength = bitstream.readBits(13);
|
|
bitstream.skipBits(11);
|
|
const numberOfAacFrames = bitstream.readBits(2) + 1;
|
|
if (numberOfAacFrames !== 1) {
|
|
throw new Error("ADTS frames with more than one AAC frame are not supported.");
|
|
}
|
|
let crcCheck = null;
|
|
if (protectionAbsence === 1) {
|
|
slice.filePos -= 2;
|
|
} else {
|
|
crcCheck = bitstream.readBits(16);
|
|
}
|
|
return {
|
|
objectType,
|
|
samplingFrequencyIndex,
|
|
channelConfiguration,
|
|
frameLength,
|
|
numberOfAacFrames,
|
|
crcCheck,
|
|
startPos
|
|
};
|
|
};
|
|
|
|
// src/adts/adts-demuxer.ts
|
|
var SAMPLES_PER_AAC_FRAME = 1024;
|
|
var AdtsDemuxer = class extends Demuxer {
|
|
constructor(input) {
|
|
super(input);
|
|
this.metadataPromise = null;
|
|
this.firstFrameHeader = null;
|
|
this.loadedSamples = [];
|
|
this.metadataTags = null;
|
|
this.tracks = [];
|
|
this.readingMutex = new AsyncMutex();
|
|
this.lastSampleLoaded = false;
|
|
this.lastLoadedPos = 0;
|
|
this.nextTimestampInSamples = 0;
|
|
this.reader = input._reader;
|
|
}
|
|
async readMetadata() {
|
|
return this.metadataPromise ??= (async () => {
|
|
while (!this.firstFrameHeader && !this.lastSampleLoaded) {
|
|
await this.advanceReader();
|
|
}
|
|
assert(this.firstFrameHeader);
|
|
this.tracks = [new InputAudioTrack(this.input, new AdtsAudioTrackBacking(this))];
|
|
})();
|
|
}
|
|
async advanceReader() {
|
|
if (this.lastLoadedPos === 0) {
|
|
while (true) {
|
|
let slice2 = this.reader.requestSlice(this.lastLoadedPos, ID3_V2_HEADER_SIZE);
|
|
if (slice2 instanceof Promise) slice2 = await slice2;
|
|
if (!slice2) {
|
|
this.lastSampleLoaded = true;
|
|
return;
|
|
}
|
|
const id3V2Header = readId3V2Header(slice2);
|
|
if (!id3V2Header) {
|
|
break;
|
|
}
|
|
this.lastLoadedPos = slice2.filePos + id3V2Header.size;
|
|
}
|
|
}
|
|
let slice = this.reader.requestSliceRange(
|
|
this.lastLoadedPos,
|
|
MIN_ADTS_FRAME_HEADER_SIZE,
|
|
MAX_ADTS_FRAME_HEADER_SIZE
|
|
);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) {
|
|
this.lastSampleLoaded = true;
|
|
return;
|
|
}
|
|
const header = readAdtsFrameHeader(slice);
|
|
if (!header) {
|
|
this.lastSampleLoaded = true;
|
|
return;
|
|
}
|
|
if (this.reader.fileSize !== null && header.startPos + header.frameLength > this.reader.fileSize) {
|
|
this.lastSampleLoaded = true;
|
|
return;
|
|
}
|
|
if (!this.firstFrameHeader) {
|
|
this.firstFrameHeader = header;
|
|
}
|
|
const sampleRate = aacFrequencyTable[header.samplingFrequencyIndex];
|
|
assert(sampleRate !== void 0);
|
|
const sampleDuration = SAMPLES_PER_AAC_FRAME / sampleRate;
|
|
const sample = {
|
|
timestamp: this.nextTimestampInSamples / sampleRate,
|
|
duration: sampleDuration,
|
|
dataStart: header.startPos,
|
|
dataSize: header.frameLength
|
|
};
|
|
this.loadedSamples.push(sample);
|
|
this.nextTimestampInSamples += SAMPLES_PER_AAC_FRAME;
|
|
this.lastLoadedPos = header.startPos + header.frameLength;
|
|
}
|
|
async getMimeType() {
|
|
return "audio/aac";
|
|
}
|
|
async getTracks() {
|
|
await this.readMetadata();
|
|
return this.tracks;
|
|
}
|
|
async computeDuration() {
|
|
await this.readMetadata();
|
|
const track = this.tracks[0];
|
|
assert(track);
|
|
return track.computeDuration();
|
|
}
|
|
async getMetadataTags() {
|
|
const release = await this.readingMutex.acquire();
|
|
try {
|
|
await this.readMetadata();
|
|
if (this.metadataTags) {
|
|
return this.metadataTags;
|
|
}
|
|
this.metadataTags = {};
|
|
let currentPos = 0;
|
|
while (true) {
|
|
let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
|
|
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
|
|
if (!headerSlice) break;
|
|
const id3V2Header = readId3V2Header(headerSlice);
|
|
if (!id3V2Header) {
|
|
break;
|
|
}
|
|
let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size);
|
|
if (contentSlice instanceof Promise) contentSlice = await contentSlice;
|
|
if (!contentSlice) break;
|
|
parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
|
|
currentPos = headerSlice.filePos + id3V2Header.size;
|
|
}
|
|
return this.metadataTags;
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
};
|
|
var AdtsAudioTrackBacking = class {
|
|
constructor(demuxer) {
|
|
this.demuxer = demuxer;
|
|
}
|
|
getId() {
|
|
return 1;
|
|
}
|
|
getNumber() {
|
|
return 1;
|
|
}
|
|
async getFirstTimestamp() {
|
|
return 0;
|
|
}
|
|
getTimeResolution() {
|
|
const sampleRate = this.getSampleRate();
|
|
return sampleRate / SAMPLES_PER_AAC_FRAME;
|
|
}
|
|
async computeDuration() {
|
|
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
|
|
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
|
|
}
|
|
getName() {
|
|
return null;
|
|
}
|
|
getLanguageCode() {
|
|
return UNDETERMINED_LANGUAGE;
|
|
}
|
|
getCodec() {
|
|
return "aac";
|
|
}
|
|
getInternalCodecId() {
|
|
assert(this.demuxer.firstFrameHeader);
|
|
return this.demuxer.firstFrameHeader.objectType;
|
|
}
|
|
getNumberOfChannels() {
|
|
assert(this.demuxer.firstFrameHeader);
|
|
const numberOfChannels = aacChannelMap[this.demuxer.firstFrameHeader.channelConfiguration];
|
|
assert(numberOfChannels !== void 0);
|
|
return numberOfChannels;
|
|
}
|
|
getSampleRate() {
|
|
assert(this.demuxer.firstFrameHeader);
|
|
const sampleRate = aacFrequencyTable[this.demuxer.firstFrameHeader.samplingFrequencyIndex];
|
|
assert(sampleRate !== void 0);
|
|
return sampleRate;
|
|
}
|
|
getDisposition() {
|
|
return {
|
|
...DEFAULT_TRACK_DISPOSITION
|
|
};
|
|
}
|
|
async getDecoderConfig() {
|
|
assert(this.demuxer.firstFrameHeader);
|
|
return {
|
|
codec: `mp4a.40.${this.demuxer.firstFrameHeader.objectType}`,
|
|
numberOfChannels: this.getNumberOfChannels(),
|
|
sampleRate: this.getSampleRate()
|
|
};
|
|
}
|
|
async getPacketAtIndex(sampleIndex, options) {
|
|
if (sampleIndex === -1) {
|
|
return null;
|
|
}
|
|
const rawSample = this.demuxer.loadedSamples[sampleIndex];
|
|
if (!rawSample) {
|
|
return null;
|
|
}
|
|
let data;
|
|
if (options.metadataOnly) {
|
|
data = PLACEHOLDER_DATA;
|
|
} else {
|
|
let slice = this.demuxer.reader.requestSlice(rawSample.dataStart, rawSample.dataSize);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) {
|
|
return null;
|
|
}
|
|
data = readBytes(slice, rawSample.dataSize);
|
|
}
|
|
return new EncodedPacket(
|
|
data,
|
|
"key",
|
|
rawSample.timestamp,
|
|
rawSample.duration,
|
|
sampleIndex,
|
|
rawSample.dataSize
|
|
);
|
|
}
|
|
getFirstPacket(options) {
|
|
return this.getPacketAtIndex(0, options);
|
|
}
|
|
async getNextPacket(packet, options) {
|
|
const release = await this.demuxer.readingMutex.acquire();
|
|
try {
|
|
const sampleIndex = binarySearchExact(
|
|
this.demuxer.loadedSamples,
|
|
packet.timestamp,
|
|
(x) => x.timestamp
|
|
);
|
|
if (sampleIndex === -1) {
|
|
throw new Error("Packet was not created from this track.");
|
|
}
|
|
const nextIndex = sampleIndex + 1;
|
|
while (nextIndex >= this.demuxer.loadedSamples.length && !this.demuxer.lastSampleLoaded) {
|
|
await this.demuxer.advanceReader();
|
|
}
|
|
return this.getPacketAtIndex(nextIndex, options);
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async getPacket(timestamp, options) {
|
|
const release = await this.demuxer.readingMutex.acquire();
|
|
try {
|
|
while (true) {
|
|
const index = binarySearchLessOrEqual(
|
|
this.demuxer.loadedSamples,
|
|
timestamp,
|
|
(x) => x.timestamp
|
|
);
|
|
if (index === -1 && this.demuxer.loadedSamples.length > 0) {
|
|
return null;
|
|
}
|
|
if (this.demuxer.lastSampleLoaded) {
|
|
return this.getPacketAtIndex(index, options);
|
|
}
|
|
if (index >= 0 && index + 1 < this.demuxer.loadedSamples.length) {
|
|
return this.getPacketAtIndex(index, options);
|
|
}
|
|
await this.demuxer.advanceReader();
|
|
}
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
getKeyPacket(timestamp, options) {
|
|
return this.getPacket(timestamp, options);
|
|
}
|
|
getNextKeyPacket(packet, options) {
|
|
return this.getNextPacket(packet, options);
|
|
}
|
|
};
|
|
|
|
// src/flac/flac-misc.ts
|
|
var getBlockSizeOrUncommon = (bits) => {
|
|
if (bits === 0) {
|
|
return null;
|
|
} else if (bits === 1) {
|
|
return 192;
|
|
} else if (bits >= 2 && bits <= 5) {
|
|
return 144 * 2 ** bits;
|
|
} else if (bits === 6) {
|
|
return "uncommon-u8";
|
|
} else if (bits === 7) {
|
|
return "uncommon-u16";
|
|
} else if (bits >= 8 && bits <= 15) {
|
|
return 2 ** bits;
|
|
} else {
|
|
return null;
|
|
}
|
|
};
|
|
var getSampleRateOrUncommon = (sampleRateBits, streamInfoSampleRate) => {
|
|
switch (sampleRateBits) {
|
|
case 0:
|
|
return streamInfoSampleRate;
|
|
case 1:
|
|
return 88200;
|
|
case 2:
|
|
return 176400;
|
|
case 3:
|
|
return 192e3;
|
|
case 4:
|
|
return 8e3;
|
|
case 5:
|
|
return 16e3;
|
|
case 6:
|
|
return 22050;
|
|
case 7:
|
|
return 24e3;
|
|
case 8:
|
|
return 32e3;
|
|
case 9:
|
|
return 44100;
|
|
case 10:
|
|
return 48e3;
|
|
case 11:
|
|
return 96e3;
|
|
case 12:
|
|
return "uncommon-u8";
|
|
case 13:
|
|
return "uncommon-u16";
|
|
case 14:
|
|
return "uncommon-u16-10";
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
var readCodedNumber = (fileSlice) => {
|
|
let ones = 0;
|
|
const bitstream1 = new Bitstream(readBytes(fileSlice, 1));
|
|
while (bitstream1.readBits(1) === 1) {
|
|
ones++;
|
|
}
|
|
if (ones === 0) {
|
|
return bitstream1.readBits(7);
|
|
}
|
|
const bitArray = [];
|
|
const extraBytes = ones - 1;
|
|
const bitstream2 = new Bitstream(readBytes(fileSlice, extraBytes));
|
|
const firstByteBits = 8 - ones - 1;
|
|
for (let i = 0; i < firstByteBits; i++) {
|
|
bitArray.unshift(bitstream1.readBits(1));
|
|
}
|
|
for (let i = 0; i < extraBytes; i++) {
|
|
for (let j = 0; j < 8; j++) {
|
|
const val = bitstream2.readBits(1);
|
|
if (j < 2) {
|
|
continue;
|
|
}
|
|
bitArray.unshift(val);
|
|
}
|
|
}
|
|
const encoded = bitArray.reduce((acc, bit, index) => {
|
|
return acc | bit << index;
|
|
}, 0);
|
|
return encoded;
|
|
};
|
|
var readBlockSize = (slice, blockSizeBits) => {
|
|
if (blockSizeBits === "uncommon-u16") {
|
|
return readU16Be(slice) + 1;
|
|
} else if (blockSizeBits === "uncommon-u8") {
|
|
return readU8(slice) + 1;
|
|
} else if (typeof blockSizeBits === "number") {
|
|
return blockSizeBits;
|
|
} else {
|
|
assertNever(blockSizeBits);
|
|
assert(false);
|
|
}
|
|
};
|
|
var readSampleRate = (slice, sampleRateOrUncommon) => {
|
|
if (sampleRateOrUncommon === "uncommon-u16") {
|
|
return readU16Be(slice);
|
|
}
|
|
if (sampleRateOrUncommon === "uncommon-u16-10") {
|
|
return readU16Be(slice) * 10;
|
|
}
|
|
if (sampleRateOrUncommon === "uncommon-u8") {
|
|
return readU8(slice);
|
|
}
|
|
if (typeof sampleRateOrUncommon === "number") {
|
|
return sampleRateOrUncommon;
|
|
}
|
|
return null;
|
|
};
|
|
var calculateCrc8 = (data) => {
|
|
const polynomial = 7;
|
|
let crc = 0;
|
|
for (const byte of data) {
|
|
crc ^= byte;
|
|
for (let i = 0; i < 8; i++) {
|
|
if ((crc & 128) !== 0) {
|
|
crc = crc << 1 ^ polynomial;
|
|
} else {
|
|
crc <<= 1;
|
|
}
|
|
crc &= 255;
|
|
}
|
|
}
|
|
return crc;
|
|
};
|
|
|
|
// src/flac/flac-demuxer.ts
|
|
var FlacDemuxer = class extends Demuxer {
|
|
constructor(input) {
|
|
super(input);
|
|
this.loadedSamples = [];
|
|
// All samples from the start of the file to lastLoadedPos
|
|
this.metadataPromise = null;
|
|
this.track = null;
|
|
this.metadataTags = {};
|
|
this.audioInfo = null;
|
|
this.lastLoadedPos = null;
|
|
this.blockingBit = null;
|
|
this.readingMutex = new AsyncMutex();
|
|
this.lastSampleLoaded = false;
|
|
this.reader = input._reader;
|
|
}
|
|
async computeDuration() {
|
|
await this.readMetadata();
|
|
assert(this.track);
|
|
return this.track.computeDuration();
|
|
}
|
|
async getMetadataTags() {
|
|
await this.readMetadata();
|
|
return this.metadataTags;
|
|
}
|
|
async getTracks() {
|
|
await this.readMetadata();
|
|
assert(this.track);
|
|
return [this.track];
|
|
}
|
|
async getMimeType() {
|
|
return "audio/flac";
|
|
}
|
|
async readMetadata() {
|
|
let currentPos = 4;
|
|
return this.metadataPromise ??= (async () => {
|
|
while (this.reader.fileSize === null || currentPos < this.reader.fileSize) {
|
|
let sizeSlice = this.reader.requestSlice(currentPos, 4);
|
|
if (sizeSlice instanceof Promise) sizeSlice = await sizeSlice;
|
|
currentPos += 4;
|
|
if (sizeSlice === null) {
|
|
throw new Error(
|
|
`Metadata block at position ${currentPos} is too small! Corrupted file.`
|
|
);
|
|
}
|
|
assert(sizeSlice);
|
|
const byte = readU8(sizeSlice);
|
|
const size = readU24Be(sizeSlice);
|
|
const isLastMetadata = (byte & 128) !== 0;
|
|
const metaBlockType = byte & 127;
|
|
switch (metaBlockType) {
|
|
case 0 /* STREAMINFO */: {
|
|
let streamInfoBlock = this.reader.requestSlice(
|
|
currentPos,
|
|
size
|
|
);
|
|
if (streamInfoBlock instanceof Promise) streamInfoBlock = await streamInfoBlock;
|
|
assert(streamInfoBlock);
|
|
if (streamInfoBlock === null) {
|
|
throw new Error(
|
|
`StreamInfo block at position ${currentPos} is too small! Corrupted file.`
|
|
);
|
|
}
|
|
const streamInfoBytes = readBytes(streamInfoBlock, 34);
|
|
const bitstream = new Bitstream(streamInfoBytes);
|
|
const minimumBlockSize = bitstream.readBits(16);
|
|
const maximumBlockSize = bitstream.readBits(16);
|
|
const minimumFrameSize = bitstream.readBits(24);
|
|
const maximumFrameSize = bitstream.readBits(24);
|
|
const sampleRate = bitstream.readBits(20);
|
|
const numberOfChannels = bitstream.readBits(3) + 1;
|
|
bitstream.readBits(5);
|
|
const totalSamples = bitstream.readBits(36);
|
|
bitstream.skipBits(16 * 8);
|
|
const description = new Uint8Array(42);
|
|
description.set(new Uint8Array([102, 76, 97, 67]), 0);
|
|
description.set(new Uint8Array([128, 0, 0, 34]), 4);
|
|
description.set(streamInfoBytes, 8);
|
|
this.audioInfo = {
|
|
numberOfChannels,
|
|
sampleRate,
|
|
totalSamples,
|
|
minimumBlockSize,
|
|
maximumBlockSize,
|
|
minimumFrameSize,
|
|
maximumFrameSize,
|
|
description
|
|
};
|
|
this.track = new InputAudioTrack(this.input, new FlacAudioTrackBacking(this));
|
|
break;
|
|
}
|
|
case 4 /* VORBIS_COMMENT */: {
|
|
let vorbisCommentBlock = this.reader.requestSlice(
|
|
currentPos,
|
|
size
|
|
);
|
|
if (vorbisCommentBlock instanceof Promise) vorbisCommentBlock = await vorbisCommentBlock;
|
|
assert(vorbisCommentBlock);
|
|
readVorbisComments(
|
|
readBytes(vorbisCommentBlock, size),
|
|
this.metadataTags
|
|
);
|
|
break;
|
|
}
|
|
case 6 /* PICTURE */: {
|
|
let pictureBlock = this.reader.requestSlice(
|
|
currentPos,
|
|
size
|
|
);
|
|
if (pictureBlock instanceof Promise) pictureBlock = await pictureBlock;
|
|
assert(pictureBlock);
|
|
const pictureType = readU32Be(pictureBlock);
|
|
const mediaTypeLength = readU32Be(pictureBlock);
|
|
const mediaType = textDecoder.decode(
|
|
readBytes(pictureBlock, mediaTypeLength)
|
|
);
|
|
const descriptionLength = readU32Be(pictureBlock);
|
|
const description = textDecoder.decode(
|
|
readBytes(pictureBlock, descriptionLength)
|
|
);
|
|
pictureBlock.skip(4 + 4 + 4 + 4);
|
|
const dataLength = readU32Be(pictureBlock);
|
|
const data = readBytes(pictureBlock, dataLength);
|
|
this.metadataTags.images ??= [];
|
|
this.metadataTags.images.push({
|
|
data,
|
|
mimeType: mediaType,
|
|
// https://www.rfc-editor.org/rfc/rfc9639.html#table13
|
|
kind: pictureType === 3 ? "coverFront" : pictureType === 4 ? "coverBack" : "unknown",
|
|
description
|
|
});
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
currentPos += size;
|
|
if (isLastMetadata) {
|
|
this.lastLoadedPos = currentPos;
|
|
break;
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
async readNextFlacFrame({
|
|
startPos,
|
|
isFirstPacket
|
|
}) {
|
|
assert(this.audioInfo);
|
|
const minimumHeaderLength = 6;
|
|
const maximumHeaderSize = 16;
|
|
const maximumSliceLength = this.audioInfo.maximumFrameSize + maximumHeaderSize;
|
|
const slice = await this.reader.requestSliceRange(
|
|
startPos,
|
|
this.audioInfo.minimumFrameSize,
|
|
maximumSliceLength
|
|
);
|
|
if (!slice) {
|
|
return null;
|
|
}
|
|
const frameHeader = this.readFlacFrameHeader({
|
|
slice,
|
|
isFirstPacket
|
|
});
|
|
if (!frameHeader) {
|
|
return null;
|
|
}
|
|
slice.filePos = startPos + this.audioInfo.minimumFrameSize;
|
|
while (true) {
|
|
if (slice.filePos > slice.end - minimumHeaderLength) {
|
|
return {
|
|
num: frameHeader.num,
|
|
blockSize: frameHeader.blockSize,
|
|
sampleRate: frameHeader.sampleRate,
|
|
size: slice.end - startPos,
|
|
isLastFrame: true
|
|
};
|
|
}
|
|
const nextByte = readU8(slice);
|
|
if (nextByte === 255) {
|
|
const positionBeforeReading = slice.filePos;
|
|
const byteAfterNextByte = readU8(slice);
|
|
const expected = this.blockingBit === 1 ? 249 : 248;
|
|
if (byteAfterNextByte !== expected) {
|
|
slice.filePos = positionBeforeReading;
|
|
continue;
|
|
}
|
|
slice.skip(-2);
|
|
const lengthIfNextFlacFrameHeaderIsLegit = slice.filePos - startPos;
|
|
const nextFrameHeader = this.readFlacFrameHeader({
|
|
slice,
|
|
isFirstPacket: false
|
|
});
|
|
if (!nextFrameHeader) {
|
|
slice.filePos = positionBeforeReading;
|
|
continue;
|
|
}
|
|
if (this.blockingBit === 0) {
|
|
if (nextFrameHeader.num - frameHeader.num !== 1) {
|
|
slice.filePos = positionBeforeReading;
|
|
continue;
|
|
}
|
|
} else {
|
|
if (nextFrameHeader.num - frameHeader.num !== frameHeader.blockSize) {
|
|
slice.filePos = positionBeforeReading;
|
|
continue;
|
|
}
|
|
}
|
|
return {
|
|
num: frameHeader.num,
|
|
blockSize: frameHeader.blockSize,
|
|
sampleRate: frameHeader.sampleRate,
|
|
size: lengthIfNextFlacFrameHeaderIsLegit,
|
|
isLastFrame: false
|
|
};
|
|
}
|
|
}
|
|
}
|
|
readFlacFrameHeader({
|
|
slice,
|
|
isFirstPacket
|
|
}) {
|
|
const startOffset = slice.filePos;
|
|
const bytes2 = readBytes(slice, 4);
|
|
const bitstream = new Bitstream(bytes2);
|
|
const bits = bitstream.readBits(15);
|
|
if (bits !== 32764) {
|
|
return null;
|
|
}
|
|
if (this.blockingBit === null) {
|
|
assert(isFirstPacket);
|
|
const newBlockingBit = bitstream.readBits(1);
|
|
this.blockingBit = newBlockingBit;
|
|
} else if (this.blockingBit === 1) {
|
|
assert(!isFirstPacket);
|
|
const newBlockingBit = bitstream.readBits(1);
|
|
if (newBlockingBit !== 1) {
|
|
return null;
|
|
}
|
|
} else if (this.blockingBit === 0) {
|
|
assert(!isFirstPacket);
|
|
const newBlockingBit = bitstream.readBits(1);
|
|
if (newBlockingBit !== 0) {
|
|
return null;
|
|
}
|
|
} else {
|
|
throw new Error("Invalid blocking bit");
|
|
}
|
|
const blockSizeOrUncommon = getBlockSizeOrUncommon(bitstream.readBits(4));
|
|
if (!blockSizeOrUncommon) {
|
|
return null;
|
|
}
|
|
assert(this.audioInfo);
|
|
const sampleRateOrUncommon = getSampleRateOrUncommon(
|
|
bitstream.readBits(4),
|
|
this.audioInfo.sampleRate
|
|
);
|
|
if (!sampleRateOrUncommon) {
|
|
return null;
|
|
}
|
|
bitstream.readBits(4);
|
|
bitstream.readBits(3);
|
|
const reservedZero = bitstream.readBits(1);
|
|
if (reservedZero !== 0) {
|
|
return null;
|
|
}
|
|
const num = readCodedNumber(slice);
|
|
const blockSize = readBlockSize(slice, blockSizeOrUncommon);
|
|
const sampleRate = readSampleRate(slice, sampleRateOrUncommon);
|
|
if (sampleRate === null) {
|
|
return null;
|
|
}
|
|
if (sampleRate !== this.audioInfo.sampleRate) {
|
|
return null;
|
|
}
|
|
const size = slice.filePos - startOffset;
|
|
const crc = readU8(slice);
|
|
slice.skip(-size);
|
|
slice.skip(-1);
|
|
const crcCalculated = calculateCrc8(readBytes(slice, size));
|
|
if (crc !== crcCalculated) {
|
|
return null;
|
|
}
|
|
return { num, blockSize, sampleRate };
|
|
}
|
|
async advanceReader() {
|
|
await this.readMetadata();
|
|
assert(this.lastLoadedPos !== null);
|
|
assert(this.audioInfo);
|
|
const startPos = this.lastLoadedPos;
|
|
const frame = await this.readNextFlacFrame({
|
|
startPos,
|
|
isFirstPacket: this.loadedSamples.length === 0
|
|
});
|
|
if (!frame) {
|
|
this.lastSampleLoaded = true;
|
|
return;
|
|
}
|
|
const lastSample = this.loadedSamples[this.loadedSamples.length - 1];
|
|
const blockOffset = lastSample ? lastSample.blockOffset + lastSample.blockSize : 0;
|
|
const sample = {
|
|
blockOffset,
|
|
blockSize: frame.blockSize,
|
|
byteOffset: startPos,
|
|
byteSize: frame.size
|
|
};
|
|
this.lastLoadedPos = this.lastLoadedPos + frame.size;
|
|
this.loadedSamples.push(sample);
|
|
if (frame.isLastFrame) {
|
|
this.lastSampleLoaded = true;
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
var FlacAudioTrackBacking = class {
|
|
constructor(demuxer) {
|
|
this.demuxer = demuxer;
|
|
}
|
|
getId() {
|
|
return 1;
|
|
}
|
|
getNumber() {
|
|
return 1;
|
|
}
|
|
getCodec() {
|
|
return "flac";
|
|
}
|
|
getInternalCodecId() {
|
|
return null;
|
|
}
|
|
getNumberOfChannels() {
|
|
assert(this.demuxer.audioInfo);
|
|
return this.demuxer.audioInfo.numberOfChannels;
|
|
}
|
|
async computeDuration() {
|
|
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
|
|
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
|
|
}
|
|
getSampleRate() {
|
|
assert(this.demuxer.audioInfo);
|
|
return this.demuxer.audioInfo.sampleRate;
|
|
}
|
|
getName() {
|
|
return null;
|
|
}
|
|
getLanguageCode() {
|
|
return UNDETERMINED_LANGUAGE;
|
|
}
|
|
getTimeResolution() {
|
|
assert(this.demuxer.audioInfo);
|
|
return this.demuxer.audioInfo.sampleRate;
|
|
}
|
|
getDisposition() {
|
|
return {
|
|
...DEFAULT_TRACK_DISPOSITION
|
|
};
|
|
}
|
|
async getFirstTimestamp() {
|
|
return 0;
|
|
}
|
|
async getDecoderConfig() {
|
|
assert(this.demuxer.audioInfo);
|
|
return {
|
|
codec: "flac",
|
|
numberOfChannels: this.demuxer.audioInfo.numberOfChannels,
|
|
sampleRate: this.demuxer.audioInfo.sampleRate,
|
|
description: this.demuxer.audioInfo.description
|
|
};
|
|
}
|
|
async getPacket(timestamp, options) {
|
|
assert(this.demuxer.audioInfo);
|
|
if (timestamp < 0) {
|
|
throw new Error("Timestamp cannot be negative");
|
|
}
|
|
const release = await this.demuxer.readingMutex.acquire();
|
|
try {
|
|
while (true) {
|
|
const packetIndex = binarySearchLessOrEqual(
|
|
this.demuxer.loadedSamples,
|
|
timestamp,
|
|
(x) => x.blockOffset / this.demuxer.audioInfo.sampleRate
|
|
);
|
|
if (packetIndex === -1) {
|
|
await this.demuxer.advanceReader();
|
|
continue;
|
|
}
|
|
const packet = this.demuxer.loadedSamples[packetIndex];
|
|
const sampleTimestamp = packet.blockOffset / this.demuxer.audioInfo.sampleRate;
|
|
const sampleDuration = packet.blockSize / this.demuxer.audioInfo.sampleRate;
|
|
if (sampleTimestamp + sampleDuration <= timestamp) {
|
|
if (this.demuxer.lastSampleLoaded) {
|
|
return this.getPacketAtIndex(
|
|
this.demuxer.loadedSamples.length - 1,
|
|
options
|
|
);
|
|
}
|
|
await this.demuxer.advanceReader();
|
|
continue;
|
|
}
|
|
return this.getPacketAtIndex(packetIndex, options);
|
|
}
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async getNextPacket(packet, options) {
|
|
const release = await this.demuxer.readingMutex.acquire();
|
|
try {
|
|
const nextIndex = packet.sequenceNumber + 1;
|
|
if (this.demuxer.lastSampleLoaded && nextIndex >= this.demuxer.loadedSamples.length) {
|
|
return null;
|
|
}
|
|
while (nextIndex >= this.demuxer.loadedSamples.length && !this.demuxer.lastSampleLoaded) {
|
|
await this.demuxer.advanceReader();
|
|
}
|
|
return this.getPacketAtIndex(nextIndex, options);
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
getKeyPacket(timestamp, options) {
|
|
return this.getPacket(timestamp, options);
|
|
}
|
|
getNextKeyPacket(packet, options) {
|
|
return this.getNextPacket(packet, options);
|
|
}
|
|
async getPacketAtIndex(sampleIndex, options) {
|
|
const rawSample = this.demuxer.loadedSamples[sampleIndex];
|
|
if (!rawSample) {
|
|
return null;
|
|
}
|
|
let data;
|
|
if (options.metadataOnly) {
|
|
data = PLACEHOLDER_DATA;
|
|
} else {
|
|
let slice = this.demuxer.reader.requestSlice(
|
|
rawSample.byteOffset,
|
|
rawSample.byteSize
|
|
);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) {
|
|
return null;
|
|
}
|
|
data = readBytes(slice, rawSample.byteSize);
|
|
}
|
|
assert(this.demuxer.audioInfo);
|
|
const timestamp = rawSample.blockOffset / this.demuxer.audioInfo.sampleRate;
|
|
const duration = rawSample.blockSize / this.demuxer.audioInfo.sampleRate;
|
|
return new EncodedPacket(
|
|
data,
|
|
"key",
|
|
timestamp,
|
|
duration,
|
|
sampleIndex,
|
|
rawSample.byteSize
|
|
);
|
|
}
|
|
async getFirstPacket(options) {
|
|
while (this.demuxer.loadedSamples.length === 0 && !this.demuxer.lastSampleLoaded) {
|
|
await this.demuxer.advanceReader();
|
|
}
|
|
return this.getPacketAtIndex(0, options);
|
|
}
|
|
};
|
|
|
|
// src/mpeg-ts/mpeg-ts-misc.ts
|
|
var TIMESCALE = 9e4;
|
|
var TS_PACKET_SIZE = 188;
|
|
var buildMpegTsMimeType = (codecStrings) => {
|
|
let string = "video/MP2T";
|
|
const uniqueCodecStrings = [...new Set(codecStrings.filter(Boolean))];
|
|
if (uniqueCodecStrings.length > 0) {
|
|
string += `; codecs="${uniqueCodecStrings.join(", ")}"`;
|
|
}
|
|
return string;
|
|
};
|
|
|
|
// src/mpeg-ts/mpeg-ts-demuxer.ts
|
|
var MpegTsDemuxer = class extends Demuxer {
|
|
constructor(input) {
|
|
super(input);
|
|
this.metadataPromise = null;
|
|
this.elementaryStreams = [];
|
|
this.tracks = [];
|
|
this.packetOffset = 0;
|
|
this.packetStride = -1;
|
|
this.sectionEndPositions = [];
|
|
this.seekChunkSize = 5 * 1024 * 1024;
|
|
// 5 MiB, picked because most HLS segments are below this size
|
|
this.minReferencePointByteDistance = -1;
|
|
this.reader = input._reader;
|
|
}
|
|
async readMetadata() {
|
|
return this.metadataPromise ??= (async () => {
|
|
const lengthToCheck = TS_PACKET_SIZE + 16 + 1;
|
|
let startingSlice = this.reader.requestSlice(0, lengthToCheck);
|
|
if (startingSlice instanceof Promise) startingSlice = await startingSlice;
|
|
assert(startingSlice);
|
|
const startingBytes = readBytes(startingSlice, lengthToCheck);
|
|
if (startingBytes[0] === 71 && startingBytes[TS_PACKET_SIZE] === 71) {
|
|
this.packetOffset = 0;
|
|
this.packetStride = TS_PACKET_SIZE;
|
|
} else if (startingBytes[0] === 71 && startingBytes[TS_PACKET_SIZE + 16] === 71) {
|
|
this.packetOffset = 0;
|
|
this.packetStride = TS_PACKET_SIZE + 16;
|
|
} else if (startingBytes[4] === 71 && startingBytes[4 + TS_PACKET_SIZE] === 71) {
|
|
this.packetOffset = 4;
|
|
this.packetStride = TS_PACKET_SIZE;
|
|
} else {
|
|
throw new Error("Unreachable.");
|
|
}
|
|
const MIN_REFERENCE_POINT_PACKET_DISTANCE = 256;
|
|
this.minReferencePointByteDistance = MIN_REFERENCE_POINT_PACKET_DISTANCE * this.packetStride;
|
|
let currentPos = this.packetOffset;
|
|
let programMapPid = null;
|
|
let hasProgramAssociationTable = false;
|
|
let hasProgramMap = false;
|
|
while (true) {
|
|
const packetHeader = await this.readPacketHeader(currentPos);
|
|
if (!packetHeader) {
|
|
break;
|
|
}
|
|
if (packetHeader.payloadUnitStartIndicator === 0) {
|
|
currentPos += this.packetStride;
|
|
continue;
|
|
}
|
|
const section = await this.readSection(
|
|
currentPos,
|
|
true,
|
|
!hasProgramMap
|
|
// Expect contiguous sections as long as we don't have the PMT
|
|
);
|
|
if (!section) {
|
|
break;
|
|
}
|
|
const BYTES_BEFORE_SECTION_LENGTH = 3;
|
|
const BITS_IN_CRC_32 = 32;
|
|
let isProbablyProgramMap = false;
|
|
if (!hasProgramMap && section.pid !== 0) {
|
|
const isPesPacket = section.payload[0] === 0 && section.payload[1] === 0 && section.payload[2] === 1;
|
|
if (!isPesPacket) {
|
|
const bitstream = new Bitstream(section.payload);
|
|
const pointerField = bitstream.readAlignedByte();
|
|
bitstream.skipBits(8 * pointerField);
|
|
const tableId = bitstream.readBits(8);
|
|
isProbablyProgramMap = tableId === 2;
|
|
}
|
|
}
|
|
if (section.pid === 0 && !hasProgramAssociationTable) {
|
|
const bitstream = new Bitstream(section.payload);
|
|
const pointerField = bitstream.readAlignedByte();
|
|
bitstream.skipBits(8 * pointerField);
|
|
bitstream.skipBits(14);
|
|
const sectionLength = bitstream.readBits(10);
|
|
bitstream.skipBits(40);
|
|
while (8 * (sectionLength + BYTES_BEFORE_SECTION_LENGTH) - bitstream.pos > BITS_IN_CRC_32) {
|
|
const programNumber = bitstream.readBits(16);
|
|
bitstream.skipBits(3);
|
|
if (programNumber !== 0) {
|
|
if (programMapPid !== null) {
|
|
throw new Error("Only files with a single program are supported.");
|
|
} else {
|
|
programMapPid = bitstream.readBits(13);
|
|
}
|
|
}
|
|
}
|
|
if (programMapPid === null) {
|
|
throw new Error("Program Association Table must link to a Program Map Table.");
|
|
}
|
|
hasProgramAssociationTable = true;
|
|
} else if ((section.pid === programMapPid || isProbablyProgramMap) && !hasProgramMap) {
|
|
const bitstream = new Bitstream(section.payload);
|
|
const pointerField = bitstream.readAlignedByte();
|
|
bitstream.skipBits(8 * pointerField);
|
|
bitstream.skipBits(12);
|
|
const sectionLength = bitstream.readBits(12);
|
|
bitstream.skipBits(43);
|
|
const pcrPid = bitstream.readBits(13);
|
|
bitstream.skipBits(6);
|
|
const programInfoLength = bitstream.readBits(10);
|
|
bitstream.skipBits(8 * programInfoLength);
|
|
while (8 * (sectionLength + BYTES_BEFORE_SECTION_LENGTH) - bitstream.pos > BITS_IN_CRC_32) {
|
|
const streamType = bitstream.readBits(8);
|
|
bitstream.skipBits(3);
|
|
const elementaryPid = bitstream.readBits(13);
|
|
bitstream.skipBits(6);
|
|
const esInfoLength = bitstream.readBits(10);
|
|
const esInfoEndPos = bitstream.pos + 8 * esInfoLength;
|
|
let hasAc3Descriptor = false;
|
|
let hasEac3Descriptor = false;
|
|
while (bitstream.pos < esInfoEndPos) {
|
|
const descriptorTag = bitstream.readBits(8);
|
|
const descriptorLength = bitstream.readBits(8);
|
|
if (descriptorTag === 106) {
|
|
hasAc3Descriptor = true;
|
|
} else if (descriptorTag === 122 || descriptorTag === 204) {
|
|
hasEac3Descriptor = true;
|
|
}
|
|
bitstream.skipBits(8 * descriptorLength);
|
|
}
|
|
let info = null;
|
|
switch (streamType) {
|
|
case 3 /* MP3_MPEG1 */:
|
|
case 4 /* MP3_MPEG2 */:
|
|
case 15 /* AAC */:
|
|
{
|
|
const codec = streamType === 15 /* AAC */ ? "aac" : "mp3";
|
|
info = {
|
|
type: "audio",
|
|
codec,
|
|
aacCodecInfo: null,
|
|
numberOfChannels: -1,
|
|
sampleRate: -1
|
|
};
|
|
}
|
|
;
|
|
break;
|
|
case 27 /* AVC */:
|
|
case 36 /* HEVC */:
|
|
{
|
|
const codec = streamType === 27 /* AVC */ ? "avc" : "hevc";
|
|
info = {
|
|
type: "video",
|
|
codec,
|
|
avcCodecInfo: null,
|
|
hevcCodecInfo: null,
|
|
colorSpace: {
|
|
primaries: null,
|
|
transfer: null,
|
|
matrix: null,
|
|
fullRange: null
|
|
},
|
|
width: -1,
|
|
height: -1,
|
|
reorderSize: -1
|
|
};
|
|
}
|
|
;
|
|
break;
|
|
case 129 /* AC3_SYSTEM_A */:
|
|
{
|
|
info = {
|
|
type: "audio",
|
|
codec: "ac3",
|
|
aacCodecInfo: null,
|
|
numberOfChannels: -1,
|
|
sampleRate: -1
|
|
};
|
|
}
|
|
;
|
|
break;
|
|
case 135 /* EAC3_SYSTEM_A */:
|
|
{
|
|
info = {
|
|
type: "audio",
|
|
codec: "eac3",
|
|
aacCodecInfo: null,
|
|
numberOfChannels: -1,
|
|
sampleRate: -1
|
|
};
|
|
}
|
|
;
|
|
break;
|
|
case 6 /* PRIVATE_DATA */:
|
|
{
|
|
if (hasEac3Descriptor) {
|
|
info = {
|
|
type: "audio",
|
|
codec: "eac3",
|
|
aacCodecInfo: null,
|
|
numberOfChannels: -1,
|
|
sampleRate: -1
|
|
};
|
|
} else if (hasAc3Descriptor) {
|
|
info = {
|
|
type: "audio",
|
|
codec: "ac3",
|
|
aacCodecInfo: null,
|
|
numberOfChannels: -1,
|
|
sampleRate: -1
|
|
};
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
default: {
|
|
console.warn(`Unsupported stream_type 0x${streamType.toString(16)}; ignoring stream.`);
|
|
}
|
|
}
|
|
if (info) {
|
|
this.elementaryStreams.push({
|
|
demuxer: this,
|
|
pid: elementaryPid,
|
|
streamType,
|
|
initialized: false,
|
|
firstSection: null,
|
|
info,
|
|
referencePesPackets: []
|
|
});
|
|
}
|
|
}
|
|
hasProgramMap = true;
|
|
} else {
|
|
const elementaryStream = this.elementaryStreams.find((x) => x.pid === section.pid);
|
|
if (elementaryStream && !elementaryStream.initialized) {
|
|
const pesPacket = readPesPacket(section);
|
|
if (!pesPacket) {
|
|
throw new Error(
|
|
`Couldn't read first PES packet for Elementary Stream with PID ${elementaryStream.pid}`
|
|
);
|
|
}
|
|
elementaryStream.firstSection = section;
|
|
if (elementaryStream.info.type === "video") {
|
|
if (elementaryStream.info.codec === "avc") {
|
|
elementaryStream.info.avcCodecInfo = extractAvcDecoderConfigurationRecord(pesPacket.data);
|
|
if (!elementaryStream.info.avcCodecInfo) {
|
|
throw new Error(
|
|
"Invalid AVC video stream; could not extract AVCDecoderConfigurationRecord from first packet."
|
|
);
|
|
}
|
|
const spsUnit = elementaryStream.info.avcCodecInfo.sequenceParameterSets[0];
|
|
assert(spsUnit);
|
|
const spsInfo = parseAvcSps(spsUnit);
|
|
elementaryStream.info.width = spsInfo.displayWidth;
|
|
elementaryStream.info.height = spsInfo.displayHeight;
|
|
elementaryStream.info.colorSpace = {
|
|
primaries: COLOR_PRIMARIES_MAP_INVERSE[spsInfo.colourPrimaries],
|
|
transfer: TRANSFER_CHARACTERISTICS_MAP_INVERSE[spsInfo.transferCharacteristics],
|
|
matrix: MATRIX_COEFFICIENTS_MAP_INVERSE[spsInfo.matrixCoefficients],
|
|
fullRange: !!spsInfo.fullRangeFlag
|
|
};
|
|
elementaryStream.info.reorderSize = spsInfo.maxDecFrameBuffering;
|
|
elementaryStream.initialized = true;
|
|
} else if (elementaryStream.info.codec === "hevc") {
|
|
elementaryStream.info.hevcCodecInfo = extractHevcDecoderConfigurationRecord(pesPacket.data);
|
|
if (!elementaryStream.info.hevcCodecInfo) {
|
|
throw new Error(
|
|
"Invalid HEVC video stream; could not extract HVCDecoderConfigurationRecord from first packet."
|
|
);
|
|
}
|
|
const spsArray = elementaryStream.info.hevcCodecInfo.arrays.find(
|
|
(a) => a.nalUnitType === 33 /* SPS_NUT */
|
|
);
|
|
const spsUnit = spsArray.nalUnits[0];
|
|
assert(spsUnit);
|
|
const spsInfo = parseHevcSps(spsUnit);
|
|
elementaryStream.info.width = spsInfo.displayWidth;
|
|
elementaryStream.info.height = spsInfo.displayHeight;
|
|
elementaryStream.info.colorSpace = {
|
|
primaries: COLOR_PRIMARIES_MAP_INVERSE[spsInfo.colourPrimaries],
|
|
transfer: TRANSFER_CHARACTERISTICS_MAP_INVERSE[spsInfo.transferCharacteristics],
|
|
matrix: MATRIX_COEFFICIENTS_MAP_INVERSE[spsInfo.matrixCoefficients],
|
|
fullRange: !!spsInfo.fullRangeFlag
|
|
};
|
|
elementaryStream.info.reorderSize = spsInfo.maxDecFrameBuffering;
|
|
elementaryStream.initialized = true;
|
|
} else {
|
|
throw new Error("Unhandled.");
|
|
}
|
|
} else {
|
|
if (elementaryStream.info.codec === "aac") {
|
|
const slice = FileSlice4.tempFromBytes(pesPacket.data);
|
|
const header = readAdtsFrameHeader(slice);
|
|
if (!header) {
|
|
throw new Error(
|
|
"Invalid AAC audio stream; could not read ADTS frame header from first packet."
|
|
);
|
|
}
|
|
elementaryStream.info.aacCodecInfo = {
|
|
isMpeg2: false,
|
|
objectType: header.objectType
|
|
};
|
|
elementaryStream.info.numberOfChannels = aacChannelMap[header.channelConfiguration];
|
|
elementaryStream.info.sampleRate = aacFrequencyTable[header.samplingFrequencyIndex];
|
|
elementaryStream.initialized = true;
|
|
} else if (elementaryStream.info.codec === "mp3") {
|
|
const word = readU32Be(FileSlice4.tempFromBytes(pesPacket.data));
|
|
const result = readMp3FrameHeader(word, pesPacket.data.byteLength);
|
|
if (!result.header) {
|
|
throw new Error(
|
|
"Invalid MP3 audio stream; could not read frame header from first packet."
|
|
);
|
|
}
|
|
elementaryStream.info.numberOfChannels = result.header.channel === 3 ? 1 : 2;
|
|
elementaryStream.info.sampleRate = result.header.sampleRate;
|
|
elementaryStream.initialized = true;
|
|
} else if (elementaryStream.info.codec === "ac3") {
|
|
const frameInfo = parseAc3SyncFrame(pesPacket.data);
|
|
if (!frameInfo) {
|
|
throw new Error(
|
|
"Invalid AC-3 audio stream; could not read sync frame from first packet."
|
|
);
|
|
}
|
|
if (frameInfo.fscod === 3) {
|
|
throw new Error(
|
|
"Invalid AC-3 audio stream; reserved sample rate code found in first packet."
|
|
);
|
|
}
|
|
elementaryStream.info.numberOfChannels = AC3_ACMOD_CHANNEL_COUNTS[frameInfo.acmod] + frameInfo.lfeon;
|
|
elementaryStream.info.sampleRate = AC3_SAMPLE_RATES[frameInfo.fscod];
|
|
elementaryStream.initialized = true;
|
|
} else if (elementaryStream.info.codec === "eac3") {
|
|
const frameInfo = parseEac3SyncFrame(pesPacket.data);
|
|
if (!frameInfo) {
|
|
throw new Error(
|
|
"Invalid E-AC-3 audio stream; could not read sync frame from first packet."
|
|
);
|
|
}
|
|
const sampleRate = getEac3SampleRate(frameInfo);
|
|
if (sampleRate === null) {
|
|
throw new Error(
|
|
"Invalid E-AC-3 audio stream; reserved sample rate code found in first packet."
|
|
);
|
|
}
|
|
elementaryStream.info.numberOfChannels = getEac3ChannelCount(frameInfo);
|
|
elementaryStream.info.sampleRate = sampleRate;
|
|
elementaryStream.initialized = true;
|
|
} else {
|
|
throw new Error("Unhandled.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const isDone = hasProgramMap && this.elementaryStreams.every((x) => x.initialized);
|
|
if (isDone) {
|
|
break;
|
|
}
|
|
currentPos += this.packetStride;
|
|
}
|
|
if (!hasProgramMap) {
|
|
if (!hasProgramAssociationTable) {
|
|
throw new Error("No Program Association Table found in the file.");
|
|
}
|
|
throw new Error("No Program Map Table found in the file.");
|
|
}
|
|
for (const stream of this.elementaryStreams) {
|
|
if (stream.info.type === "video") {
|
|
this.tracks.push(
|
|
new InputVideoTrack(
|
|
this.input,
|
|
new MpegTsVideoTrackBacking(stream)
|
|
)
|
|
);
|
|
} else {
|
|
this.tracks.push(
|
|
new InputAudioTrack(
|
|
this.input,
|
|
new MpegTsAudioTrackBacking(stream)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
async getTracks() {
|
|
await this.readMetadata();
|
|
return this.tracks;
|
|
}
|
|
async getMetadataTags() {
|
|
return {};
|
|
}
|
|
async computeDuration() {
|
|
const tracks = await this.getTracks();
|
|
const trackDurations = await Promise.all(tracks.map((x) => x.computeDuration()));
|
|
return Math.max(0, ...trackDurations);
|
|
}
|
|
async getMimeType() {
|
|
await this.readMetadata();
|
|
const tracks = await this.getTracks();
|
|
const codecStrings = await Promise.all(tracks.map((x) => x.getCodecParameterString()));
|
|
return buildMpegTsMimeType(codecStrings);
|
|
}
|
|
async readSection(startPos, full, contiguous = false) {
|
|
let endPos = startPos;
|
|
let currentPos = startPos;
|
|
const chunks = [];
|
|
let chunksByteLength = 0;
|
|
let firstPacket = null;
|
|
let mustAddSectionEnd = true;
|
|
let randomAccessIndicator = 0;
|
|
while (true) {
|
|
const packet = await this.readPacket(currentPos);
|
|
currentPos += this.packetStride;
|
|
if (!packet) {
|
|
break;
|
|
}
|
|
if (!firstPacket) {
|
|
if (packet.payloadUnitStartIndicator === 0) {
|
|
break;
|
|
}
|
|
firstPacket = packet;
|
|
} else {
|
|
if (packet.pid !== firstPacket.pid) {
|
|
if (contiguous) {
|
|
break;
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
if (packet.payloadUnitStartIndicator === 1) {
|
|
break;
|
|
}
|
|
}
|
|
const hasAdaptationField = !!(packet.adaptationFieldControl & 2);
|
|
const hasPayload = !!(packet.adaptationFieldControl & 1);
|
|
let adaptationFieldLength = 0;
|
|
if (hasAdaptationField) {
|
|
adaptationFieldLength = 1 + packet.body[0];
|
|
if (packet === firstPacket && adaptationFieldLength > 1) {
|
|
randomAccessIndicator = packet.body[1] >> 6 & 1;
|
|
}
|
|
}
|
|
if (hasPayload) {
|
|
if (adaptationFieldLength === 0) {
|
|
chunks.push(packet.body);
|
|
chunksByteLength += packet.body.byteLength;
|
|
} else {
|
|
chunks.push(packet.body.subarray(adaptationFieldLength));
|
|
chunksByteLength += packet.body.byteLength - adaptationFieldLength;
|
|
}
|
|
}
|
|
endPos = currentPos;
|
|
if (!full && chunksByteLength >= 64) {
|
|
mustAddSectionEnd = false;
|
|
break;
|
|
}
|
|
const isKnownSectionEnd = binarySearchExact(this.sectionEndPositions, endPos, (x) => x) !== -1;
|
|
if (isKnownSectionEnd) {
|
|
mustAddSectionEnd = false;
|
|
break;
|
|
}
|
|
}
|
|
if (mustAddSectionEnd) {
|
|
const index = binarySearchLessOrEqual(this.sectionEndPositions, endPos, (x) => x);
|
|
this.sectionEndPositions.splice(index + 1, 0, endPos);
|
|
}
|
|
if (!firstPacket) {
|
|
return null;
|
|
}
|
|
let merged;
|
|
if (chunks.length === 1) {
|
|
merged = chunks[0];
|
|
} else {
|
|
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
merged = new Uint8Array(totalLength);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
merged.set(chunk, offset);
|
|
offset += chunk.length;
|
|
}
|
|
}
|
|
return {
|
|
startPos,
|
|
endPos: full ? endPos : null,
|
|
pid: firstPacket.pid,
|
|
payload: merged,
|
|
randomAccessIndicator
|
|
};
|
|
}
|
|
async readPacketHeader(pos) {
|
|
let slice = this.reader.requestSlice(pos, 4);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) {
|
|
return null;
|
|
}
|
|
const syncByte = readU8(slice);
|
|
if (syncByte !== 71) {
|
|
throw new Error("Invalid TS packet sync byte. Likely an internal bug, please report this file.");
|
|
}
|
|
const nextTwoBytes = readU16Be(slice);
|
|
const transportErrorIndicator = nextTwoBytes >> 15;
|
|
const payloadUnitStartIndicator = nextTwoBytes >> 14 & 1;
|
|
const transportPriority = nextTwoBytes >> 13 & 1;
|
|
const pid = nextTwoBytes & 8191;
|
|
const nextByte = readU8(slice);
|
|
const transportScramblingControl = nextByte >> 6;
|
|
const adaptationFieldControl = nextByte >> 4 & 3;
|
|
const continuityCounter = nextByte & 15;
|
|
return {
|
|
payloadUnitStartIndicator,
|
|
pid,
|
|
adaptationFieldControl
|
|
};
|
|
}
|
|
async readPacket(pos) {
|
|
let slice = this.reader.requestSlice(pos, TS_PACKET_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) {
|
|
return null;
|
|
}
|
|
const bytes2 = readBytes(slice, TS_PACKET_SIZE);
|
|
const syncByte = bytes2[0];
|
|
if (syncByte !== 71) {
|
|
throw new Error("Invalid TS packet sync byte. Likely an internal bug, please report this file.");
|
|
}
|
|
const nextTwoBytes = (bytes2[1] << 8) + bytes2[2];
|
|
const transportErrorIndicator = nextTwoBytes >> 15;
|
|
const payloadUnitStartIndicator = nextTwoBytes >> 14 & 1;
|
|
const transportPriority = nextTwoBytes >> 13 & 1;
|
|
const pid = nextTwoBytes & 8191;
|
|
const nextByte = bytes2[3];
|
|
const transportScramblingControl = nextByte >> 6;
|
|
const adaptationFieldControl = nextByte >> 4 & 3;
|
|
const continuityCounter = nextByte & 15;
|
|
return {
|
|
payloadUnitStartIndicator,
|
|
pid,
|
|
adaptationFieldControl,
|
|
body: bytes2.subarray(4)
|
|
};
|
|
}
|
|
};
|
|
var readPesPacketHeader = (section) => {
|
|
if (section.payload.byteLength < 3) {
|
|
return null;
|
|
}
|
|
const bitstream = new Bitstream(section.payload);
|
|
const startCodePrefix = bitstream.readBits(24);
|
|
if (startCodePrefix !== 1) {
|
|
return null;
|
|
}
|
|
const streamId = bitstream.readBits(8);
|
|
bitstream.skipBits(16);
|
|
if (streamId === 188 || streamId === 190 || streamId === 191 || streamId === 240 || streamId === 241 || streamId === 255 || streamId === 242 || streamId === 248) {
|
|
return null;
|
|
}
|
|
bitstream.skipBits(8);
|
|
const ptsDtsFlags = bitstream.readBits(2);
|
|
bitstream.skipBits(14);
|
|
let pts = 0;
|
|
if (ptsDtsFlags === 2 || ptsDtsFlags === 3) {
|
|
bitstream.skipBits(4);
|
|
pts += bitstream.readBits(3) * (1 << 30);
|
|
bitstream.skipBits(1);
|
|
pts += bitstream.readBits(15) * (1 << 15);
|
|
bitstream.skipBits(1);
|
|
pts += bitstream.readBits(15);
|
|
} else {
|
|
throw new Error(
|
|
"PES packets without PTS are not currently supported. If you think this file should be supported, please report it."
|
|
);
|
|
}
|
|
return {
|
|
sectionStartPos: section.startPos,
|
|
sectionEndPos: section.endPos,
|
|
pts,
|
|
randomAccessIndicator: section.randomAccessIndicator
|
|
};
|
|
};
|
|
var readPesPacket = (section) => {
|
|
assert(section.endPos !== null);
|
|
const header = readPesPacketHeader(section);
|
|
if (!header) {
|
|
return null;
|
|
}
|
|
const bitstream = new Bitstream(section.payload);
|
|
bitstream.skipBits(32);
|
|
const pesPacketLength = bitstream.readBits(16);
|
|
const BYTES_UNTIL_END_OF_PES_PACKET_LENGTH = 6;
|
|
bitstream.skipBits(16);
|
|
const pesHeaderDataLength = bitstream.readBits(8);
|
|
const pesHeaderEndPos = bitstream.pos + 8 * pesHeaderDataLength;
|
|
bitstream.pos = pesHeaderEndPos;
|
|
const bytePos = pesHeaderEndPos / 8;
|
|
assert(Number.isInteger(bytePos));
|
|
const data = section.payload.subarray(
|
|
bytePos,
|
|
// "A value of 0 indicates that the PES packet length is neither specified nor bounded and is allowed only in
|
|
// PES packets whose payload consists of bytes from a video elementary stream contained in
|
|
// transport stream packets."
|
|
pesPacketLength > 0 ? BYTES_UNTIL_END_OF_PES_PACKET_LENGTH + pesPacketLength : section.payload.byteLength
|
|
);
|
|
return {
|
|
...header,
|
|
data
|
|
};
|
|
};
|
|
var MpegTsTrackBacking = class _MpegTsTrackBacking {
|
|
constructor(elementaryStream) {
|
|
this.elementaryStream = elementaryStream;
|
|
this.packetBuffers = /* @__PURE__ */ new WeakMap();
|
|
/** Used for recreating PacketBuffers if necessary. */
|
|
this.packetSectionStarts = /* @__PURE__ */ new WeakMap();
|
|
}
|
|
getId() {
|
|
return this.elementaryStream.pid;
|
|
}
|
|
getNumber() {
|
|
const demuxer = this.elementaryStream.demuxer;
|
|
const trackType = this.elementaryStream.info.type;
|
|
let number = 0;
|
|
for (const track of demuxer.tracks) {
|
|
if (track.type === trackType) {
|
|
number++;
|
|
}
|
|
assert(track._backing instanceof _MpegTsTrackBacking);
|
|
if (track._backing.elementaryStream === this.elementaryStream) {
|
|
break;
|
|
}
|
|
}
|
|
return number;
|
|
}
|
|
getCodec() {
|
|
throw new Error("Not implemented on base class.");
|
|
}
|
|
getInternalCodecId() {
|
|
return this.elementaryStream.streamType;
|
|
}
|
|
getName() {
|
|
return null;
|
|
}
|
|
getLanguageCode() {
|
|
return UNDETERMINED_LANGUAGE;
|
|
}
|
|
getDisposition() {
|
|
return DEFAULT_TRACK_DISPOSITION;
|
|
}
|
|
getTimeResolution() {
|
|
return TIMESCALE;
|
|
}
|
|
async computeDuration() {
|
|
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
|
|
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
|
|
}
|
|
async getFirstTimestamp() {
|
|
const firstPacket = await this.getFirstPacket({ metadataOnly: true });
|
|
return firstPacket?.timestamp ?? 0;
|
|
}
|
|
createEncodedPacket(suppliedPacket, duration, options) {
|
|
let packetType;
|
|
if (this.allPacketsAreKeyPackets()) {
|
|
packetType = "key";
|
|
} else {
|
|
packetType = suppliedPacket.randomAccessIndicator === 1 ? "key" : "delta";
|
|
}
|
|
return new EncodedPacket(
|
|
options.metadataOnly ? PLACEHOLDER_DATA : suppliedPacket.data,
|
|
packetType,
|
|
suppliedPacket.pts / TIMESCALE,
|
|
Math.max(duration / TIMESCALE, 0),
|
|
suppliedPacket.sequenceNumber,
|
|
suppliedPacket.data.byteLength
|
|
);
|
|
}
|
|
async getFirstPacket(options) {
|
|
const section = this.elementaryStream.firstSection;
|
|
assert(section);
|
|
const pesPacket = readPesPacket(section);
|
|
assert(pesPacket);
|
|
const context = new PacketReadingContext(this.elementaryStream, pesPacket);
|
|
const buffer = new PacketBuffer(this, context);
|
|
const result = await buffer.readNext();
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
const packet = this.createEncodedPacket(result.packet, result.duration, options);
|
|
this.packetBuffers.set(packet, buffer);
|
|
this.packetSectionStarts.set(packet, result.packet.sectionStartPos);
|
|
return packet;
|
|
}
|
|
async getNextPacket(packet, options) {
|
|
let buffer = this.packetBuffers.get(packet);
|
|
if (buffer) {
|
|
const result = await buffer.readNext();
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
this.packetBuffers.delete(packet);
|
|
const newPacket = this.createEncodedPacket(result.packet, result.duration, options);
|
|
this.packetBuffers.set(newPacket, buffer);
|
|
this.packetSectionStarts.set(newPacket, result.packet.sectionStartPos);
|
|
return newPacket;
|
|
}
|
|
const sectionStartPos = this.packetSectionStarts.get(packet);
|
|
if (sectionStartPos === void 0) {
|
|
throw new Error("Packet was not created from this track.");
|
|
}
|
|
const demuxer = this.elementaryStream.demuxer;
|
|
const section = await demuxer.readSection(sectionStartPos, true);
|
|
assert(section);
|
|
const pesPacket = readPesPacket(section);
|
|
assert(pesPacket);
|
|
const context = new PacketReadingContext(this.elementaryStream, pesPacket);
|
|
buffer = new PacketBuffer(this, context);
|
|
const targetSequenceNumber = packet.sequenceNumber;
|
|
while (true) {
|
|
const result = await buffer.readNext();
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
if (result.packet.sequenceNumber > targetSequenceNumber) {
|
|
const newPacket = this.createEncodedPacket(result.packet, result.duration, options);
|
|
this.packetBuffers.set(newPacket, buffer);
|
|
this.packetSectionStarts.set(newPacket, result.packet.sectionStartPos);
|
|
return newPacket;
|
|
}
|
|
}
|
|
}
|
|
async getNextKeyPacket(packet, options) {
|
|
let currentPacket = packet;
|
|
while (true) {
|
|
currentPacket = await this.getNextPacket(currentPacket, options);
|
|
if (!currentPacket) {
|
|
return null;
|
|
}
|
|
if (currentPacket.type === "key") {
|
|
return currentPacket;
|
|
}
|
|
}
|
|
}
|
|
getPacket(timestamp, options) {
|
|
return this.doPacketLookup(timestamp, false, options);
|
|
}
|
|
getKeyPacket(timestamp, options) {
|
|
return this.doPacketLookup(timestamp, true, options);
|
|
}
|
|
/**
|
|
* Searches for the packet with the largest timestamp not larger than `timestamp` in the file, using a combination
|
|
* of chunk-based binary search and linear refinement. The reason the coarse search is done in large chunks is to
|
|
* make it more performant for small files and over high-latency readers such as the network.
|
|
*/
|
|
async doPacketLookup(timestamp, keyframesOnly, options) {
|
|
const searchPts = roundIfAlmostInteger(timestamp * TIMESCALE);
|
|
const demuxer = this.elementaryStream.demuxer;
|
|
const { reader, seekChunkSize } = demuxer;
|
|
const pid = this.elementaryStream.pid;
|
|
const findFirstPesPacketHeaderInChunk = async (startPos, endPos) => {
|
|
let currentPos = startPos;
|
|
while (currentPos < endPos) {
|
|
const packetHeader = await demuxer.readPacketHeader(currentPos);
|
|
if (!packetHeader) {
|
|
return null;
|
|
}
|
|
if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {
|
|
const section = await demuxer.readSection(currentPos, false);
|
|
if (!section) {
|
|
return null;
|
|
}
|
|
const pesPacketHeader = readPesPacketHeader(section);
|
|
if (pesPacketHeader) {
|
|
return pesPacketHeader;
|
|
}
|
|
}
|
|
currentPos += demuxer.packetStride;
|
|
}
|
|
return null;
|
|
};
|
|
const firstSection = this.elementaryStream.firstSection;
|
|
assert(firstSection);
|
|
const firstPesPacketHeader = readPesPacketHeader(firstSection);
|
|
assert(firstPesPacketHeader);
|
|
if (searchPts < firstPesPacketHeader.pts) {
|
|
return null;
|
|
}
|
|
let scanStartPos;
|
|
const referencePesPackets = this.elementaryStream.referencePesPackets;
|
|
const referencePointIndex = binarySearchLessOrEqual(referencePesPackets, searchPts, (x) => x.pts);
|
|
const referencePoint = referencePointIndex !== -1 ? referencePesPackets[referencePointIndex] : null;
|
|
if (referencePoint && searchPts - referencePoint.pts < TIMESCALE / 2) {
|
|
scanStartPos = referencePoint.sectionStartPos;
|
|
} else {
|
|
let startChunkIndex = 0;
|
|
if (reader.fileSize !== null) {
|
|
const numChunks = Math.ceil(reader.fileSize / seekChunkSize);
|
|
if (numChunks > 1) {
|
|
let low = 0;
|
|
let high = numChunks - 1;
|
|
startChunkIndex = low;
|
|
while (low <= high) {
|
|
const mid = Math.floor((low + high) / 2);
|
|
const chunkStartPos = floorToMultiple(mid * seekChunkSize, demuxer.packetStride) + firstPesPacketHeader.sectionStartPos;
|
|
const chunkEndPos = chunkStartPos + seekChunkSize;
|
|
const pesHeader = await findFirstPesPacketHeaderInChunk(chunkStartPos, chunkEndPos);
|
|
if (!pesHeader) {
|
|
high = mid - 1;
|
|
continue;
|
|
}
|
|
if (pesHeader.pts <= searchPts) {
|
|
startChunkIndex = mid;
|
|
low = mid + 1;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
scanStartPos = floorToMultiple(
|
|
startChunkIndex * seekChunkSize,
|
|
demuxer.packetStride
|
|
) + firstPesPacketHeader.sectionStartPos;
|
|
}
|
|
let currentPesHeader = await findFirstPesPacketHeaderInChunk(
|
|
scanStartPos,
|
|
reader.fileSize ?? Infinity
|
|
);
|
|
if (!currentPesHeader) {
|
|
currentPesHeader = firstPesPacketHeader;
|
|
}
|
|
const reorderSize = this.getReorderSize();
|
|
const retrieveEncodedPacket = async (sectionStartPos, predicate) => {
|
|
const section = await demuxer.readSection(sectionStartPos, true);
|
|
assert(section);
|
|
const pesPacket = readPesPacket(section);
|
|
assert(pesPacket);
|
|
const context = new PacketReadingContext(this.elementaryStream, pesPacket);
|
|
const buffer = new PacketBuffer(this, context);
|
|
while (true) {
|
|
const topPts = last(buffer.presentationOrderPackets)?.pts ?? -Infinity;
|
|
if (topPts >= searchPts) {
|
|
break;
|
|
}
|
|
const didRead = await buffer.readNextPacket();
|
|
if (!didRead) {
|
|
break;
|
|
}
|
|
}
|
|
const targetIndex = findLastIndex(buffer.presentationOrderPackets, predicate);
|
|
if (targetIndex === -1) {
|
|
return null;
|
|
}
|
|
const targetPacket = buffer.presentationOrderPackets[targetIndex];
|
|
const lastDuration = targetIndex === 0 ? 0 : targetPacket.pts - buffer.presentationOrderPackets[targetIndex - 1].pts;
|
|
while (buffer.decodeOrderPackets[0] !== targetPacket) {
|
|
buffer.decodeOrderPackets.shift();
|
|
}
|
|
buffer.lastDuration = lastDuration;
|
|
const result = await buffer.readNext();
|
|
assert(result);
|
|
const packet = this.createEncodedPacket(result.packet, result.duration, options);
|
|
this.packetBuffers.set(packet, buffer);
|
|
this.packetSectionStarts.set(packet, result.packet.sectionStartPos);
|
|
return packet;
|
|
};
|
|
if (!keyframesOnly || this.allPacketsAreKeyPackets()) {
|
|
outer:
|
|
while (true) {
|
|
let currentPos = currentPesHeader.sectionStartPos + demuxer.packetStride;
|
|
while (true) {
|
|
const packetHeader = await demuxer.readPacketHeader(currentPos);
|
|
if (!packetHeader) {
|
|
break outer;
|
|
}
|
|
if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {
|
|
const section = await demuxer.readSection(currentPos, false);
|
|
if (section) {
|
|
const nextPesHeader = readPesPacketHeader(section);
|
|
if (nextPesHeader) {
|
|
if (nextPesHeader.pts > searchPts) {
|
|
break outer;
|
|
}
|
|
currentPesHeader = nextPesHeader;
|
|
maybeInsertReferencePacket(this.elementaryStream, nextPesHeader);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
currentPos += demuxer.packetStride;
|
|
}
|
|
}
|
|
outer:
|
|
for (let i = 0; i < reorderSize; i++) {
|
|
let pos = currentPesHeader.sectionStartPos - demuxer.packetStride;
|
|
while (pos >= demuxer.packetOffset) {
|
|
const packetHeader = await demuxer.readPacketHeader(pos);
|
|
if (!packetHeader) {
|
|
break outer;
|
|
}
|
|
if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {
|
|
const section = await demuxer.readSection(pos, false);
|
|
if (section) {
|
|
const header = readPesPacketHeader(section);
|
|
if (header) {
|
|
currentPesHeader = header;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
pos -= demuxer.packetStride;
|
|
}
|
|
}
|
|
return retrieveEncodedPacket(currentPesHeader.sectionStartPos, (p) => p.pts <= searchPts);
|
|
} else {
|
|
let currentChunkStartPos = scanStartPos;
|
|
let nextChunkStartPos = null;
|
|
while (true) {
|
|
let bestKeyPesHeader = null;
|
|
const isFirstChunk = currentChunkStartPos <= firstPesPacketHeader.sectionStartPos;
|
|
let pesHeader;
|
|
if (isFirstChunk) {
|
|
pesHeader = firstPesPacketHeader;
|
|
bestKeyPesHeader = firstPesPacketHeader;
|
|
} else {
|
|
pesHeader = await findFirstPesPacketHeaderInChunk(
|
|
currentChunkStartPos,
|
|
reader.fileSize ?? Infinity
|
|
);
|
|
}
|
|
let passedSearchPts = false;
|
|
let lookaheadCount = 0;
|
|
outer:
|
|
while (pesHeader) {
|
|
if (nextChunkStartPos !== null && pesHeader.sectionStartPos >= nextChunkStartPos) {
|
|
break;
|
|
}
|
|
const isKeyCandidate = pesHeader.randomAccessIndicator === 1;
|
|
if (isKeyCandidate && pesHeader.pts <= searchPts) {
|
|
bestKeyPesHeader = pesHeader;
|
|
}
|
|
if (pesHeader.pts > searchPts) {
|
|
passedSearchPts = true;
|
|
}
|
|
if (passedSearchPts) {
|
|
lookaheadCount++;
|
|
if (lookaheadCount >= reorderSize) {
|
|
break;
|
|
}
|
|
}
|
|
let currentPos = pesHeader.sectionStartPos + demuxer.packetStride;
|
|
while (true) {
|
|
const packetHeader = await demuxer.readPacketHeader(currentPos);
|
|
if (!packetHeader) {
|
|
break outer;
|
|
}
|
|
if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {
|
|
const section = await demuxer.readSection(currentPos, false);
|
|
if (section) {
|
|
pesHeader = readPesPacketHeader(section);
|
|
if (pesHeader) {
|
|
maybeInsertReferencePacket(this.elementaryStream, pesHeader);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
currentPos += demuxer.packetStride;
|
|
}
|
|
}
|
|
if (bestKeyPesHeader) {
|
|
let startPesHeader = bestKeyPesHeader;
|
|
if (lookaheadCount === 0) {
|
|
outer:
|
|
for (let i = 0; i < reorderSize - 1; i++) {
|
|
let pos = startPesHeader.sectionStartPos - demuxer.packetStride;
|
|
while (pos >= demuxer.packetOffset) {
|
|
const packetHeader = await demuxer.readPacketHeader(pos);
|
|
if (!packetHeader) {
|
|
break outer;
|
|
}
|
|
if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {
|
|
const section = await demuxer.readSection(pos, false);
|
|
if (section) {
|
|
const header = readPesPacketHeader(section);
|
|
if (header) {
|
|
startPesHeader = header;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
pos -= demuxer.packetStride;
|
|
}
|
|
}
|
|
}
|
|
const encodedPacket = await retrieveEncodedPacket(
|
|
startPesHeader.sectionStartPos,
|
|
(p) => p.pts <= searchPts && p.randomAccessIndicator === 1
|
|
);
|
|
assert(encodedPacket);
|
|
return encodedPacket;
|
|
}
|
|
assert(!isFirstChunk);
|
|
nextChunkStartPos = currentChunkStartPos;
|
|
currentChunkStartPos = Math.max(
|
|
floorToMultiple(
|
|
currentChunkStartPos - firstPesPacketHeader.sectionStartPos - seekChunkSize,
|
|
demuxer.packetStride
|
|
) + firstPesPacketHeader.sectionStartPos,
|
|
firstPesPacketHeader.sectionStartPos
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
var MpegTsVideoTrackBacking = class extends MpegTsTrackBacking {
|
|
constructor(elementaryStream) {
|
|
super(elementaryStream);
|
|
this.elementaryStream = elementaryStream;
|
|
this.decoderConfig = {
|
|
codec: extractVideoCodecString({
|
|
width: this.elementaryStream.info.width,
|
|
height: this.elementaryStream.info.height,
|
|
codec: this.elementaryStream.info.codec,
|
|
codecDescription: null,
|
|
colorSpace: this.elementaryStream.info.colorSpace,
|
|
avcType: 1,
|
|
avcCodecInfo: this.elementaryStream.info.avcCodecInfo,
|
|
hevcCodecInfo: this.elementaryStream.info.hevcCodecInfo,
|
|
vp9CodecInfo: null,
|
|
av1CodecInfo: null
|
|
}),
|
|
codedWidth: this.elementaryStream.info.width,
|
|
codedHeight: this.elementaryStream.info.height,
|
|
colorSpace: this.elementaryStream.info.colorSpace
|
|
};
|
|
}
|
|
getCodec() {
|
|
return this.elementaryStream.info.codec;
|
|
}
|
|
getCodedWidth() {
|
|
return this.elementaryStream.info.width;
|
|
}
|
|
getCodedHeight() {
|
|
return this.elementaryStream.info.height;
|
|
}
|
|
getRotation() {
|
|
return 0;
|
|
}
|
|
async getColorSpace() {
|
|
return this.elementaryStream.info.colorSpace;
|
|
}
|
|
async canBeTransparent() {
|
|
return false;
|
|
}
|
|
async getDecoderConfig() {
|
|
return this.decoderConfig;
|
|
}
|
|
allPacketsAreKeyPackets() {
|
|
return false;
|
|
}
|
|
getReorderSize() {
|
|
return this.elementaryStream.info.reorderSize;
|
|
}
|
|
};
|
|
var MpegTsAudioTrackBacking = class extends MpegTsTrackBacking {
|
|
constructor(elementaryStream) {
|
|
super(elementaryStream);
|
|
this.elementaryStream = elementaryStream;
|
|
}
|
|
getCodec() {
|
|
return this.elementaryStream.info.codec;
|
|
}
|
|
getNumberOfChannels() {
|
|
return this.elementaryStream.info.numberOfChannels;
|
|
}
|
|
getSampleRate() {
|
|
return this.elementaryStream.info.sampleRate;
|
|
}
|
|
async getDecoderConfig() {
|
|
return {
|
|
codec: extractAudioCodecString({
|
|
codec: this.elementaryStream.info.codec,
|
|
codecDescription: null,
|
|
aacCodecInfo: this.elementaryStream.info.aacCodecInfo
|
|
}),
|
|
numberOfChannels: this.elementaryStream.info.numberOfChannels,
|
|
sampleRate: this.elementaryStream.info.sampleRate
|
|
};
|
|
}
|
|
allPacketsAreKeyPackets() {
|
|
return true;
|
|
}
|
|
getReorderSize() {
|
|
return 1;
|
|
}
|
|
};
|
|
var maybeInsertReferencePacket = (elementaryStream, pesPacketHeader) => {
|
|
const referencePesPackets = elementaryStream.referencePesPackets;
|
|
const index = binarySearchLessOrEqual(
|
|
referencePesPackets,
|
|
pesPacketHeader.sectionStartPos,
|
|
(x) => x.sectionStartPos
|
|
);
|
|
if (index >= 0) {
|
|
const entry = referencePesPackets[index];
|
|
if (pesPacketHeader.pts <= entry.pts) {
|
|
return false;
|
|
}
|
|
const minByteDistance = elementaryStream.demuxer.minReferencePointByteDistance;
|
|
if (pesPacketHeader.sectionStartPos - entry.sectionStartPos < minByteDistance) {
|
|
return false;
|
|
}
|
|
if (index < referencePesPackets.length - 1) {
|
|
const nextEntry = referencePesPackets[index + 1];
|
|
if (nextEntry.pts < pesPacketHeader.pts) {
|
|
return false;
|
|
}
|
|
if (nextEntry.sectionStartPos - pesPacketHeader.sectionStartPos < minByteDistance) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
referencePesPackets.splice(index + 1, 0, pesPacketHeader);
|
|
return true;
|
|
};
|
|
var markNextPacket = async (context) => {
|
|
assert(!context.suppliedPacket);
|
|
const elementaryStream = context.elementaryStream;
|
|
if (elementaryStream.info.type === "video") {
|
|
const codec = elementaryStream.info.codec;
|
|
const CHUNK_SIZE = 1024;
|
|
if (codec !== "avc" && codec !== "hevc") {
|
|
throw new Error("Unhandled.");
|
|
}
|
|
let packetStartPos = null;
|
|
while (true) {
|
|
let remaining = context.ensureBuffered(CHUNK_SIZE);
|
|
if (remaining instanceof Promise) remaining = await remaining;
|
|
if (remaining === 0) {
|
|
break;
|
|
}
|
|
const chunkStartPos = context.currentPos;
|
|
const chunk = context.readBytes(remaining);
|
|
const length = chunk.byteLength;
|
|
let i = 0;
|
|
while (i < length) {
|
|
const zeroIndex = chunk.indexOf(0, i);
|
|
if (zeroIndex === -1 || zeroIndex >= length) {
|
|
break;
|
|
}
|
|
i = zeroIndex;
|
|
const posBeforeZero = chunkStartPos + i;
|
|
if (i + 4 >= length) {
|
|
context.seekTo(posBeforeZero);
|
|
break;
|
|
}
|
|
const b1 = chunk[i + 1];
|
|
const b2 = chunk[i + 2];
|
|
const b3 = chunk[i + 3];
|
|
let startCodeLength = 0;
|
|
let nalUnitTypeByte = null;
|
|
if (b1 === 0 && b2 === 0 && b3 === 1) {
|
|
startCodeLength = 4;
|
|
nalUnitTypeByte = chunk[i + 4];
|
|
} else if (b1 === 0 && b2 === 1) {
|
|
startCodeLength = 3;
|
|
nalUnitTypeByte = b3;
|
|
}
|
|
if (startCodeLength === 0) {
|
|
i++;
|
|
continue;
|
|
}
|
|
const startCodePos = posBeforeZero;
|
|
if (packetStartPos === null) {
|
|
packetStartPos = startCodePos;
|
|
i += startCodeLength;
|
|
continue;
|
|
}
|
|
if (nalUnitTypeByte !== null) {
|
|
const nalUnitType = codec === "avc" ? extractNalUnitTypeForAvc(nalUnitTypeByte) : extractNalUnitTypeForHevc(nalUnitTypeByte);
|
|
const isAud = codec === "avc" ? nalUnitType === 9 /* AUD */ : nalUnitType === 35 /* AUD_NUT */;
|
|
if (isAud) {
|
|
const packetLength = startCodePos - packetStartPos;
|
|
context.seekTo(packetStartPos);
|
|
return context.supplyPacket(packetLength, 0);
|
|
}
|
|
}
|
|
i += startCodeLength;
|
|
}
|
|
if (remaining < CHUNK_SIZE) {
|
|
break;
|
|
}
|
|
}
|
|
if (packetStartPos !== null) {
|
|
const packetLength = context.endPos - packetStartPos;
|
|
context.seekTo(packetStartPos);
|
|
return context.supplyPacket(packetLength, 0);
|
|
}
|
|
} else {
|
|
const codec = elementaryStream.info.codec;
|
|
const CHUNK_SIZE = 128;
|
|
while (true) {
|
|
let remaining = context.ensureBuffered(CHUNK_SIZE);
|
|
if (remaining instanceof Promise) remaining = await remaining;
|
|
const startPos = context.currentPos;
|
|
while (context.currentPos - startPos < remaining) {
|
|
const byte = context.readU8();
|
|
if (codec === "aac") {
|
|
if (byte !== 255) {
|
|
continue;
|
|
}
|
|
context.skip(-1);
|
|
const possibleHeaderStartPos = context.currentPos;
|
|
let remaining2 = context.ensureBuffered(MAX_ADTS_FRAME_HEADER_SIZE);
|
|
if (remaining2 instanceof Promise) remaining2 = await remaining2;
|
|
if (remaining2 < MAX_ADTS_FRAME_HEADER_SIZE) {
|
|
return;
|
|
}
|
|
const headerBytes = context.readBytes(MAX_ADTS_FRAME_HEADER_SIZE);
|
|
const header = readAdtsFrameHeader(FileSlice4.tempFromBytes(headerBytes));
|
|
if (header) {
|
|
context.seekTo(possibleHeaderStartPos);
|
|
let remaining3 = context.ensureBuffered(header.frameLength);
|
|
if (remaining3 instanceof Promise) remaining3 = await remaining3;
|
|
return context.supplyPacket(
|
|
remaining3,
|
|
Math.round(SAMPLES_PER_AAC_FRAME * TIMESCALE / elementaryStream.info.sampleRate)
|
|
);
|
|
} else {
|
|
context.seekTo(possibleHeaderStartPos + 1);
|
|
}
|
|
} else if (codec === "mp3") {
|
|
if (byte !== 255) {
|
|
continue;
|
|
}
|
|
context.skip(-1);
|
|
const possibleHeaderStartPos = context.currentPos;
|
|
let remaining2 = context.ensureBuffered(FRAME_HEADER_SIZE);
|
|
if (remaining2 instanceof Promise) remaining2 = await remaining2;
|
|
if (remaining2 < FRAME_HEADER_SIZE) {
|
|
return;
|
|
}
|
|
const headerBytes = context.readBytes(FRAME_HEADER_SIZE);
|
|
const word = toDataView(headerBytes).getUint32(0);
|
|
const result = readMp3FrameHeader(word, null);
|
|
if (result.header) {
|
|
context.seekTo(possibleHeaderStartPos);
|
|
let remaining3 = context.ensureBuffered(result.header.totalSize);
|
|
if (remaining3 instanceof Promise) remaining3 = await remaining3;
|
|
const duration = result.header.audioSamplesInFrame * TIMESCALE / elementaryStream.info.sampleRate;
|
|
return context.supplyPacket(remaining3, Math.round(duration));
|
|
} else {
|
|
context.seekTo(possibleHeaderStartPos + 1);
|
|
}
|
|
} else if (codec === "ac3") {
|
|
if (byte !== 11) {
|
|
continue;
|
|
}
|
|
context.skip(-1);
|
|
const possibleSyncPos = context.currentPos;
|
|
let remaining2 = context.ensureBuffered(5);
|
|
if (remaining2 instanceof Promise) remaining2 = await remaining2;
|
|
if (remaining2 < 5) {
|
|
return;
|
|
}
|
|
const headerBytes = context.readBytes(5);
|
|
if (headerBytes[0] !== 11 || headerBytes[1] !== 119) {
|
|
context.seekTo(possibleSyncPos + 1);
|
|
continue;
|
|
}
|
|
const fscod = headerBytes[4] >> 6;
|
|
const frmsizecod = headerBytes[4] & 63;
|
|
if (fscod === 3 || frmsizecod > 37) {
|
|
context.seekTo(possibleSyncPos + 1);
|
|
continue;
|
|
}
|
|
const frameSize = AC3_FRAME_SIZES[3 * frmsizecod + fscod];
|
|
assert(frameSize !== void 0);
|
|
context.seekTo(possibleSyncPos);
|
|
remaining2 = context.ensureBuffered(frameSize);
|
|
if (remaining2 instanceof Promise) remaining2 = await remaining2;
|
|
const duration = Math.round(
|
|
AC3_SAMPLES_PER_FRAME * TIMESCALE / elementaryStream.info.sampleRate
|
|
);
|
|
return context.supplyPacket(remaining2, duration);
|
|
} else if (codec === "eac3") {
|
|
if (byte !== 11) {
|
|
continue;
|
|
}
|
|
context.skip(-1);
|
|
const possibleSyncPos = context.currentPos;
|
|
let remaining2 = context.ensureBuffered(5);
|
|
if (remaining2 instanceof Promise) remaining2 = await remaining2;
|
|
if (remaining2 < 5) {
|
|
return;
|
|
}
|
|
const headerBytes = context.readBytes(5);
|
|
if (headerBytes[0] !== 11 || headerBytes[1] !== 119) {
|
|
context.seekTo(possibleSyncPos + 1);
|
|
continue;
|
|
}
|
|
const frmsiz = (headerBytes[2] & 7) << 8 | headerBytes[3];
|
|
const frameSize = (frmsiz + 1) * 2;
|
|
const fscod = headerBytes[4] >> 6;
|
|
const numblkscod = fscod === 3 ? 3 : headerBytes[4] >> 4 & 3;
|
|
const numblks = EAC3_NUMBLKS_TABLE[numblkscod];
|
|
context.seekTo(possibleSyncPos);
|
|
remaining2 = context.ensureBuffered(frameSize);
|
|
if (remaining2 instanceof Promise) remaining2 = await remaining2;
|
|
const samplesPerFrame = numblks * 256;
|
|
const duration = Math.round(
|
|
samplesPerFrame * TIMESCALE / elementaryStream.info.sampleRate
|
|
);
|
|
return context.supplyPacket(remaining2, duration);
|
|
} else {
|
|
throw new Error("Unhandled.");
|
|
}
|
|
}
|
|
if (remaining < CHUNK_SIZE) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
var PacketReadingContext = class _PacketReadingContext {
|
|
constructor(elementaryStream, startingPesPacket) {
|
|
this.currentPos = 0;
|
|
// Relative to the data in startingPesPacket
|
|
this.pesPackets = [];
|
|
this.currentPesPacketIndex = 0;
|
|
this.currentPesPacketPos = 0;
|
|
this.endPos = 0;
|
|
this.nextPts = 0;
|
|
this.suppliedPacket = null;
|
|
this.elementaryStream = elementaryStream;
|
|
this.pid = elementaryStream.pid;
|
|
this.demuxer = elementaryStream.demuxer;
|
|
this.startingPesPacket = startingPesPacket;
|
|
}
|
|
clone() {
|
|
const clone = new _PacketReadingContext(this.elementaryStream, this.startingPesPacket);
|
|
clone.currentPos = this.currentPos;
|
|
clone.pesPackets = [...this.pesPackets];
|
|
clone.currentPesPacketIndex = this.currentPesPacketIndex;
|
|
clone.currentPesPacketPos = this.currentPesPacketPos;
|
|
clone.endPos = this.endPos;
|
|
clone.nextPts = this.nextPts;
|
|
return clone;
|
|
}
|
|
ensureBuffered(length) {
|
|
const remaining = this.endPos - this.currentPos;
|
|
if (remaining >= length) {
|
|
return length;
|
|
}
|
|
return this.bufferData(length - remaining).then(() => Math.min(this.endPos - this.currentPos, length));
|
|
}
|
|
getCurrentPesPacket() {
|
|
const packet = this.pesPackets[this.currentPesPacketIndex];
|
|
assert(packet);
|
|
return packet;
|
|
}
|
|
async bufferData(length) {
|
|
const targetEndPos = this.endPos + length;
|
|
while (this.endPos < targetEndPos) {
|
|
let pesPacket;
|
|
if (this.pesPackets.length === 0) {
|
|
pesPacket = this.startingPesPacket;
|
|
} else {
|
|
let currentPos = last(this.pesPackets).sectionEndPos;
|
|
assert(currentPos !== null);
|
|
while (true) {
|
|
const packetHeader = await this.demuxer.readPacketHeader(currentPos);
|
|
if (!packetHeader) {
|
|
return;
|
|
}
|
|
if (packetHeader.pid === this.pid) {
|
|
const nextSection = await this.demuxer.readSection(currentPos, true);
|
|
if (!nextSection) {
|
|
return;
|
|
}
|
|
const nextPesPacket = readPesPacket(nextSection);
|
|
if (nextPesPacket) {
|
|
pesPacket = nextPesPacket;
|
|
break;
|
|
}
|
|
}
|
|
currentPos += this.demuxer.packetStride;
|
|
}
|
|
}
|
|
this.pesPackets.push(pesPacket);
|
|
this.endPos += pesPacket.data.byteLength;
|
|
if (this.pesPackets.length === 1) {
|
|
this.nextPts = pesPacket.pts;
|
|
}
|
|
}
|
|
}
|
|
readBytes(length) {
|
|
const currentPesPacket = this.getCurrentPesPacket();
|
|
const relativeStartOffset = this.currentPos - this.currentPesPacketPos;
|
|
const relativeEndOffset = relativeStartOffset + length;
|
|
this.currentPos += length;
|
|
if (relativeEndOffset <= currentPesPacket.data.byteLength) {
|
|
return currentPesPacket.data.subarray(relativeStartOffset, relativeEndOffset);
|
|
}
|
|
const result = new Uint8Array(length);
|
|
result.set(currentPesPacket.data.subarray(relativeStartOffset));
|
|
let offset = currentPesPacket.data.byteLength - relativeStartOffset;
|
|
while (true) {
|
|
this.advanceCurrentPacket();
|
|
const currentPesPacket2 = this.getCurrentPesPacket();
|
|
const relativeEndOffset2 = length - offset;
|
|
if (relativeEndOffset2 <= currentPesPacket2.data.byteLength) {
|
|
result.set(currentPesPacket2.data.subarray(0, relativeEndOffset2), offset);
|
|
break;
|
|
}
|
|
result.set(currentPesPacket2.data, offset);
|
|
offset += currentPesPacket2.data.byteLength;
|
|
}
|
|
return result;
|
|
}
|
|
readU8() {
|
|
let currentPesPacket = this.getCurrentPesPacket();
|
|
const relativeOffset = this.currentPos - this.currentPesPacketPos;
|
|
this.currentPos++;
|
|
if (relativeOffset < currentPesPacket.data.byteLength) {
|
|
return currentPesPacket.data[relativeOffset];
|
|
}
|
|
this.advanceCurrentPacket();
|
|
currentPesPacket = this.getCurrentPesPacket();
|
|
return currentPesPacket.data[0];
|
|
}
|
|
seekTo(pos) {
|
|
if (pos === this.currentPos) {
|
|
return;
|
|
}
|
|
if (pos < this.currentPos) {
|
|
while (pos < this.currentPesPacketPos) {
|
|
this.currentPesPacketIndex--;
|
|
const currentPacket = this.getCurrentPesPacket();
|
|
this.currentPesPacketPos -= currentPacket.data.byteLength;
|
|
this.nextPts = currentPacket.pts;
|
|
}
|
|
} else {
|
|
while (true) {
|
|
const currentPesPacket = this.getCurrentPesPacket();
|
|
const currentEndPos = this.currentPesPacketPos + currentPesPacket.data.byteLength;
|
|
if (pos < currentEndPos) {
|
|
break;
|
|
}
|
|
this.currentPesPacketPos += currentPesPacket.data.byteLength;
|
|
this.currentPesPacketIndex++;
|
|
this.nextPts = this.getCurrentPesPacket().pts;
|
|
}
|
|
}
|
|
this.currentPos = pos;
|
|
}
|
|
skip(n) {
|
|
this.seekTo(this.currentPos + n);
|
|
}
|
|
advanceCurrentPacket() {
|
|
this.currentPesPacketPos += this.getCurrentPesPacket().data.byteLength;
|
|
this.currentPesPacketIndex++;
|
|
this.nextPts = this.getCurrentPesPacket().pts;
|
|
}
|
|
/** Supplies the context with a new encoded packet, beginning at the current position. */
|
|
supplyPacket(packetLength, intrinsicDuration) {
|
|
const currentPesPacket = this.getCurrentPesPacket();
|
|
maybeInsertReferencePacket(this.elementaryStream, currentPesPacket);
|
|
const pts = this.nextPts;
|
|
this.nextPts += intrinsicDuration;
|
|
const sectionStartPos = currentPesPacket.sectionStartPos;
|
|
const sequenceNumber = sectionStartPos + (this.currentPos - this.currentPesPacketPos);
|
|
const data = this.readBytes(packetLength);
|
|
let randomAccessIndicator = currentPesPacket.randomAccessIndicator;
|
|
assert(this.elementaryStream.firstSection);
|
|
if (currentPesPacket.sectionStartPos === this.elementaryStream.firstSection.startPos) {
|
|
randomAccessIndicator = 1;
|
|
}
|
|
this.suppliedPacket = {
|
|
pts,
|
|
data,
|
|
sequenceNumber,
|
|
sectionStartPos,
|
|
randomAccessIndicator
|
|
};
|
|
this.pesPackets.splice(0, this.currentPesPacketIndex);
|
|
this.currentPesPacketIndex = 0;
|
|
}
|
|
};
|
|
var PacketBuffer = class {
|
|
constructor(backing, context) {
|
|
this.decodeOrderPackets = [];
|
|
this.reorderBuffer = [];
|
|
this.presentationOrderPackets = [];
|
|
this.reachedEnd = false;
|
|
this.lastDuration = 0;
|
|
this.backing = backing;
|
|
this.context = context;
|
|
this.reorderSize = backing.getReorderSize();
|
|
assert(this.reorderSize >= 0);
|
|
}
|
|
async readNext() {
|
|
if (this.decodeOrderPackets.length === 0) {
|
|
const didRead = await this.readNextPacket();
|
|
if (!didRead) {
|
|
return null;
|
|
}
|
|
}
|
|
await this.ensureCurrentPacketHasNext();
|
|
const packet = this.decodeOrderPackets[0];
|
|
const presentationIndex = this.presentationOrderPackets.indexOf(packet);
|
|
assert(presentationIndex !== -1);
|
|
let duration;
|
|
if (presentationIndex === this.presentationOrderPackets.length - 1) {
|
|
duration = this.lastDuration;
|
|
} else {
|
|
const nextPacket = this.presentationOrderPackets[presentationIndex + 1];
|
|
duration = nextPacket.pts - packet.pts;
|
|
this.lastDuration = duration;
|
|
}
|
|
this.decodeOrderPackets.shift();
|
|
while (this.presentationOrderPackets.length > 0) {
|
|
const first = this.presentationOrderPackets[0];
|
|
if (this.decodeOrderPackets.includes(first)) {
|
|
break;
|
|
}
|
|
this.presentationOrderPackets.shift();
|
|
}
|
|
return { packet, duration };
|
|
}
|
|
async readNextPacket() {
|
|
if (this.reachedEnd) {
|
|
return false;
|
|
}
|
|
let suppliedPacket;
|
|
if (this.context.suppliedPacket) {
|
|
suppliedPacket = this.context.suppliedPacket;
|
|
} else {
|
|
await markNextPacket(this.context);
|
|
suppliedPacket = this.context.suppliedPacket;
|
|
}
|
|
this.context.suppliedPacket = null;
|
|
if (!suppliedPacket) {
|
|
this.reachedEnd = true;
|
|
this.flushReorderBuffer();
|
|
return false;
|
|
}
|
|
this.decodeOrderPackets.push(suppliedPacket);
|
|
this.processPacketThroughReorderBuffer(suppliedPacket);
|
|
return true;
|
|
}
|
|
async ensureCurrentPacketHasNext() {
|
|
const current = this.decodeOrderPackets[0];
|
|
assert(current);
|
|
while (true) {
|
|
const presentationIndex = this.presentationOrderPackets.indexOf(current);
|
|
if (presentationIndex !== -1 && presentationIndex <= this.presentationOrderPackets.length - 2) {
|
|
break;
|
|
}
|
|
const didRead = await this.readNextPacket();
|
|
if (!didRead) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
processPacketThroughReorderBuffer(packet) {
|
|
this.reorderBuffer.push(packet);
|
|
if (this.reorderBuffer.length >= this.reorderSize) {
|
|
let minIndex = 0;
|
|
for (let i = 1; i < this.reorderBuffer.length; i++) {
|
|
if (this.reorderBuffer[i].pts < this.reorderBuffer[minIndex].pts) {
|
|
minIndex = i;
|
|
}
|
|
}
|
|
const packet2 = this.reorderBuffer.splice(minIndex, 1)[0];
|
|
this.presentationOrderPackets.push(packet2);
|
|
}
|
|
}
|
|
flushReorderBuffer() {
|
|
this.reorderBuffer.sort((a, b) => a.pts - b.pts);
|
|
this.presentationOrderPackets.push(...this.reorderBuffer);
|
|
this.reorderBuffer.length = 0;
|
|
}
|
|
};
|
|
|
|
// src/input-format.ts
|
|
var InputFormat = class {
|
|
};
|
|
var IsobmffInputFormat = class extends InputFormat {
|
|
/** @internal */
|
|
async _getMajorBrand(input) {
|
|
let slice = input._reader.requestSlice(0, 12);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) return null;
|
|
slice.skip(4);
|
|
const fourCc = readAscii(slice, 4);
|
|
if (fourCc !== "ftyp") {
|
|
return null;
|
|
}
|
|
return readAscii(slice, 4);
|
|
}
|
|
/** @internal */
|
|
_createDemuxer(input) {
|
|
return new IsobmffDemuxer(input);
|
|
}
|
|
};
|
|
var Mp4InputFormat = class extends IsobmffInputFormat {
|
|
/** @internal */
|
|
async _canReadInput(input) {
|
|
const majorBrand = await this._getMajorBrand(input);
|
|
return !!majorBrand && majorBrand !== "qt ";
|
|
}
|
|
get name() {
|
|
return "MP4";
|
|
}
|
|
get mimeType() {
|
|
return "video/mp4";
|
|
}
|
|
};
|
|
var QuickTimeInputFormat = class extends IsobmffInputFormat {
|
|
/** @internal */
|
|
async _canReadInput(input) {
|
|
const majorBrand = await this._getMajorBrand(input);
|
|
return majorBrand === "qt ";
|
|
}
|
|
get name() {
|
|
return "QuickTime File Format";
|
|
}
|
|
get mimeType() {
|
|
return "video/quicktime";
|
|
}
|
|
};
|
|
var MatroskaInputFormat = class extends InputFormat {
|
|
/** @internal */
|
|
async isSupportedEBMLOfDocType(input, desiredDocType) {
|
|
let headerSlice = input._reader.requestSlice(0, MAX_HEADER_SIZE);
|
|
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
|
|
if (!headerSlice) return false;
|
|
const varIntSize = readVarIntSize(headerSlice);
|
|
if (varIntSize === null) {
|
|
return false;
|
|
}
|
|
if (varIntSize < 1 || varIntSize > 8) {
|
|
return false;
|
|
}
|
|
const id = readUnsignedInt(headerSlice, varIntSize);
|
|
if (id !== 440786851 /* EBML */) {
|
|
return false;
|
|
}
|
|
const dataSize = readElementSize(headerSlice);
|
|
if (typeof dataSize !== "number") {
|
|
return false;
|
|
}
|
|
let dataSlice = input._reader.requestSlice(headerSlice.filePos, dataSize);
|
|
if (dataSlice instanceof Promise) dataSlice = await dataSlice;
|
|
if (!dataSlice) return false;
|
|
const startPos = headerSlice.filePos;
|
|
while (dataSlice.filePos <= startPos + dataSize - MIN_HEADER_SIZE) {
|
|
const header = readElementHeader(dataSlice);
|
|
if (!header) break;
|
|
const { id: id2, size } = header;
|
|
const dataStartPos = dataSlice.filePos;
|
|
if (size === void 0) return false;
|
|
switch (id2) {
|
|
case 17030 /* EBMLVersion */:
|
|
{
|
|
const ebmlVersion = readUnsignedInt(dataSlice, size);
|
|
if (ebmlVersion !== 1) {
|
|
return false;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 17143 /* EBMLReadVersion */:
|
|
{
|
|
const ebmlReadVersion = readUnsignedInt(dataSlice, size);
|
|
if (ebmlReadVersion !== 1) {
|
|
return false;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 17026 /* DocType */:
|
|
{
|
|
const docType = readAsciiString(dataSlice, size);
|
|
if (docType !== desiredDocType) {
|
|
return false;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 17031 /* DocTypeVersion */:
|
|
{
|
|
const docTypeVersion = readUnsignedInt(dataSlice, size);
|
|
if (docTypeVersion > 4) {
|
|
return false;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
}
|
|
dataSlice.filePos = dataStartPos + size;
|
|
}
|
|
return true;
|
|
}
|
|
/** @internal */
|
|
_canReadInput(input) {
|
|
return this.isSupportedEBMLOfDocType(input, "matroska");
|
|
}
|
|
/** @internal */
|
|
_createDemuxer(input) {
|
|
return new MatroskaDemuxer(input);
|
|
}
|
|
get name() {
|
|
return "Matroska";
|
|
}
|
|
get mimeType() {
|
|
return "video/x-matroska";
|
|
}
|
|
};
|
|
var WebMInputFormat = class extends MatroskaInputFormat {
|
|
/** @internal */
|
|
_canReadInput(input) {
|
|
return this.isSupportedEBMLOfDocType(input, "webm");
|
|
}
|
|
get name() {
|
|
return "WebM";
|
|
}
|
|
get mimeType() {
|
|
return "video/webm";
|
|
}
|
|
};
|
|
var Mp3InputFormat = class extends InputFormat {
|
|
/** @internal */
|
|
async _canReadInput(input) {
|
|
let currentPos = 0;
|
|
while (true) {
|
|
let slice2 = input._reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
|
|
if (slice2 instanceof Promise) slice2 = await slice2;
|
|
if (!slice2) break;
|
|
const id3V2Header = readId3V2Header(slice2);
|
|
if (!id3V2Header) {
|
|
break;
|
|
}
|
|
currentPos = slice2.filePos + id3V2Header.size;
|
|
}
|
|
const firstResult = await readNextMp3FrameHeader(input._reader, currentPos, currentPos + 4096);
|
|
if (!firstResult) {
|
|
return false;
|
|
}
|
|
const firstHeader = firstResult.header;
|
|
const xingOffset = getXingOffset(firstHeader.mpegVersionId, firstHeader.channel);
|
|
let slice = input._reader.requestSlice(firstResult.startPos + xingOffset, 4);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) return false;
|
|
const word = readU32Be(slice);
|
|
const isXing = word === XING || word === INFO;
|
|
if (isXing) {
|
|
return true;
|
|
}
|
|
currentPos = firstResult.startPos + firstResult.header.totalSize;
|
|
const secondResult = await readNextMp3FrameHeader(input._reader, currentPos, currentPos + FRAME_HEADER_SIZE);
|
|
if (!secondResult) {
|
|
return false;
|
|
}
|
|
const secondHeader = secondResult.header;
|
|
if (firstHeader.channel !== secondHeader.channel || firstHeader.sampleRate !== secondHeader.sampleRate) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
/** @internal */
|
|
_createDemuxer(input) {
|
|
return new Mp3Demuxer(input);
|
|
}
|
|
get name() {
|
|
return "MP3";
|
|
}
|
|
get mimeType() {
|
|
return "audio/mpeg";
|
|
}
|
|
};
|
|
var WaveInputFormat = class extends InputFormat {
|
|
/** @internal */
|
|
async _canReadInput(input) {
|
|
let slice = input._reader.requestSlice(0, 12);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) return false;
|
|
const riffType = readAscii(slice, 4);
|
|
if (riffType !== "RIFF" && riffType !== "RIFX" && riffType !== "RF64") {
|
|
return false;
|
|
}
|
|
slice.skip(4);
|
|
const format = readAscii(slice, 4);
|
|
return format === "WAVE";
|
|
}
|
|
/** @internal */
|
|
_createDemuxer(input) {
|
|
return new WaveDemuxer(input);
|
|
}
|
|
get name() {
|
|
return "WAVE";
|
|
}
|
|
get mimeType() {
|
|
return "audio/wav";
|
|
}
|
|
};
|
|
var OggInputFormat = class extends InputFormat {
|
|
/** @internal */
|
|
async _canReadInput(input) {
|
|
let slice = input._reader.requestSlice(0, 4);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) return false;
|
|
return readAscii(slice, 4) === "OggS";
|
|
}
|
|
/** @internal */
|
|
_createDemuxer(input) {
|
|
return new OggDemuxer(input);
|
|
}
|
|
get name() {
|
|
return "Ogg";
|
|
}
|
|
get mimeType() {
|
|
return "application/ogg";
|
|
}
|
|
};
|
|
var FlacInputFormat = class extends InputFormat {
|
|
/** @internal */
|
|
async _canReadInput(input) {
|
|
let slice = input._reader.requestSlice(0, 4);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) return false;
|
|
return readAscii(slice, 4) === "fLaC";
|
|
}
|
|
get name() {
|
|
return "FLAC";
|
|
}
|
|
get mimeType() {
|
|
return "audio/flac";
|
|
}
|
|
/** @internal */
|
|
_createDemuxer(input) {
|
|
return new FlacDemuxer(input);
|
|
}
|
|
};
|
|
var AdtsInputFormat = class extends InputFormat {
|
|
/** @internal */
|
|
async _canReadInput(input) {
|
|
let currentPos = 0;
|
|
while (true) {
|
|
let slice2 = input._reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
|
|
if (slice2 instanceof Promise) slice2 = await slice2;
|
|
if (!slice2) break;
|
|
const id3V2Header = readId3V2Header(slice2);
|
|
if (!id3V2Header) {
|
|
break;
|
|
}
|
|
currentPos = slice2.filePos + id3V2Header.size;
|
|
}
|
|
let slice = input._reader.requestSliceRange(
|
|
currentPos,
|
|
MIN_ADTS_FRAME_HEADER_SIZE,
|
|
MAX_ADTS_FRAME_HEADER_SIZE
|
|
);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) return false;
|
|
const firstHeader = readAdtsFrameHeader(slice);
|
|
if (!firstHeader) {
|
|
return false;
|
|
}
|
|
currentPos += firstHeader.frameLength;
|
|
slice = input._reader.requestSliceRange(
|
|
currentPos,
|
|
MIN_ADTS_FRAME_HEADER_SIZE,
|
|
MAX_ADTS_FRAME_HEADER_SIZE
|
|
);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) return false;
|
|
const secondHeader = readAdtsFrameHeader(slice);
|
|
if (!secondHeader) {
|
|
return false;
|
|
}
|
|
return firstHeader.objectType === secondHeader.objectType && firstHeader.samplingFrequencyIndex === secondHeader.samplingFrequencyIndex && firstHeader.channelConfiguration === secondHeader.channelConfiguration;
|
|
}
|
|
/** @internal */
|
|
_createDemuxer(input) {
|
|
return new AdtsDemuxer(input);
|
|
}
|
|
get name() {
|
|
return "ADTS";
|
|
}
|
|
get mimeType() {
|
|
return "audio/aac";
|
|
}
|
|
};
|
|
var MpegTsInputFormat = class extends InputFormat {
|
|
/** @internal */
|
|
async _canReadInput(input) {
|
|
const lengthToCheck = TS_PACKET_SIZE + 16 + 1;
|
|
let slice = input._reader.requestSlice(0, lengthToCheck);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
if (!slice) return false;
|
|
const bytes2 = readBytes(slice, lengthToCheck);
|
|
if (bytes2[0] === 71 && bytes2[TS_PACKET_SIZE] === 71) {
|
|
return true;
|
|
} else if (bytes2[0] === 71 && bytes2[TS_PACKET_SIZE + 16] === 71) {
|
|
return true;
|
|
} else if (bytes2[4] === 71 && bytes2[4 + TS_PACKET_SIZE] === 71) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
/** @internal */
|
|
_createDemuxer(input) {
|
|
return new MpegTsDemuxer(input);
|
|
}
|
|
get name() {
|
|
return "MPEG Transport Stream";
|
|
}
|
|
get mimeType() {
|
|
return "video/MP2T";
|
|
}
|
|
};
|
|
var MP4 = /* @__PURE__ */ new Mp4InputFormat();
|
|
var QTFF = /* @__PURE__ */ new QuickTimeInputFormat();
|
|
var MATROSKA = /* @__PURE__ */ new MatroskaInputFormat();
|
|
var WEBM = /* @__PURE__ */ new WebMInputFormat();
|
|
var MP3 = /* @__PURE__ */ new Mp3InputFormat();
|
|
var WAVE = /* @__PURE__ */ new WaveInputFormat();
|
|
var OGG = /* @__PURE__ */ new OggInputFormat();
|
|
var ADTS = /* @__PURE__ */ new AdtsInputFormat();
|
|
var FLAC = /* @__PURE__ */ new FlacInputFormat();
|
|
var MPEG_TS = /* @__PURE__ */ new MpegTsInputFormat();
|
|
var ALL_FORMATS = [MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, FLAC, MP3, ADTS, MPEG_TS];
|
|
|
|
// src/source.ts
|
|
var nodeAlias = __toESM(require_node(), 1);
|
|
var node = typeof nodeAlias !== "undefined" ? nodeAlias : void 0;
|
|
var Source = class {
|
|
constructor() {
|
|
/** @internal */
|
|
this._disposed = false;
|
|
/** @internal */
|
|
this._sizePromise = null;
|
|
/** Called each time data is retrieved from the source. Will be called with the retrieved range (end exclusive). */
|
|
this.onread = null;
|
|
}
|
|
/**
|
|
* Resolves with the total size of the file in bytes. This function is memoized, meaning only the first call
|
|
* will retrieve the size.
|
|
*
|
|
* Returns null if the source is unsized.
|
|
*/
|
|
async getSizeOrNull() {
|
|
if (this._disposed) {
|
|
throw new InputDisposedError();
|
|
}
|
|
return this._sizePromise ??= Promise.resolve(this._retrieveSize());
|
|
}
|
|
/**
|
|
* Resolves with the total size of the file in bytes. This function is memoized, meaning only the first call
|
|
* will retrieve the size.
|
|
*
|
|
* Throws an error if the source is unsized.
|
|
*/
|
|
async getSize() {
|
|
if (this._disposed) {
|
|
throw new InputDisposedError();
|
|
}
|
|
const result = await this.getSizeOrNull();
|
|
if (result === null) {
|
|
throw new Error("Cannot determine the size of an unsized source.");
|
|
}
|
|
return result;
|
|
}
|
|
};
|
|
var BufferSource = class extends Source {
|
|
/**
|
|
* Creates a new {@link BufferSource} backed by the specified `ArrayBuffer`, `SharedArrayBuffer`,
|
|
* or `ArrayBufferView`.
|
|
*/
|
|
constructor(buffer) {
|
|
if (!(buffer instanceof ArrayBuffer) && !(typeof SharedArrayBuffer !== "undefined" && buffer instanceof SharedArrayBuffer) && !ArrayBuffer.isView(buffer)) {
|
|
throw new TypeError("buffer must be an ArrayBuffer, SharedArrayBuffer, or ArrayBufferView.");
|
|
}
|
|
super();
|
|
/** @internal */
|
|
this._onreadCalled = false;
|
|
this._bytes = toUint8Array(buffer);
|
|
this._view = toDataView(buffer);
|
|
}
|
|
/** @internal */
|
|
_retrieveSize() {
|
|
return this._bytes.byteLength;
|
|
}
|
|
/** @internal */
|
|
_read() {
|
|
if (!this._onreadCalled) {
|
|
this.onread?.(0, this._bytes.byteLength);
|
|
this._onreadCalled = true;
|
|
}
|
|
return {
|
|
bytes: this._bytes,
|
|
view: this._view,
|
|
offset: 0
|
|
};
|
|
}
|
|
/** @internal */
|
|
_dispose() {
|
|
}
|
|
};
|
|
var BlobSource = class extends Source {
|
|
/**
|
|
* Creates a new {@link BlobSource} backed by the specified
|
|
* [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob).
|
|
*/
|
|
constructor(blob, options = {}) {
|
|
if (!(blob instanceof Blob)) {
|
|
throw new TypeError("blob must be a Blob.");
|
|
}
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.maxCacheSize !== void 0 && (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)) {
|
|
throw new TypeError("options.maxCacheSize, when provided, must be a non-negative number.");
|
|
}
|
|
super();
|
|
/** @internal */
|
|
this._readers = /* @__PURE__ */ new WeakMap();
|
|
this._blob = blob;
|
|
this._orchestrator = new ReadOrchestrator({
|
|
maxCacheSize: options.maxCacheSize ?? 8 * 2 ** 20,
|
|
maxWorkerCount: 4,
|
|
runWorker: this._runWorker.bind(this),
|
|
prefetchProfile: PREFETCH_PROFILES.fileSystem
|
|
});
|
|
}
|
|
/** @internal */
|
|
_retrieveSize() {
|
|
const size = this._blob.size;
|
|
this._orchestrator.fileSize = size;
|
|
return size;
|
|
}
|
|
/** @internal */
|
|
_read(start, end) {
|
|
return this._orchestrator.read(start, end);
|
|
}
|
|
/** @internal */
|
|
async _runWorker(worker) {
|
|
let reader = this._readers.get(worker);
|
|
if (reader === void 0) {
|
|
if ("stream" in this._blob && !isWebKit()) {
|
|
const slice = this._blob.slice(worker.currentPos);
|
|
reader = slice.stream().getReader();
|
|
} else {
|
|
reader = null;
|
|
}
|
|
this._readers.set(worker, reader);
|
|
}
|
|
while (worker.currentPos < worker.targetPos && !worker.aborted) {
|
|
if (reader) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
this._orchestrator.forgetWorker(worker);
|
|
throw new Error("Blob reader stopped unexpectedly before all requested data was read.");
|
|
}
|
|
if (worker.aborted) {
|
|
break;
|
|
}
|
|
this.onread?.(worker.currentPos, worker.currentPos + value.length);
|
|
this._orchestrator.supplyWorkerData(worker, value);
|
|
} else {
|
|
const data = await this._blob.slice(worker.currentPos, worker.targetPos).arrayBuffer();
|
|
if (worker.aborted) {
|
|
break;
|
|
}
|
|
this.onread?.(worker.currentPos, worker.currentPos + data.byteLength);
|
|
this._orchestrator.supplyWorkerData(worker, new Uint8Array(data));
|
|
}
|
|
}
|
|
worker.running = false;
|
|
if (worker.aborted) {
|
|
await reader?.cancel();
|
|
}
|
|
}
|
|
/** @internal */
|
|
_dispose() {
|
|
this._orchestrator.dispose();
|
|
}
|
|
};
|
|
var URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20;
|
|
var DEFAULT_RETRY_DELAY = (previousAttempts, error, src) => {
|
|
const couldBeCorsError = error instanceof Error && (error.message.includes("Failed to fetch") || error.message.includes("Load failed") || error.message.includes("NetworkError when attempting to fetch resource"));
|
|
if (couldBeCorsError) {
|
|
let originOfSrc = null;
|
|
try {
|
|
if (typeof window !== "undefined" && typeof window.location !== "undefined") {
|
|
originOfSrc = new URL(src instanceof Request ? src.url : src, window.location.href).origin;
|
|
}
|
|
} catch {
|
|
}
|
|
const isOnline = typeof navigator !== "undefined" && typeof navigator.onLine === "boolean" ? navigator.onLine : true;
|
|
if (isOnline && originOfSrc !== null && originOfSrc !== window.location.origin) {
|
|
console.warn(
|
|
`Request will not be retried because a CORS error was suspected due to different origins. You can modify this behavior by providing your own function for the 'getRetryDelay' option.`
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
return Math.min(2 ** (previousAttempts - 2), 16);
|
|
};
|
|
var UrlSource = class extends Source {
|
|
/**
|
|
* Creates a new {@link UrlSource} backed by the resource at the specified URL.
|
|
*
|
|
* When passing a `Request` instance, note that the `signal` and `headers.Range` options will be overridden by
|
|
* Mediabunny. If you want to cancel ongoing requests, use {@link Input.dispose}.
|
|
*/
|
|
constructor(url2, options = {}) {
|
|
if (typeof url2 !== "string" && !(url2 instanceof URL) && !(typeof Request !== "undefined" && url2 instanceof Request)) {
|
|
throw new TypeError("url must be a string, URL or Request.");
|
|
}
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.requestInit !== void 0 && (!options.requestInit || typeof options.requestInit !== "object")) {
|
|
throw new TypeError("options.requestInit, when provided, must be an object.");
|
|
}
|
|
if (options.getRetryDelay !== void 0 && typeof options.getRetryDelay !== "function") {
|
|
throw new TypeError("options.getRetryDelay, when provided, must be a function.");
|
|
}
|
|
if (options.maxCacheSize !== void 0 && (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)) {
|
|
throw new TypeError("options.maxCacheSize, when provided, must be a non-negative number.");
|
|
}
|
|
if (options.parallelism !== void 0 && (!Number.isInteger(options.parallelism) || options.parallelism < 1)) {
|
|
throw new TypeError("options.parallelism, when provided, must be a positive number.");
|
|
}
|
|
if (options.fetchFn !== void 0 && typeof options.fetchFn !== "function") {
|
|
throw new TypeError("options.fetchFn, when provided, must be a function.");
|
|
}
|
|
super();
|
|
/** @internal */
|
|
this._existingResponses = /* @__PURE__ */ new WeakMap();
|
|
this._url = url2;
|
|
this._options = options;
|
|
this._getRetryDelay = options.getRetryDelay ?? DEFAULT_RETRY_DELAY;
|
|
const DEFAULT_PARALLELISM = 2;
|
|
this._orchestrator = new ReadOrchestrator({
|
|
maxCacheSize: options.maxCacheSize ?? 64 * 2 ** 20,
|
|
maxWorkerCount: options.parallelism ?? DEFAULT_PARALLELISM,
|
|
runWorker: this._runWorker.bind(this),
|
|
prefetchProfile: PREFETCH_PROFILES.network
|
|
});
|
|
}
|
|
/** @internal */
|
|
async _retrieveSize() {
|
|
const abortController = new AbortController();
|
|
const response = await retriedFetch(
|
|
this._options.fetchFn ?? fetch,
|
|
this._url,
|
|
mergeRequestInit(this._options.requestInit ?? {}, {
|
|
headers: {
|
|
// We could also send a non-range request to request the same bytes (all of them), but doing it like
|
|
// this is an easy way to check if the server supports range requests in the first place
|
|
Range: "bytes=0-"
|
|
},
|
|
signal: abortController.signal
|
|
}),
|
|
this._getRetryDelay,
|
|
() => this._disposed
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`Error fetching ${String(this._url)}: ${response.status} ${response.statusText}`);
|
|
}
|
|
let worker;
|
|
let fileSize;
|
|
if (response.status === 206) {
|
|
fileSize = this._getTotalLengthFromRangeResponse(response);
|
|
worker = this._orchestrator.createWorker(0, Math.min(fileSize, URL_SOURCE_MIN_LOAD_AMOUNT));
|
|
} else {
|
|
const contentLength = response.headers.get("Content-Length");
|
|
if (contentLength) {
|
|
fileSize = Number(contentLength);
|
|
worker = this._orchestrator.createWorker(0, fileSize);
|
|
this._orchestrator.options.maxCacheSize = Infinity;
|
|
console.warn(
|
|
"HTTP server did not respond with 206 Partial Content, meaning the entire remote resource now has to be downloaded. For efficient media file streaming across a network, please make sure your server supports range requests."
|
|
);
|
|
} else {
|
|
throw new Error(`HTTP response (status ${response.status}) must surface Content-Length header.`);
|
|
}
|
|
}
|
|
this._orchestrator.fileSize = fileSize;
|
|
this._existingResponses.set(worker, { response, abortController });
|
|
this._orchestrator.runWorker(worker);
|
|
return fileSize;
|
|
}
|
|
/** @internal */
|
|
_read(start, end) {
|
|
return this._orchestrator.read(start, end);
|
|
}
|
|
/** @internal */
|
|
async _runWorker(worker) {
|
|
while (true) {
|
|
const existing = this._existingResponses.get(worker);
|
|
this._existingResponses.delete(worker);
|
|
let abortController = existing?.abortController;
|
|
let response = existing?.response;
|
|
if (!abortController) {
|
|
abortController = new AbortController();
|
|
response = await retriedFetch(
|
|
this._options.fetchFn ?? fetch,
|
|
this._url,
|
|
mergeRequestInit(this._options.requestInit ?? {}, {
|
|
headers: {
|
|
Range: `bytes=${worker.currentPos}-`
|
|
},
|
|
signal: abortController.signal
|
|
}),
|
|
this._getRetryDelay,
|
|
() => this._disposed
|
|
);
|
|
}
|
|
assert(response);
|
|
if (!response.ok) {
|
|
throw new Error(`Error fetching ${String(this._url)}: ${response.status} ${response.statusText}`);
|
|
}
|
|
if (worker.currentPos > 0 && response.status !== 206) {
|
|
throw new Error(
|
|
"HTTP server did not respond with 206 Partial Content to a range request. To enable efficient media file streaming across a network, please make sure your server supports range requests."
|
|
);
|
|
}
|
|
if (!response.body) {
|
|
throw new Error(
|
|
"Missing HTTP response body stream. The used fetch function must provide the response body as a ReadableStream."
|
|
);
|
|
}
|
|
const reader = response.body.getReader();
|
|
while (true) {
|
|
if (worker.currentPos >= worker.targetPos || worker.aborted) {
|
|
abortController.abort();
|
|
worker.running = false;
|
|
return;
|
|
}
|
|
let readResult;
|
|
try {
|
|
readResult = await reader.read();
|
|
} catch (error) {
|
|
if (this._disposed) {
|
|
throw error;
|
|
}
|
|
const retryDelayInSeconds = this._getRetryDelay(1, error, this._url);
|
|
if (retryDelayInSeconds !== null) {
|
|
console.error("Error while reading response stream. Attempting to resume.", error);
|
|
await new Promise((resolve) => setTimeout(resolve, 1e3 * retryDelayInSeconds));
|
|
break;
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
if (worker.aborted) {
|
|
continue;
|
|
}
|
|
const { done, value } = readResult;
|
|
if (done) {
|
|
if (worker.currentPos >= worker.targetPos) {
|
|
this._orchestrator.forgetWorker(worker);
|
|
worker.running = false;
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
this.onread?.(worker.currentPos, worker.currentPos + value.length);
|
|
this._orchestrator.supplyWorkerData(worker, value);
|
|
}
|
|
}
|
|
}
|
|
/** @internal */
|
|
_getTotalLengthFromRangeResponse(response) {
|
|
const contentRange = response.headers.get("Content-Range");
|
|
if (contentRange) {
|
|
const match = /\/(\d+)/.exec(contentRange);
|
|
if (match) {
|
|
return Number(match[1]);
|
|
}
|
|
}
|
|
const contentLength = response.headers.get("Content-Length");
|
|
if (contentLength) {
|
|
return Number(contentLength);
|
|
} else {
|
|
throw new Error(
|
|
"Partial HTTP response (status 206) must surface either Content-Range or Content-Length header."
|
|
);
|
|
}
|
|
}
|
|
/** @internal */
|
|
_dispose() {
|
|
this._orchestrator.dispose();
|
|
}
|
|
};
|
|
var FilePathSource = class extends Source {
|
|
/** Creates a new {@link FilePathSource} backed by the file at the specified file path. */
|
|
constructor(filePath, options = {}) {
|
|
if (typeof filePath !== "string") {
|
|
throw new TypeError("filePath must be a string.");
|
|
}
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.maxCacheSize !== void 0 && (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)) {
|
|
throw new TypeError("options.maxCacheSize, when provided, must be a non-negative number.");
|
|
}
|
|
super();
|
|
/** @internal */
|
|
this._fileHandle = null;
|
|
this._streamSource = new StreamSource({
|
|
getSize: async () => {
|
|
this._fileHandle = await node.fs.open(filePath, "r");
|
|
const stats = await this._fileHandle.stat();
|
|
return stats.size;
|
|
},
|
|
read: async (start, end) => {
|
|
assert(this._fileHandle);
|
|
const buffer = new Uint8Array(end - start);
|
|
await this._fileHandle.read(buffer, 0, end - start, start);
|
|
return buffer;
|
|
},
|
|
maxCacheSize: options.maxCacheSize,
|
|
prefetchProfile: "fileSystem"
|
|
});
|
|
}
|
|
/** @internal */
|
|
_read(start, end) {
|
|
return this._streamSource._read(start, end);
|
|
}
|
|
/** @internal */
|
|
_retrieveSize() {
|
|
return this._streamSource._retrieveSize();
|
|
}
|
|
/** @internal */
|
|
_dispose() {
|
|
this._streamSource._dispose();
|
|
void this._fileHandle?.close();
|
|
this._fileHandle = null;
|
|
}
|
|
};
|
|
var StreamSource = class extends Source {
|
|
/** Creates a new {@link StreamSource} whose behavior is specified by `options`. */
|
|
constructor(options) {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (typeof options.getSize !== "function") {
|
|
throw new TypeError("options.getSize must be a function.");
|
|
}
|
|
if (typeof options.read !== "function") {
|
|
throw new TypeError("options.read must be a function.");
|
|
}
|
|
if (options.dispose !== void 0 && typeof options.dispose !== "function") {
|
|
throw new TypeError("options.dispose, when provided, must be a function.");
|
|
}
|
|
if (options.maxCacheSize !== void 0 && (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)) {
|
|
throw new TypeError("options.maxCacheSize, when provided, must be a non-negative number.");
|
|
}
|
|
if (options.prefetchProfile && !["none", "fileSystem", "network"].includes(options.prefetchProfile)) {
|
|
throw new TypeError(
|
|
"options.prefetchProfile, when provided, must be one of 'none', 'fileSystem' or 'network'."
|
|
);
|
|
}
|
|
super();
|
|
this._options = options;
|
|
this._orchestrator = new ReadOrchestrator({
|
|
maxCacheSize: options.maxCacheSize ?? 8 * 2 ** 20,
|
|
maxWorkerCount: 2,
|
|
// Fixed for now, *should* be fine
|
|
prefetchProfile: PREFETCH_PROFILES[options.prefetchProfile ?? "none"],
|
|
runWorker: this._runWorker.bind(this)
|
|
});
|
|
}
|
|
/** @internal */
|
|
_retrieveSize() {
|
|
const result = this._options.getSize();
|
|
if (result instanceof Promise) {
|
|
return result.then((size) => {
|
|
if (!Number.isInteger(size) || size < 0) {
|
|
throw new TypeError("options.getSize must return or resolve to a non-negative integer.");
|
|
}
|
|
this._orchestrator.fileSize = size;
|
|
return size;
|
|
});
|
|
} else {
|
|
if (!Number.isInteger(result) || result < 0) {
|
|
throw new TypeError("options.getSize must return or resolve to a non-negative integer.");
|
|
}
|
|
this._orchestrator.fileSize = result;
|
|
return result;
|
|
}
|
|
}
|
|
/** @internal */
|
|
_read(start, end) {
|
|
return this._orchestrator.read(start, end);
|
|
}
|
|
/** @internal */
|
|
async _runWorker(worker) {
|
|
while (worker.currentPos < worker.targetPos && !worker.aborted) {
|
|
const originalCurrentPos = worker.currentPos;
|
|
const originalTargetPos = worker.targetPos;
|
|
let data = this._options.read(worker.currentPos, originalTargetPos);
|
|
if (data instanceof Promise) data = await data;
|
|
if (worker.aborted) {
|
|
break;
|
|
}
|
|
if (data instanceof Uint8Array) {
|
|
data = toUint8Array(data);
|
|
if (data.length !== originalTargetPos - worker.currentPos) {
|
|
throw new Error(
|
|
`options.read returned a Uint8Array with unexpected length: Requested ${originalTargetPos - worker.currentPos} bytes, but got ${data.length}.`
|
|
);
|
|
}
|
|
this.onread?.(worker.currentPos, worker.currentPos + data.length);
|
|
this._orchestrator.supplyWorkerData(worker, data);
|
|
} else if (data instanceof ReadableStream) {
|
|
const reader = data.getReader();
|
|
while (worker.currentPos < originalTargetPos && !worker.aborted) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
if (worker.currentPos < originalTargetPos) {
|
|
throw new Error(
|
|
`ReadableStream returned by options.read ended before supplying enough data. Requested ${originalTargetPos - originalCurrentPos} bytes, but got ${worker.currentPos - originalCurrentPos}`
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
if (!(value instanceof Uint8Array)) {
|
|
throw new TypeError("ReadableStream returned by options.read must yield Uint8Array chunks.");
|
|
}
|
|
if (worker.aborted) {
|
|
break;
|
|
}
|
|
const data2 = toUint8Array(value);
|
|
this.onread?.(worker.currentPos, worker.currentPos + data2.length);
|
|
this._orchestrator.supplyWorkerData(worker, data2);
|
|
}
|
|
} else {
|
|
throw new TypeError("options.read must return or resolve to a Uint8Array or a ReadableStream.");
|
|
}
|
|
}
|
|
worker.running = false;
|
|
}
|
|
/** @internal */
|
|
_dispose() {
|
|
this._orchestrator.dispose();
|
|
this._options.dispose?.();
|
|
}
|
|
};
|
|
var ReadableStreamSource = class extends Source {
|
|
/** Creates a new {@link ReadableStreamSource} backed by the specified `ReadableStream<Uint8Array>`. */
|
|
constructor(stream, options = {}) {
|
|
if (!(stream instanceof ReadableStream)) {
|
|
throw new TypeError("stream must be a ReadableStream.");
|
|
}
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.maxCacheSize !== void 0 && (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)) {
|
|
throw new TypeError("options.maxCacheSize, when provided, must be a non-negative number.");
|
|
}
|
|
super();
|
|
/** @internal */
|
|
this._reader = null;
|
|
/** @internal */
|
|
this._cache = [];
|
|
/** @internal */
|
|
this._pendingSlices = [];
|
|
/** @internal */
|
|
this._currentIndex = 0;
|
|
/** @internal */
|
|
this._targetIndex = 0;
|
|
/** @internal */
|
|
this._maxRequestedIndex = 0;
|
|
/** @internal */
|
|
this._endIndex = null;
|
|
/** @internal */
|
|
this._pulling = false;
|
|
this._stream = stream;
|
|
this._maxCacheSize = options.maxCacheSize ?? 16 * 2 ** 20;
|
|
}
|
|
/** @internal */
|
|
_retrieveSize() {
|
|
return this._endIndex;
|
|
}
|
|
/** @internal */
|
|
_read(start, end) {
|
|
if (this._endIndex !== null && end > this._endIndex) {
|
|
return null;
|
|
}
|
|
this._maxRequestedIndex = Math.max(this._maxRequestedIndex, end);
|
|
const cacheStartIndex = binarySearchLessOrEqual(this._cache, start, (x) => x.start);
|
|
const cacheStartEntry = cacheStartIndex !== -1 ? this._cache[cacheStartIndex] : null;
|
|
if (cacheStartEntry && cacheStartEntry.start <= start && end <= cacheStartEntry.end) {
|
|
return {
|
|
bytes: cacheStartEntry.bytes,
|
|
view: cacheStartEntry.view,
|
|
offset: cacheStartEntry.start
|
|
};
|
|
}
|
|
let lastEnd = start;
|
|
const bytes2 = new Uint8Array(end - start);
|
|
if (cacheStartIndex !== -1) {
|
|
for (let i = cacheStartIndex; i < this._cache.length; i++) {
|
|
const cacheEntry = this._cache[i];
|
|
if (cacheEntry.start >= end) {
|
|
break;
|
|
}
|
|
const cappedStart = Math.max(start, cacheEntry.start);
|
|
if (cappedStart > lastEnd) {
|
|
this._throwDueToCacheMiss();
|
|
}
|
|
const cappedEnd = Math.min(end, cacheEntry.end);
|
|
if (cappedStart < cappedEnd) {
|
|
bytes2.set(
|
|
cacheEntry.bytes.subarray(cappedStart - cacheEntry.start, cappedEnd - cacheEntry.start),
|
|
cappedStart - start
|
|
);
|
|
lastEnd = cappedEnd;
|
|
}
|
|
}
|
|
}
|
|
if (lastEnd === end) {
|
|
return {
|
|
bytes: bytes2,
|
|
view: toDataView(bytes2),
|
|
offset: start
|
|
};
|
|
}
|
|
if (this._currentIndex > lastEnd) {
|
|
this._throwDueToCacheMiss();
|
|
}
|
|
const { promise, resolve, reject } = promiseWithResolvers();
|
|
this._pendingSlices.push({
|
|
start,
|
|
end,
|
|
bytes: bytes2,
|
|
resolve,
|
|
reject
|
|
});
|
|
this._targetIndex = Math.max(this._targetIndex, end);
|
|
if (!this._pulling) {
|
|
this._pulling = true;
|
|
void this._pull().catch((error) => {
|
|
this._pulling = false;
|
|
if (this._pendingSlices.length > 0) {
|
|
this._pendingSlices.forEach((x) => x.reject(error));
|
|
this._pendingSlices.length = 0;
|
|
} else {
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
return promise;
|
|
}
|
|
/** @internal */
|
|
_throwDueToCacheMiss() {
|
|
throw new Error(
|
|
"Read is before the cached region. With ReadableStreamSource, you must access the data more sequentially or increase the size of its cache."
|
|
);
|
|
}
|
|
/** @internal */
|
|
async _pull() {
|
|
this._reader ??= this._stream.getReader();
|
|
while (this._currentIndex < this._targetIndex && !this._disposed) {
|
|
const { done, value } = await this._reader.read();
|
|
if (done) {
|
|
for (const pendingSlice of this._pendingSlices) {
|
|
pendingSlice.resolve(null);
|
|
}
|
|
this._pendingSlices.length = 0;
|
|
this._endIndex = this._currentIndex;
|
|
break;
|
|
}
|
|
const startIndex = this._currentIndex;
|
|
const endIndex = this._currentIndex + value.byteLength;
|
|
for (let i = 0; i < this._pendingSlices.length; i++) {
|
|
const pendingSlice = this._pendingSlices[i];
|
|
const cappedStart = Math.max(startIndex, pendingSlice.start);
|
|
const cappedEnd = Math.min(endIndex, pendingSlice.end);
|
|
if (cappedStart < cappedEnd) {
|
|
pendingSlice.bytes.set(
|
|
value.subarray(cappedStart - startIndex, cappedEnd - startIndex),
|
|
cappedStart - pendingSlice.start
|
|
);
|
|
if (cappedEnd === pendingSlice.end) {
|
|
pendingSlice.resolve({
|
|
bytes: pendingSlice.bytes,
|
|
view: toDataView(pendingSlice.bytes),
|
|
offset: pendingSlice.start
|
|
});
|
|
this._pendingSlices.splice(i, 1);
|
|
i--;
|
|
}
|
|
}
|
|
}
|
|
this._cache.push({
|
|
start: startIndex,
|
|
end: endIndex,
|
|
bytes: value,
|
|
view: toDataView(value),
|
|
age: 0
|
|
// Unused
|
|
});
|
|
while (this._cache.length > 0) {
|
|
const firstEntry = this._cache[0];
|
|
const distance = this._maxRequestedIndex - firstEntry.end;
|
|
if (distance <= this._maxCacheSize) {
|
|
break;
|
|
}
|
|
this._cache.shift();
|
|
}
|
|
this._currentIndex += value.byteLength;
|
|
}
|
|
this._pulling = false;
|
|
}
|
|
/** @internal */
|
|
_dispose() {
|
|
this._pendingSlices.length = 0;
|
|
this._cache.length = 0;
|
|
}
|
|
};
|
|
var PREFETCH_PROFILES = {
|
|
none: (start, end) => ({ start, end }),
|
|
fileSystem: (start, end) => {
|
|
const padding = 2 ** 16;
|
|
start = Math.floor((start - padding) / padding) * padding;
|
|
end = Math.ceil((end + padding) / padding) * padding;
|
|
return { start, end };
|
|
},
|
|
network: (start, end, workers) => {
|
|
const paddingStart = 2 ** 16;
|
|
start = Math.max(0, Math.floor((start - paddingStart) / paddingStart) * paddingStart);
|
|
for (const worker of workers) {
|
|
const maxExtensionAmount = 8 * 2 ** 20;
|
|
const thresholdPoint = Math.max(
|
|
(worker.startPos + worker.targetPos) / 2,
|
|
worker.targetPos - maxExtensionAmount
|
|
);
|
|
if (closedIntervalsOverlap(
|
|
start,
|
|
end,
|
|
thresholdPoint,
|
|
worker.targetPos
|
|
)) {
|
|
const size = worker.targetPos - worker.startPos;
|
|
const a = Math.ceil((size + 1) / maxExtensionAmount) * maxExtensionAmount;
|
|
const b = 2 ** Math.ceil(Math.log2(size + 1));
|
|
const extent = Math.min(b, a);
|
|
end = Math.max(end, worker.startPos + extent);
|
|
}
|
|
}
|
|
end = Math.max(end, start + URL_SOURCE_MIN_LOAD_AMOUNT);
|
|
return {
|
|
start,
|
|
end
|
|
};
|
|
}
|
|
};
|
|
var ReadOrchestrator = class {
|
|
constructor(options) {
|
|
this.options = options;
|
|
this.fileSize = null;
|
|
this.nextAge = 0;
|
|
// Used for LRU eviction of both cache entries and workers
|
|
this.workers = [];
|
|
this.cache = [];
|
|
this.currentCacheSize = 0;
|
|
this.disposed = false;
|
|
}
|
|
read(innerStart, innerEnd) {
|
|
assert(this.fileSize !== null);
|
|
const prefetchRange = this.options.prefetchProfile(innerStart, innerEnd, this.workers);
|
|
const outerStart = Math.max(prefetchRange.start, 0);
|
|
const outerEnd = Math.min(prefetchRange.end, this.fileSize);
|
|
assert(outerStart <= innerStart && innerEnd <= outerEnd);
|
|
let result = null;
|
|
const innerCacheStartIndex = binarySearchLessOrEqual(this.cache, innerStart, (x) => x.start);
|
|
const innerStartEntry = innerCacheStartIndex !== -1 ? this.cache[innerCacheStartIndex] : null;
|
|
if (innerStartEntry && innerStartEntry.start <= innerStart && innerEnd <= innerStartEntry.end) {
|
|
innerStartEntry.age = this.nextAge++;
|
|
result = {
|
|
bytes: innerStartEntry.bytes,
|
|
view: innerStartEntry.view,
|
|
offset: innerStartEntry.start
|
|
};
|
|
}
|
|
const outerCacheStartIndex = binarySearchLessOrEqual(this.cache, outerStart, (x) => x.start);
|
|
const bytes2 = result ? null : new Uint8Array(innerEnd - innerStart);
|
|
let contiguousBytesWriteEnd = 0;
|
|
let lastEnd = outerStart;
|
|
const outerHoles = [];
|
|
if (outerCacheStartIndex !== -1) {
|
|
for (let i = outerCacheStartIndex; i < this.cache.length; i++) {
|
|
const entry = this.cache[i];
|
|
if (entry.start >= outerEnd) {
|
|
break;
|
|
}
|
|
if (entry.end <= outerStart) {
|
|
continue;
|
|
}
|
|
const cappedOuterStart = Math.max(outerStart, entry.start);
|
|
const cappedOuterEnd = Math.min(outerEnd, entry.end);
|
|
assert(cappedOuterStart <= cappedOuterEnd);
|
|
if (lastEnd < cappedOuterStart) {
|
|
outerHoles.push({ start: lastEnd, end: cappedOuterStart });
|
|
}
|
|
lastEnd = cappedOuterEnd;
|
|
if (bytes2) {
|
|
const cappedInnerStart = Math.max(innerStart, entry.start);
|
|
const cappedInnerEnd = Math.min(innerEnd, entry.end);
|
|
if (cappedInnerStart < cappedInnerEnd) {
|
|
const relativeOffset = cappedInnerStart - innerStart;
|
|
bytes2.set(
|
|
entry.bytes.subarray(cappedInnerStart - entry.start, cappedInnerEnd - entry.start),
|
|
relativeOffset
|
|
);
|
|
if (relativeOffset === contiguousBytesWriteEnd) {
|
|
contiguousBytesWriteEnd = cappedInnerEnd - innerStart;
|
|
}
|
|
}
|
|
}
|
|
entry.age = this.nextAge++;
|
|
}
|
|
if (lastEnd < outerEnd) {
|
|
outerHoles.push({ start: lastEnd, end: outerEnd });
|
|
}
|
|
} else {
|
|
outerHoles.push({ start: outerStart, end: outerEnd });
|
|
}
|
|
if (bytes2 && contiguousBytesWriteEnd >= bytes2.length) {
|
|
result = {
|
|
bytes: bytes2,
|
|
view: toDataView(bytes2),
|
|
offset: innerStart
|
|
};
|
|
}
|
|
if (outerHoles.length === 0) {
|
|
assert(result);
|
|
return result;
|
|
}
|
|
const { promise, resolve, reject } = promiseWithResolvers();
|
|
const innerHoles = [];
|
|
for (const outerHole of outerHoles) {
|
|
const cappedStart = Math.max(innerStart, outerHole.start);
|
|
const cappedEnd = Math.min(innerEnd, outerHole.end);
|
|
if (cappedStart === outerHole.start && cappedEnd === outerHole.end) {
|
|
innerHoles.push(outerHole);
|
|
} else if (cappedStart < cappedEnd) {
|
|
innerHoles.push({ start: cappedStart, end: cappedEnd });
|
|
}
|
|
}
|
|
for (const outerHole of outerHoles) {
|
|
const pendingSlice = bytes2 && {
|
|
start: innerStart,
|
|
bytes: bytes2,
|
|
holes: innerHoles,
|
|
resolve,
|
|
reject
|
|
};
|
|
let workerFound = false;
|
|
for (const worker of this.workers) {
|
|
const gapTolerance = 2 ** 17;
|
|
if (closedIntervalsOverlap(
|
|
outerHole.start - gapTolerance,
|
|
outerHole.start,
|
|
worker.currentPos,
|
|
worker.targetPos
|
|
)) {
|
|
worker.targetPos = Math.max(worker.targetPos, outerHole.end);
|
|
workerFound = true;
|
|
if (pendingSlice && !worker.pendingSlices.includes(pendingSlice)) {
|
|
worker.pendingSlices.push(pendingSlice);
|
|
}
|
|
if (!worker.running) {
|
|
this.runWorker(worker);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (!workerFound) {
|
|
const newWorker = this.createWorker(outerHole.start, outerHole.end);
|
|
if (pendingSlice) {
|
|
newWorker.pendingSlices = [pendingSlice];
|
|
}
|
|
this.runWorker(newWorker);
|
|
}
|
|
}
|
|
if (!result) {
|
|
assert(bytes2);
|
|
result = promise.then((bytes3) => ({
|
|
bytes: bytes3,
|
|
view: toDataView(bytes3),
|
|
offset: innerStart
|
|
}));
|
|
} else {
|
|
}
|
|
return result;
|
|
}
|
|
createWorker(startPos, targetPos) {
|
|
const worker = {
|
|
startPos,
|
|
currentPos: startPos,
|
|
targetPos,
|
|
running: false,
|
|
// Due to async shenanigans, it can happen that workers are started after disposal. In this case, instead of
|
|
// simply not creating the worker, we allow it to run but immediately label it as aborted, so it can then
|
|
// shut itself down.
|
|
aborted: this.disposed,
|
|
pendingSlices: [],
|
|
age: this.nextAge++
|
|
};
|
|
this.workers.push(worker);
|
|
while (this.workers.length > this.options.maxWorkerCount) {
|
|
let oldestIndex = 0;
|
|
let oldestWorker = this.workers[0];
|
|
for (let i = 1; i < this.workers.length; i++) {
|
|
const worker2 = this.workers[i];
|
|
if (worker2.age < oldestWorker.age) {
|
|
oldestIndex = i;
|
|
oldestWorker = worker2;
|
|
}
|
|
}
|
|
if (oldestWorker.running && oldestWorker.pendingSlices.length > 0) {
|
|
break;
|
|
}
|
|
oldestWorker.aborted = true;
|
|
this.workers.splice(oldestIndex, 1);
|
|
}
|
|
return worker;
|
|
}
|
|
runWorker(worker) {
|
|
assert(!worker.running);
|
|
assert(worker.currentPos < worker.targetPos);
|
|
worker.running = true;
|
|
worker.age = this.nextAge++;
|
|
void this.options.runWorker(worker).catch((error) => {
|
|
worker.running = false;
|
|
if (worker.pendingSlices.length > 0) {
|
|
worker.pendingSlices.forEach((x) => x.reject(error));
|
|
worker.pendingSlices.length = 0;
|
|
} else {
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
/** Called by a worker when it has read some data. */
|
|
supplyWorkerData(worker, bytes2) {
|
|
assert(!worker.aborted);
|
|
const start = worker.currentPos;
|
|
const end = start + bytes2.length;
|
|
this.insertIntoCache({
|
|
start,
|
|
end,
|
|
bytes: bytes2,
|
|
view: toDataView(bytes2),
|
|
age: this.nextAge++
|
|
});
|
|
worker.currentPos += bytes2.length;
|
|
worker.targetPos = Math.max(worker.targetPos, worker.currentPos);
|
|
for (let i = 0; i < worker.pendingSlices.length; i++) {
|
|
const pendingSlice = worker.pendingSlices[i];
|
|
const clampedStart = Math.max(start, pendingSlice.start);
|
|
const clampedEnd = Math.min(end, pendingSlice.start + pendingSlice.bytes.length);
|
|
if (clampedStart < clampedEnd) {
|
|
pendingSlice.bytes.set(
|
|
bytes2.subarray(clampedStart - start, clampedEnd - start),
|
|
clampedStart - pendingSlice.start
|
|
);
|
|
}
|
|
for (let j = 0; j < pendingSlice.holes.length; j++) {
|
|
const hole = pendingSlice.holes[j];
|
|
if (start <= hole.start && end > hole.start) {
|
|
hole.start = end;
|
|
}
|
|
if (hole.end <= hole.start) {
|
|
pendingSlice.holes.splice(j, 1);
|
|
j--;
|
|
}
|
|
}
|
|
if (pendingSlice.holes.length === 0) {
|
|
pendingSlice.resolve(pendingSlice.bytes);
|
|
worker.pendingSlices.splice(i, 1);
|
|
i--;
|
|
}
|
|
}
|
|
for (let i = 0; i < this.workers.length; i++) {
|
|
const otherWorker = this.workers[i];
|
|
if (worker === otherWorker || otherWorker.running) {
|
|
continue;
|
|
}
|
|
if (closedIntervalsOverlap(
|
|
start,
|
|
end,
|
|
otherWorker.currentPos,
|
|
otherWorker.targetPos
|
|
// These should typically be equal when the worker's idle
|
|
)) {
|
|
this.workers.splice(i, 1);
|
|
i--;
|
|
}
|
|
}
|
|
}
|
|
forgetWorker(worker) {
|
|
const index = this.workers.indexOf(worker);
|
|
assert(index !== -1);
|
|
this.workers.splice(index, 1);
|
|
}
|
|
insertIntoCache(entry) {
|
|
if (this.options.maxCacheSize === 0) {
|
|
return;
|
|
}
|
|
let insertionIndex = binarySearchLessOrEqual(this.cache, entry.start, (x) => x.start) + 1;
|
|
if (insertionIndex > 0) {
|
|
const previous = this.cache[insertionIndex - 1];
|
|
if (previous.end >= entry.end) {
|
|
return;
|
|
}
|
|
if (previous.end > entry.start) {
|
|
const joined = new Uint8Array(entry.end - previous.start);
|
|
joined.set(previous.bytes, 0);
|
|
joined.set(entry.bytes, entry.start - previous.start);
|
|
this.currentCacheSize += entry.end - previous.end;
|
|
previous.bytes = joined;
|
|
previous.view = toDataView(joined);
|
|
previous.end = entry.end;
|
|
insertionIndex--;
|
|
entry = previous;
|
|
} else {
|
|
this.cache.splice(insertionIndex, 0, entry);
|
|
this.currentCacheSize += entry.bytes.length;
|
|
}
|
|
} else {
|
|
this.cache.splice(insertionIndex, 0, entry);
|
|
this.currentCacheSize += entry.bytes.length;
|
|
}
|
|
for (let i = insertionIndex + 1; i < this.cache.length; i++) {
|
|
const next = this.cache[i];
|
|
if (entry.end <= next.start) {
|
|
break;
|
|
}
|
|
if (entry.end >= next.end) {
|
|
this.cache.splice(i, 1);
|
|
this.currentCacheSize -= next.bytes.length;
|
|
i--;
|
|
continue;
|
|
}
|
|
const joined = new Uint8Array(next.end - entry.start);
|
|
joined.set(entry.bytes, 0);
|
|
joined.set(next.bytes, next.start - entry.start);
|
|
this.currentCacheSize -= entry.end - next.start;
|
|
entry.bytes = joined;
|
|
entry.view = toDataView(joined);
|
|
entry.end = next.end;
|
|
this.cache.splice(i, 1);
|
|
break;
|
|
}
|
|
while (this.currentCacheSize > this.options.maxCacheSize) {
|
|
let oldestIndex = 0;
|
|
let oldestEntry = this.cache[0];
|
|
for (let i = 1; i < this.cache.length; i++) {
|
|
const entry2 = this.cache[i];
|
|
if (entry2.age < oldestEntry.age) {
|
|
oldestIndex = i;
|
|
oldestEntry = entry2;
|
|
}
|
|
}
|
|
if (this.currentCacheSize - oldestEntry.bytes.length <= this.options.maxCacheSize) {
|
|
break;
|
|
}
|
|
this.cache.splice(oldestIndex, 1);
|
|
this.currentCacheSize -= oldestEntry.bytes.length;
|
|
}
|
|
}
|
|
dispose() {
|
|
for (const worker of this.workers) {
|
|
worker.aborted = true;
|
|
}
|
|
this.workers.length = 0;
|
|
this.cache.length = 0;
|
|
this.disposed = true;
|
|
}
|
|
};
|
|
|
|
// src/input.ts
|
|
polyfillSymbolDispose();
|
|
var Input = class {
|
|
/**
|
|
* Creates a new input file from the specified options. No reading operations will be performed until methods are
|
|
* called on this instance.
|
|
*/
|
|
constructor(options) {
|
|
/** @internal */
|
|
this._demuxerPromise = null;
|
|
/** @internal */
|
|
this._format = null;
|
|
/** @internal */
|
|
this._disposed = false;
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (!Array.isArray(options.formats) || options.formats.some((x) => !(x instanceof InputFormat))) {
|
|
throw new TypeError("options.formats must be an array of InputFormat.");
|
|
}
|
|
if (!(options.source instanceof Source)) {
|
|
throw new TypeError("options.source must be a Source.");
|
|
}
|
|
if (options.source._disposed) {
|
|
throw new Error("options.source must not be disposed.");
|
|
}
|
|
this._formats = options.formats;
|
|
this._source = options.source;
|
|
this._reader = new Reader11(options.source);
|
|
}
|
|
/** True if the input has been disposed. */
|
|
get disposed() {
|
|
return this._disposed;
|
|
}
|
|
/** @internal */
|
|
_getDemuxer() {
|
|
return this._demuxerPromise ??= (async () => {
|
|
this._reader.fileSize = await this._source.getSizeOrNull();
|
|
for (const format of this._formats) {
|
|
const canRead = await format._canReadInput(this);
|
|
if (canRead) {
|
|
this._format = format;
|
|
return format._createDemuxer(this);
|
|
}
|
|
}
|
|
throw new Error("Input has an unsupported or unrecognizable format.");
|
|
})();
|
|
}
|
|
/**
|
|
* Returns the source from which this input file reads its data. This is the same source that was passed to the
|
|
* constructor.
|
|
*/
|
|
get source() {
|
|
return this._source;
|
|
}
|
|
/**
|
|
* Returns the format of the input file. You can compare this result directly to the {@link InputFormat} singletons
|
|
* or use `instanceof` checks for subset-aware logic (for example, `format instanceof MatroskaInputFormat` is true
|
|
* for both MKV and WebM).
|
|
*/
|
|
async getFormat() {
|
|
await this._getDemuxer();
|
|
assert(this._format);
|
|
return this._format;
|
|
}
|
|
/**
|
|
* Computes the duration of the input file, in seconds. More precisely, returns the largest end timestamp among
|
|
* all tracks.
|
|
*/
|
|
async computeDuration() {
|
|
const demuxer = await this._getDemuxer();
|
|
return demuxer.computeDuration();
|
|
}
|
|
/**
|
|
* Returns the timestamp at which the input file starts. More precisely, returns the smallest starting timestamp
|
|
* among all tracks.
|
|
*/
|
|
async getFirstTimestamp() {
|
|
const tracks = await this.getTracks();
|
|
if (tracks.length === 0) {
|
|
return 0;
|
|
}
|
|
const firstTimestamps = await Promise.all(tracks.map((x) => x.getFirstTimestamp()));
|
|
return Math.min(...firstTimestamps);
|
|
}
|
|
/** Returns the list of all tracks of this input file. */
|
|
async getTracks() {
|
|
const demuxer = await this._getDemuxer();
|
|
return demuxer.getTracks();
|
|
}
|
|
/** Returns the list of all video tracks of this input file. */
|
|
async getVideoTracks() {
|
|
const tracks = await this.getTracks();
|
|
return tracks.filter((x) => x.isVideoTrack());
|
|
}
|
|
/** Returns the list of all audio tracks of this input file. */
|
|
async getAudioTracks() {
|
|
const tracks = await this.getTracks();
|
|
return tracks.filter((x) => x.isAudioTrack());
|
|
}
|
|
/** Returns the primary video track of this input file, or null if there are no video tracks. */
|
|
async getPrimaryVideoTrack() {
|
|
const tracks = await this.getTracks();
|
|
return tracks.find((x) => x.isVideoTrack()) ?? null;
|
|
}
|
|
/** Returns the primary audio track of this input file, or null if there are no audio tracks. */
|
|
async getPrimaryAudioTrack() {
|
|
const tracks = await this.getTracks();
|
|
return tracks.find((x) => x.isAudioTrack()) ?? null;
|
|
}
|
|
/** Returns the full MIME type of this input file, including track codecs. */
|
|
async getMimeType() {
|
|
const demuxer = await this._getDemuxer();
|
|
return demuxer.getMimeType();
|
|
}
|
|
/**
|
|
* Returns descriptive metadata tags about the media file, such as title, author, date, cover art, or other
|
|
* attached files.
|
|
*/
|
|
async getMetadataTags() {
|
|
const demuxer = await this._getDemuxer();
|
|
return demuxer.getMetadataTags();
|
|
}
|
|
/**
|
|
* Disposes this input and frees connected resources. When an input is disposed, ongoing read operations will be
|
|
* canceled, all future read operations will fail, any open decoders will be closed, and all ongoing media sink
|
|
* operations will be canceled. Disallowed and canceled operations will throw an {@link InputDisposedError}.
|
|
*
|
|
* You are expected not to use an input after disposing it. While some operations may still work, it is not
|
|
* specified and may change in any future update.
|
|
*/
|
|
dispose() {
|
|
if (this._disposed) {
|
|
return;
|
|
}
|
|
this._disposed = true;
|
|
this._source._disposed = true;
|
|
this._source._dispose();
|
|
}
|
|
/**
|
|
* Calls `.dispose()` on the input, implementing the `Disposable` interface for use with
|
|
* JavaScript Explicit Resource Management features.
|
|
*/
|
|
[Symbol.dispose]() {
|
|
this.dispose();
|
|
}
|
|
};
|
|
var InputDisposedError = class extends Error {
|
|
/** Creates a new {@link InputDisposedError}. */
|
|
constructor(message = "Input has been disposed.") {
|
|
super(message);
|
|
this.name = "InputDisposedError";
|
|
}
|
|
};
|
|
|
|
// src/reader.ts
|
|
var Reader11 = class {
|
|
constructor(source) {
|
|
this.source = source;
|
|
}
|
|
requestSlice(start, length) {
|
|
if (this.source._disposed) {
|
|
throw new InputDisposedError();
|
|
}
|
|
if (start < 0) {
|
|
return null;
|
|
}
|
|
if (this.fileSize !== null && start + length > this.fileSize) {
|
|
return null;
|
|
}
|
|
const end = start + length;
|
|
const result = this.source._read(start, end);
|
|
if (result instanceof Promise) {
|
|
return result.then((x) => {
|
|
if (!x) {
|
|
return null;
|
|
}
|
|
return new FileSlice4(x.bytes, x.view, x.offset, start, end);
|
|
});
|
|
} else {
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
return new FileSlice4(result.bytes, result.view, result.offset, start, end);
|
|
}
|
|
}
|
|
requestSliceRange(start, minLength, maxLength) {
|
|
if (this.source._disposed) {
|
|
throw new InputDisposedError();
|
|
}
|
|
if (start < 0) {
|
|
return null;
|
|
}
|
|
if (this.fileSize !== null) {
|
|
return this.requestSlice(
|
|
start,
|
|
clamp(this.fileSize - start, minLength, maxLength)
|
|
);
|
|
} else {
|
|
const promisedAttempt = this.requestSlice(start, maxLength);
|
|
const handleAttempt = (attempt) => {
|
|
if (attempt) {
|
|
return attempt;
|
|
}
|
|
const handleFileSize = (fileSize) => {
|
|
assert(fileSize !== null);
|
|
return this.requestSlice(
|
|
start,
|
|
clamp(fileSize - start, minLength, maxLength)
|
|
);
|
|
};
|
|
const promisedFileSize = this.source._retrieveSize();
|
|
if (promisedFileSize instanceof Promise) {
|
|
return promisedFileSize.then(handleFileSize);
|
|
} else {
|
|
return handleFileSize(promisedFileSize);
|
|
}
|
|
};
|
|
if (promisedAttempt instanceof Promise) {
|
|
return promisedAttempt.then(handleAttempt);
|
|
} else {
|
|
return handleAttempt(promisedAttempt);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
var FileSlice4 = class _FileSlice {
|
|
constructor(bytes2, view2, offset, start, end) {
|
|
this.bytes = bytes2;
|
|
this.view = view2;
|
|
this.offset = offset;
|
|
this.start = start;
|
|
this.end = end;
|
|
this.bufferPos = start - offset;
|
|
}
|
|
static tempFromBytes(bytes2) {
|
|
return new _FileSlice(
|
|
bytes2,
|
|
toDataView(bytes2),
|
|
0,
|
|
0,
|
|
bytes2.length
|
|
);
|
|
}
|
|
get length() {
|
|
return this.end - this.start;
|
|
}
|
|
get filePos() {
|
|
return this.offset + this.bufferPos;
|
|
}
|
|
set filePos(value) {
|
|
this.bufferPos = value - this.offset;
|
|
}
|
|
/** The number of bytes left from the current pos to the end of the slice. */
|
|
get remainingLength() {
|
|
return Math.max(this.end - this.filePos, 0);
|
|
}
|
|
skip(byteCount) {
|
|
this.bufferPos += byteCount;
|
|
}
|
|
/** Creates a new subslice of this slice whose byte range must be contained within this slice. */
|
|
slice(filePos, length = this.end - filePos) {
|
|
if (filePos < this.start || filePos + length > this.end) {
|
|
throw new RangeError("Slicing outside of original slice.");
|
|
}
|
|
return new _FileSlice(
|
|
this.bytes,
|
|
this.view,
|
|
this.offset,
|
|
filePos,
|
|
filePos + length
|
|
);
|
|
}
|
|
};
|
|
var checkIsInRange = (slice, bytesToRead) => {
|
|
if (slice.filePos < slice.start || slice.filePos + bytesToRead > slice.end) {
|
|
throw new RangeError(
|
|
`Tried reading [${slice.filePos}, ${slice.filePos + bytesToRead}), but slice is [${slice.start}, ${slice.end}). This is likely an internal error, please report it alongside the file that caused it.`
|
|
);
|
|
}
|
|
};
|
|
var readBytes = (slice, length) => {
|
|
checkIsInRange(slice, length);
|
|
const bytes2 = slice.bytes.subarray(slice.bufferPos, slice.bufferPos + length);
|
|
slice.bufferPos += length;
|
|
return bytes2;
|
|
};
|
|
var readU8 = (slice) => {
|
|
checkIsInRange(slice, 1);
|
|
return slice.view.getUint8(slice.bufferPos++);
|
|
};
|
|
var readU16 = (slice, littleEndian) => {
|
|
checkIsInRange(slice, 2);
|
|
const value = slice.view.getUint16(slice.bufferPos, littleEndian);
|
|
slice.bufferPos += 2;
|
|
return value;
|
|
};
|
|
var readU16Be = (slice) => {
|
|
checkIsInRange(slice, 2);
|
|
const value = slice.view.getUint16(slice.bufferPos, false);
|
|
slice.bufferPos += 2;
|
|
return value;
|
|
};
|
|
var readU24Be = (slice) => {
|
|
checkIsInRange(slice, 3);
|
|
const value = getUint24(slice.view, slice.bufferPos, false);
|
|
slice.bufferPos += 3;
|
|
return value;
|
|
};
|
|
var readI16Be = (slice) => {
|
|
checkIsInRange(slice, 2);
|
|
const value = slice.view.getInt16(slice.bufferPos, false);
|
|
slice.bufferPos += 2;
|
|
return value;
|
|
};
|
|
var readU32 = (slice, littleEndian) => {
|
|
checkIsInRange(slice, 4);
|
|
const value = slice.view.getUint32(slice.bufferPos, littleEndian);
|
|
slice.bufferPos += 4;
|
|
return value;
|
|
};
|
|
var readU32Be = (slice) => {
|
|
checkIsInRange(slice, 4);
|
|
const value = slice.view.getUint32(slice.bufferPos, false);
|
|
slice.bufferPos += 4;
|
|
return value;
|
|
};
|
|
var readU32Le = (slice) => {
|
|
checkIsInRange(slice, 4);
|
|
const value = slice.view.getUint32(slice.bufferPos, true);
|
|
slice.bufferPos += 4;
|
|
return value;
|
|
};
|
|
var readI32Be = (slice) => {
|
|
checkIsInRange(slice, 4);
|
|
const value = slice.view.getInt32(slice.bufferPos, false);
|
|
slice.bufferPos += 4;
|
|
return value;
|
|
};
|
|
var readI32Le = (slice) => {
|
|
checkIsInRange(slice, 4);
|
|
const value = slice.view.getInt32(slice.bufferPos, true);
|
|
slice.bufferPos += 4;
|
|
return value;
|
|
};
|
|
var readU64 = (slice, littleEndian) => {
|
|
let low;
|
|
let high;
|
|
if (littleEndian) {
|
|
low = readU32(slice, true);
|
|
high = readU32(slice, true);
|
|
} else {
|
|
high = readU32(slice, false);
|
|
low = readU32(slice, false);
|
|
}
|
|
return high * 4294967296 + low;
|
|
};
|
|
var readU64Be = (slice) => {
|
|
const high = readU32Be(slice);
|
|
const low = readU32Be(slice);
|
|
return high * 4294967296 + low;
|
|
};
|
|
var readI64Be = (slice) => {
|
|
const high = readI32Be(slice);
|
|
const low = readU32Be(slice);
|
|
return high * 4294967296 + low;
|
|
};
|
|
var readI64Le = (slice) => {
|
|
const low = readU32Le(slice);
|
|
const high = readI32Le(slice);
|
|
return high * 4294967296 + low;
|
|
};
|
|
var readF32Be = (slice) => {
|
|
checkIsInRange(slice, 4);
|
|
const value = slice.view.getFloat32(slice.bufferPos, false);
|
|
slice.bufferPos += 4;
|
|
return value;
|
|
};
|
|
var readF64Be = (slice) => {
|
|
checkIsInRange(slice, 8);
|
|
const value = slice.view.getFloat64(slice.bufferPos, false);
|
|
slice.bufferPos += 8;
|
|
return value;
|
|
};
|
|
var readAscii = (slice, length) => {
|
|
checkIsInRange(slice, length);
|
|
let str = "";
|
|
for (let i = 0; i < length; i++) {
|
|
str += String.fromCharCode(slice.bytes[slice.bufferPos++]);
|
|
}
|
|
return str;
|
|
};
|
|
|
|
// src/id3.ts
|
|
var ID3_V1_TAG_SIZE = 128;
|
|
var ID3_V2_HEADER_SIZE = 10;
|
|
var ID3_V1_GENRES = [
|
|
"Blues",
|
|
"Classic rock",
|
|
"Country",
|
|
"Dance",
|
|
"Disco",
|
|
"Funk",
|
|
"Grunge",
|
|
"Hip-hop",
|
|
"Jazz",
|
|
"Metal",
|
|
"New age",
|
|
"Oldies",
|
|
"Other",
|
|
"Pop",
|
|
"Rhythm and blues",
|
|
"Rap",
|
|
"Reggae",
|
|
"Rock",
|
|
"Techno",
|
|
"Industrial",
|
|
"Alternative",
|
|
"Ska",
|
|
"Death metal",
|
|
"Pranks",
|
|
"Soundtrack",
|
|
"Euro-techno",
|
|
"Ambient",
|
|
"Trip-hop",
|
|
"Vocal",
|
|
"Jazz & funk",
|
|
"Fusion",
|
|
"Trance",
|
|
"Classical",
|
|
"Instrumental",
|
|
"Acid",
|
|
"House",
|
|
"Game",
|
|
"Sound clip",
|
|
"Gospel",
|
|
"Noise",
|
|
"Alternative rock",
|
|
"Bass",
|
|
"Soul",
|
|
"Punk",
|
|
"Space",
|
|
"Meditative",
|
|
"Instrumental pop",
|
|
"Instrumental rock",
|
|
"Ethnic",
|
|
"Gothic",
|
|
"Darkwave",
|
|
"Techno-industrial",
|
|
"Electronic",
|
|
"Pop-folk",
|
|
"Eurodance",
|
|
"Dream",
|
|
"Southern rock",
|
|
"Comedy",
|
|
"Cult",
|
|
"Gangsta",
|
|
"Top 40",
|
|
"Christian rap",
|
|
"Pop/funk",
|
|
"Jungle music",
|
|
"Native US",
|
|
"Cabaret",
|
|
"New wave",
|
|
"Psychedelic",
|
|
"Rave",
|
|
"Showtunes",
|
|
"Trailer",
|
|
"Lo-fi",
|
|
"Tribal",
|
|
"Acid punk",
|
|
"Acid jazz",
|
|
"Polka",
|
|
"Retro",
|
|
"Musical",
|
|
"Rock 'n' roll",
|
|
"Hard rock",
|
|
"Folk",
|
|
"Folk rock",
|
|
"National folk",
|
|
"Swing",
|
|
"Fast fusion",
|
|
"Bebop",
|
|
"Latin",
|
|
"Revival",
|
|
"Celtic",
|
|
"Bluegrass",
|
|
"Avantgarde",
|
|
"Gothic rock",
|
|
"Progressive rock",
|
|
"Psychedelic rock",
|
|
"Symphonic rock",
|
|
"Slow rock",
|
|
"Big band",
|
|
"Chorus",
|
|
"Easy listening",
|
|
"Acoustic",
|
|
"Humour",
|
|
"Speech",
|
|
"Chanson",
|
|
"Opera",
|
|
"Chamber music",
|
|
"Sonata",
|
|
"Symphony",
|
|
"Booty bass",
|
|
"Primus",
|
|
"Porn groove",
|
|
"Satire",
|
|
"Slow jam",
|
|
"Club",
|
|
"Tango",
|
|
"Samba",
|
|
"Folklore",
|
|
"Ballad",
|
|
"Power ballad",
|
|
"Rhythmic Soul",
|
|
"Freestyle",
|
|
"Duet",
|
|
"Punk rock",
|
|
"Drum solo",
|
|
"A cappella",
|
|
"Euro-house",
|
|
"Dance hall",
|
|
"Goa music",
|
|
"Drum & bass",
|
|
"Club-house",
|
|
"Hardcore techno",
|
|
"Terror",
|
|
"Indie",
|
|
"Britpop",
|
|
"Negerpunk",
|
|
"Polsk punk",
|
|
"Beat",
|
|
"Christian gangsta rap",
|
|
"Heavy metal",
|
|
"Black metal",
|
|
"Crossover",
|
|
"Contemporary Christian",
|
|
"Christian rock",
|
|
"Merengue",
|
|
"Salsa",
|
|
"Thrash metal",
|
|
"Anime",
|
|
"Jpop",
|
|
"Synthpop",
|
|
"Christmas",
|
|
"Art rock",
|
|
"Baroque",
|
|
"Bhangra",
|
|
"Big beat",
|
|
"Breakbeat",
|
|
"Chillout",
|
|
"Downtempo",
|
|
"Dub",
|
|
"EBM",
|
|
"Eclectic",
|
|
"Electro",
|
|
"Electroclash",
|
|
"Emo",
|
|
"Experimental",
|
|
"Garage",
|
|
"Global",
|
|
"IDM",
|
|
"Illbient",
|
|
"Industro-Goth",
|
|
"Jam Band",
|
|
"Krautrock",
|
|
"Leftfield",
|
|
"Lounge",
|
|
"Math rock",
|
|
"New romantic",
|
|
"Nu-breakz",
|
|
"Post-punk",
|
|
"Post-rock",
|
|
"Psytrance",
|
|
"Shoegaze",
|
|
"Space rock",
|
|
"Trop rock",
|
|
"World music",
|
|
"Neoclassical",
|
|
"Audiobook",
|
|
"Audio theatre",
|
|
"Neue Deutsche Welle",
|
|
"Podcast",
|
|
"Indie rock",
|
|
"G-Funk",
|
|
"Dubstep",
|
|
"Garage rock",
|
|
"Psybient"
|
|
];
|
|
var parseId3V1Tag = (slice, tags) => {
|
|
const startPos = slice.filePos;
|
|
tags.raw ??= {};
|
|
tags.raw["TAG"] ??= readBytes(slice, ID3_V1_TAG_SIZE - 3);
|
|
slice.filePos = startPos;
|
|
const title = readId3V1String(slice, 30);
|
|
if (title) tags.title ??= title;
|
|
const artist = readId3V1String(slice, 30);
|
|
if (artist) tags.artist ??= artist;
|
|
const album = readId3V1String(slice, 30);
|
|
if (album) tags.album ??= album;
|
|
const yearText = readId3V1String(slice, 4);
|
|
const year = Number.parseInt(yearText, 10);
|
|
if (Number.isInteger(year) && year > 0) {
|
|
tags.date ??= new Date(year, 0, 1);
|
|
}
|
|
const commentBytes = readBytes(slice, 30);
|
|
let comment;
|
|
if (commentBytes[28] === 0 && commentBytes[29] !== 0) {
|
|
const trackNum = commentBytes[29];
|
|
if (trackNum > 0) {
|
|
tags.trackNumber ??= trackNum;
|
|
}
|
|
slice.skip(-30);
|
|
comment = readId3V1String(slice, 28);
|
|
slice.skip(2);
|
|
} else {
|
|
slice.skip(-30);
|
|
comment = readId3V1String(slice, 30);
|
|
}
|
|
if (comment) tags.comment ??= comment;
|
|
const genreIndex = readU8(slice);
|
|
if (genreIndex < ID3_V1_GENRES.length) {
|
|
tags.genre ??= ID3_V1_GENRES[genreIndex];
|
|
}
|
|
};
|
|
var readId3V1String = (slice, length) => {
|
|
const bytes2 = readBytes(slice, length);
|
|
const endIndex = coalesceIndex(bytes2.indexOf(0), bytes2.length);
|
|
const relevantBytes = bytes2.subarray(0, endIndex);
|
|
let str = "";
|
|
for (let i = 0; i < relevantBytes.length; i++) {
|
|
str += String.fromCharCode(relevantBytes[i]);
|
|
}
|
|
return str.trimEnd();
|
|
};
|
|
var readId3V2Header = (slice) => {
|
|
const startPos = slice.filePos;
|
|
const tag = readAscii(slice, 3);
|
|
const majorVersion = readU8(slice);
|
|
const revision = readU8(slice);
|
|
const flags = readU8(slice);
|
|
const sizeRaw = readU32Be(slice);
|
|
if (tag !== "ID3" || majorVersion === 255 || revision === 255 || (sizeRaw & 2155905152) !== 0) {
|
|
slice.filePos = startPos;
|
|
return null;
|
|
}
|
|
const size = decodeSynchsafe(sizeRaw);
|
|
return { majorVersion, revision, flags, size };
|
|
};
|
|
var parseId3V2Tag = (slice, header, tags) => {
|
|
if (![2, 3, 4].includes(header.majorVersion)) {
|
|
console.warn(`Unsupported ID3v2 major version: ${header.majorVersion}`);
|
|
return;
|
|
}
|
|
const bytes2 = readBytes(slice, header.size);
|
|
const reader = new Id3V2Reader(header, bytes2);
|
|
if (header.flags & 16 /* Footer */) {
|
|
reader.removeFooter();
|
|
}
|
|
if (header.flags & 128 /* Unsynchronisation */ && header.majorVersion === 3) {
|
|
reader.ununsynchronizeAll();
|
|
}
|
|
if (header.flags & 64 /* ExtendedHeader */) {
|
|
const extendedHeaderSize = reader.readU32();
|
|
if (header.majorVersion === 3) {
|
|
reader.pos += extendedHeaderSize;
|
|
} else {
|
|
reader.pos += extendedHeaderSize - 4;
|
|
}
|
|
}
|
|
while (reader.pos <= reader.bytes.length - reader.frameHeaderSize()) {
|
|
const frame = reader.readId3V2Frame();
|
|
if (!frame) {
|
|
break;
|
|
}
|
|
const frameStartPos = reader.pos;
|
|
const frameEndPos = reader.pos + frame.size;
|
|
let frameEncrypted = false;
|
|
let frameCompressed = false;
|
|
let frameUnsynchronized = false;
|
|
if (header.majorVersion === 3) {
|
|
frameEncrypted = !!(frame.flags & 1 << 6);
|
|
frameCompressed = !!(frame.flags & 1 << 7);
|
|
} else if (header.majorVersion === 4) {
|
|
frameEncrypted = !!(frame.flags & 1 << 2);
|
|
frameCompressed = !!(frame.flags & 1 << 3);
|
|
frameUnsynchronized = !!(frame.flags & 1 << 1) || !!(header.flags & 128 /* Unsynchronisation */);
|
|
}
|
|
if (frameEncrypted) {
|
|
console.warn(`Skipping encrypted ID3v2 frame ${frame.id}`);
|
|
reader.pos = frameEndPos;
|
|
continue;
|
|
}
|
|
if (frameCompressed) {
|
|
console.warn(`Skipping compressed ID3v2 frame ${frame.id}`);
|
|
reader.pos = frameEndPos;
|
|
continue;
|
|
}
|
|
if (frameUnsynchronized) {
|
|
reader.ununsynchronizeRegion(reader.pos, frameEndPos);
|
|
}
|
|
tags.raw ??= {};
|
|
if (frame.id[0] === "T") {
|
|
tags.raw[frame.id] ??= reader.readId3V2EncodingAndText(frameEndPos);
|
|
} else {
|
|
tags.raw[frame.id] ??= reader.readBytes(frame.size);
|
|
}
|
|
reader.pos = frameStartPos;
|
|
switch (frame.id) {
|
|
case "TIT2":
|
|
case "TT2":
|
|
{
|
|
tags.title ??= reader.readId3V2EncodingAndText(frameEndPos);
|
|
}
|
|
;
|
|
break;
|
|
case "TIT3":
|
|
case "TT3":
|
|
{
|
|
tags.description ??= reader.readId3V2EncodingAndText(frameEndPos);
|
|
}
|
|
;
|
|
break;
|
|
case "TPE1":
|
|
case "TP1":
|
|
{
|
|
tags.artist ??= reader.readId3V2EncodingAndText(frameEndPos);
|
|
}
|
|
;
|
|
break;
|
|
case "TALB":
|
|
case "TAL":
|
|
{
|
|
tags.album ??= reader.readId3V2EncodingAndText(frameEndPos);
|
|
}
|
|
;
|
|
break;
|
|
case "TPE2":
|
|
case "TP2":
|
|
{
|
|
tags.albumArtist ??= reader.readId3V2EncodingAndText(frameEndPos);
|
|
}
|
|
;
|
|
break;
|
|
case "TRCK":
|
|
case "TRK":
|
|
{
|
|
const trackText = reader.readId3V2EncodingAndText(frameEndPos);
|
|
const parts = trackText.split("/");
|
|
const trackNum = Number.parseInt(parts[0], 10);
|
|
const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);
|
|
if (Number.isInteger(trackNum) && trackNum > 0) {
|
|
tags.trackNumber ??= trackNum;
|
|
}
|
|
if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {
|
|
tags.tracksTotal ??= tracksTotal;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "TPOS":
|
|
case "TPA":
|
|
{
|
|
const discText = reader.readId3V2EncodingAndText(frameEndPos);
|
|
const parts = discText.split("/");
|
|
const discNum = Number.parseInt(parts[0], 10);
|
|
const discsTotal = parts[1] && Number.parseInt(parts[1], 10);
|
|
if (Number.isInteger(discNum) && discNum > 0) {
|
|
tags.discNumber ??= discNum;
|
|
}
|
|
if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) {
|
|
tags.discsTotal ??= discsTotal;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "TCON":
|
|
case "TCO":
|
|
{
|
|
const genreText = reader.readId3V2EncodingAndText(frameEndPos);
|
|
let match = /^\((\d+)\)/.exec(genreText);
|
|
if (match) {
|
|
const genreNumber = Number.parseInt(match[1]);
|
|
if (ID3_V1_GENRES[genreNumber] !== void 0) {
|
|
tags.genre ??= ID3_V1_GENRES[genreNumber];
|
|
break;
|
|
}
|
|
}
|
|
match = /^\d+$/.exec(genreText);
|
|
if (match) {
|
|
const genreNumber = Number.parseInt(match[0]);
|
|
if (ID3_V1_GENRES[genreNumber] !== void 0) {
|
|
tags.genre ??= ID3_V1_GENRES[genreNumber];
|
|
break;
|
|
}
|
|
}
|
|
tags.genre ??= genreText;
|
|
}
|
|
;
|
|
break;
|
|
case "TDRC":
|
|
case "TDAT":
|
|
{
|
|
const dateText = reader.readId3V2EncodingAndText(frameEndPos);
|
|
const date = new Date(dateText);
|
|
if (!Number.isNaN(date.getTime())) {
|
|
tags.date ??= date;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "TYER":
|
|
case "TYE":
|
|
{
|
|
const yearText = reader.readId3V2EncodingAndText(frameEndPos);
|
|
const year = Number.parseInt(yearText, 10);
|
|
if (Number.isInteger(year)) {
|
|
tags.date ??= new Date(year, 0, 1);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "USLT":
|
|
case "ULT":
|
|
{
|
|
const encoding = reader.readU8();
|
|
reader.pos += 3;
|
|
reader.readId3V2Text(encoding, frameEndPos);
|
|
tags.lyrics ??= reader.readId3V2Text(encoding, frameEndPos);
|
|
}
|
|
;
|
|
break;
|
|
case "COMM":
|
|
case "COM":
|
|
{
|
|
const encoding = reader.readU8();
|
|
reader.pos += 3;
|
|
reader.readId3V2Text(encoding, frameEndPos);
|
|
tags.comment ??= reader.readId3V2Text(encoding, frameEndPos);
|
|
}
|
|
;
|
|
break;
|
|
case "APIC":
|
|
case "PIC":
|
|
{
|
|
const encoding = reader.readId3V2TextEncoding();
|
|
let mimeType;
|
|
if (header.majorVersion === 2) {
|
|
const imageFormat = reader.readAscii(3);
|
|
mimeType = imageFormat === "PNG" ? "image/png" : imageFormat === "JPG" ? "image/jpeg" : "image/*";
|
|
} else {
|
|
mimeType = reader.readId3V2Text(encoding, frameEndPos);
|
|
}
|
|
const pictureType = reader.readU8();
|
|
const description = reader.readId3V2Text(encoding, frameEndPos).trimEnd();
|
|
const imageDataSize = frameEndPos - reader.pos;
|
|
if (imageDataSize >= 0) {
|
|
const imageData = reader.readBytes(imageDataSize);
|
|
if (!tags.images) tags.images = [];
|
|
tags.images.push({
|
|
data: imageData,
|
|
mimeType,
|
|
kind: pictureType === 3 ? "coverFront" : pictureType === 4 ? "coverBack" : "unknown",
|
|
description
|
|
});
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
{
|
|
reader.pos += frame.size;
|
|
}
|
|
;
|
|
break;
|
|
}
|
|
reader.pos = frameEndPos;
|
|
}
|
|
};
|
|
var Id3V2Reader = class {
|
|
constructor(header, bytes2) {
|
|
this.header = header;
|
|
this.bytes = bytes2;
|
|
this.pos = 0;
|
|
this.view = new DataView(bytes2.buffer, bytes2.byteOffset, bytes2.byteLength);
|
|
}
|
|
frameHeaderSize() {
|
|
return this.header.majorVersion === 2 ? 6 : 10;
|
|
}
|
|
ununsynchronizeAll() {
|
|
const newBytes = [];
|
|
for (let i = 0; i < this.bytes.length; i++) {
|
|
const value1 = this.bytes[i];
|
|
newBytes.push(value1);
|
|
if (value1 === 255 && i !== this.bytes.length - 1) {
|
|
const value2 = this.bytes[i];
|
|
if (value2 === 0) {
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
this.bytes = new Uint8Array(newBytes);
|
|
this.view = new DataView(this.bytes.buffer);
|
|
}
|
|
ununsynchronizeRegion(start, end) {
|
|
const newBytes = [];
|
|
for (let i = start; i < end; i++) {
|
|
const value1 = this.bytes[i];
|
|
newBytes.push(value1);
|
|
if (value1 === 255 && i !== end - 1) {
|
|
const value2 = this.bytes[i + 1];
|
|
if (value2 === 0) {
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
const before = this.bytes.subarray(0, start);
|
|
const after = this.bytes.subarray(end);
|
|
this.bytes = new Uint8Array(before.length + newBytes.length + after.length);
|
|
this.bytes.set(before, 0);
|
|
this.bytes.set(newBytes, before.length);
|
|
this.bytes.set(after, before.length + newBytes.length);
|
|
this.view = new DataView(this.bytes.buffer);
|
|
}
|
|
removeFooter() {
|
|
this.bytes = this.bytes.subarray(0, this.bytes.length - ID3_V2_HEADER_SIZE);
|
|
this.view = new DataView(this.bytes.buffer);
|
|
}
|
|
readBytes(length) {
|
|
const slice = this.bytes.subarray(this.pos, this.pos + length);
|
|
this.pos += length;
|
|
return slice;
|
|
}
|
|
readU8() {
|
|
const value = this.view.getUint8(this.pos);
|
|
this.pos += 1;
|
|
return value;
|
|
}
|
|
readU16() {
|
|
const value = this.view.getUint16(this.pos, false);
|
|
this.pos += 2;
|
|
return value;
|
|
}
|
|
readU24() {
|
|
const high = this.view.getUint16(this.pos, false);
|
|
const low = this.view.getUint8(this.pos + 1);
|
|
this.pos += 3;
|
|
return high * 256 + low;
|
|
}
|
|
readU32() {
|
|
const value = this.view.getUint32(this.pos, false);
|
|
this.pos += 4;
|
|
return value;
|
|
}
|
|
readAscii(length) {
|
|
let str = "";
|
|
for (let i = 0; i < length; i++) {
|
|
str += String.fromCharCode(this.view.getUint8(this.pos + i));
|
|
}
|
|
this.pos += length;
|
|
return str;
|
|
}
|
|
readId3V2Frame() {
|
|
if (this.header.majorVersion === 2) {
|
|
const id = this.readAscii(3);
|
|
if (id === "\0\0\0") {
|
|
return null;
|
|
}
|
|
const size = this.readU24();
|
|
return { id, size, flags: 0 };
|
|
} else {
|
|
const id = this.readAscii(4);
|
|
if (id === "\0\0\0\0") {
|
|
return null;
|
|
}
|
|
const sizeRaw = this.readU32();
|
|
let size = this.header.majorVersion === 4 ? decodeSynchsafe(sizeRaw) : sizeRaw;
|
|
const flags = this.readU16();
|
|
const headerEndPos = this.pos;
|
|
const isSizeValid = (size2) => {
|
|
const nextPos = this.pos + size2;
|
|
if (nextPos > this.bytes.length) {
|
|
return false;
|
|
}
|
|
if (nextPos <= this.bytes.length - this.frameHeaderSize()) {
|
|
this.pos += size2;
|
|
const nextId = this.readAscii(4);
|
|
if (nextId !== "\0\0\0\0" && !/[0-9A-Z]{4}/.test(nextId)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
if (!isSizeValid(size)) {
|
|
const otherSize = this.header.majorVersion === 4 ? sizeRaw : decodeSynchsafe(sizeRaw);
|
|
if (isSizeValid(otherSize)) {
|
|
size = otherSize;
|
|
}
|
|
}
|
|
this.pos = headerEndPos;
|
|
return { id, size, flags };
|
|
}
|
|
}
|
|
readId3V2TextEncoding() {
|
|
const number = this.readU8();
|
|
if (number > 3) {
|
|
throw new Error(`Unsupported text encoding: ${number}`);
|
|
}
|
|
return number;
|
|
}
|
|
readId3V2Text(encoding, until) {
|
|
const startPos = this.pos;
|
|
const data = this.readBytes(until - this.pos);
|
|
switch (encoding) {
|
|
case 0 /* ISO_8859_1 */: {
|
|
let str = "";
|
|
for (let i = 0; i < data.length; i++) {
|
|
const value = data[i];
|
|
if (value === 0) {
|
|
this.pos = startPos + i + 1;
|
|
break;
|
|
}
|
|
str += String.fromCharCode(value);
|
|
}
|
|
return str;
|
|
}
|
|
case 1 /* UTF_16_WITH_BOM */: {
|
|
if (data[0] === 255 && data[1] === 254) {
|
|
const decoder = new TextDecoder("utf-16le");
|
|
const endIndex = coalesceIndex(
|
|
data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0),
|
|
data.length
|
|
);
|
|
this.pos = startPos + Math.min(endIndex + 2, data.length);
|
|
return decoder.decode(data.subarray(2, endIndex));
|
|
} else if (data[0] === 254 && data[1] === 255) {
|
|
const decoder = new TextDecoder("utf-16be");
|
|
const endIndex = coalesceIndex(
|
|
data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0),
|
|
data.length
|
|
);
|
|
this.pos = startPos + Math.min(endIndex + 2, data.length);
|
|
return decoder.decode(data.subarray(2, endIndex));
|
|
} else {
|
|
const endIndex = coalesceIndex(data.findIndex((x) => x === 0), data.length);
|
|
this.pos = startPos + Math.min(endIndex + 1, data.length);
|
|
return textDecoder.decode(data.subarray(0, endIndex));
|
|
}
|
|
}
|
|
case 2 /* UTF_16_BE_NO_BOM */: {
|
|
const decoder = new TextDecoder("utf-16be");
|
|
const endIndex = coalesceIndex(
|
|
data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0),
|
|
data.length
|
|
);
|
|
this.pos = startPos + Math.min(endIndex + 2, data.length);
|
|
return decoder.decode(data.subarray(0, endIndex));
|
|
}
|
|
case 3 /* UTF_8 */: {
|
|
const endIndex = coalesceIndex(data.findIndex((x) => x === 0), data.length);
|
|
this.pos = startPos + Math.min(endIndex + 1, data.length);
|
|
return textDecoder.decode(data.subarray(0, endIndex));
|
|
}
|
|
}
|
|
}
|
|
readId3V2EncodingAndText(until) {
|
|
if (this.pos >= until) {
|
|
return "";
|
|
}
|
|
const encoding = this.readId3V2TextEncoding();
|
|
return this.readId3V2Text(encoding, until);
|
|
}
|
|
};
|
|
var Id3V2Writer = class {
|
|
constructor(writer) {
|
|
this.helper = new Uint8Array(8);
|
|
this.helperView = toDataView(this.helper);
|
|
this.writer = writer;
|
|
}
|
|
writeId3V2Tag(metadata) {
|
|
const tagStartPos = this.writer.getPos();
|
|
this.writeAscii("ID3");
|
|
this.writeU8(4);
|
|
this.writeU8(0);
|
|
this.writeU8(0);
|
|
this.writeSynchsafeU32(0);
|
|
const framesStartPos = this.writer.getPos();
|
|
const writtenTags = /* @__PURE__ */ new Set();
|
|
for (const { key, value } of keyValueIterator(metadata)) {
|
|
switch (key) {
|
|
case "title":
|
|
{
|
|
this.writeId3V2TextFrame("TIT2", value);
|
|
writtenTags.add("TIT2");
|
|
}
|
|
;
|
|
break;
|
|
case "description":
|
|
{
|
|
this.writeId3V2TextFrame("TIT3", value);
|
|
writtenTags.add("TIT3");
|
|
}
|
|
;
|
|
break;
|
|
case "artist":
|
|
{
|
|
this.writeId3V2TextFrame("TPE1", value);
|
|
writtenTags.add("TPE1");
|
|
}
|
|
;
|
|
break;
|
|
case "album":
|
|
{
|
|
this.writeId3V2TextFrame("TALB", value);
|
|
writtenTags.add("TALB");
|
|
}
|
|
;
|
|
break;
|
|
case "albumArtist":
|
|
{
|
|
this.writeId3V2TextFrame("TPE2", value);
|
|
writtenTags.add("TPE2");
|
|
}
|
|
;
|
|
break;
|
|
case "trackNumber":
|
|
{
|
|
const string = metadata.tracksTotal !== void 0 ? `${value}/${metadata.tracksTotal}` : value.toString();
|
|
this.writeId3V2TextFrame("TRCK", string);
|
|
writtenTags.add("TRCK");
|
|
}
|
|
;
|
|
break;
|
|
case "discNumber":
|
|
{
|
|
const string = metadata.discsTotal !== void 0 ? `${value}/${metadata.discsTotal}` : value.toString();
|
|
this.writeId3V2TextFrame("TPOS", string);
|
|
writtenTags.add("TPOS");
|
|
}
|
|
;
|
|
break;
|
|
case "genre":
|
|
{
|
|
this.writeId3V2TextFrame("TCON", value);
|
|
writtenTags.add("TCON");
|
|
}
|
|
;
|
|
break;
|
|
case "date":
|
|
{
|
|
this.writeId3V2TextFrame("TDRC", value.toISOString().slice(0, 10));
|
|
writtenTags.add("TDRC");
|
|
}
|
|
;
|
|
break;
|
|
case "lyrics":
|
|
{
|
|
this.writeId3V2LyricsFrame(value);
|
|
writtenTags.add("USLT");
|
|
}
|
|
;
|
|
break;
|
|
case "comment":
|
|
{
|
|
this.writeId3V2CommentFrame(value);
|
|
writtenTags.add("COMM");
|
|
}
|
|
;
|
|
break;
|
|
case "images":
|
|
{
|
|
const pictureTypeMap = { coverFront: 3, coverBack: 4, unknown: 0 };
|
|
for (const image of value) {
|
|
const pictureType = pictureTypeMap[image.kind] ?? 0;
|
|
const description = image.description ?? "";
|
|
this.writeId3V2ApicFrame(image.mimeType, pictureType, description, image.data);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "tracksTotal":
|
|
case "discsTotal":
|
|
{
|
|
}
|
|
;
|
|
break;
|
|
case "raw":
|
|
{
|
|
}
|
|
;
|
|
break;
|
|
default: {
|
|
assertNever(key);
|
|
}
|
|
}
|
|
}
|
|
if (metadata.raw) {
|
|
for (const key in metadata.raw) {
|
|
const value = metadata.raw[key];
|
|
if (value == null || key.length !== 4 || writtenTags.has(key)) {
|
|
continue;
|
|
}
|
|
let bytes2;
|
|
if (typeof value === "string") {
|
|
const encoded = textEncoder.encode(value);
|
|
bytes2 = new Uint8Array(encoded.byteLength + 2);
|
|
bytes2[0] = 3 /* UTF_8 */;
|
|
bytes2.set(encoded, 1);
|
|
} else if (value instanceof Uint8Array) {
|
|
bytes2 = value;
|
|
} else {
|
|
continue;
|
|
}
|
|
this.writeAscii(key);
|
|
this.writeSynchsafeU32(bytes2.byteLength);
|
|
this.writeU16(0);
|
|
this.writer.write(bytes2);
|
|
}
|
|
}
|
|
const framesEndPos = this.writer.getPos();
|
|
const framesSize = framesEndPos - framesStartPos;
|
|
this.writer.seek(tagStartPos + 6);
|
|
this.writeSynchsafeU32(framesSize);
|
|
this.writer.seek(framesEndPos);
|
|
return framesSize + 10;
|
|
}
|
|
writeU8(value) {
|
|
this.helper[0] = value;
|
|
this.writer.write(this.helper.subarray(0, 1));
|
|
}
|
|
writeU16(value) {
|
|
this.helperView.setUint16(0, value, false);
|
|
this.writer.write(this.helper.subarray(0, 2));
|
|
}
|
|
writeU32(value) {
|
|
this.helperView.setUint32(0, value, false);
|
|
this.writer.write(this.helper.subarray(0, 4));
|
|
}
|
|
writeAscii(text) {
|
|
for (let i = 0; i < text.length; i++) {
|
|
this.helper[i] = text.charCodeAt(i);
|
|
}
|
|
this.writer.write(this.helper.subarray(0, text.length));
|
|
}
|
|
writeSynchsafeU32(value) {
|
|
this.writeU32(encodeSynchsafe(value));
|
|
}
|
|
writeIsoString(text) {
|
|
const bytes2 = new Uint8Array(text.length + 1);
|
|
for (let i = 0; i < text.length; i++) {
|
|
bytes2[i] = text.charCodeAt(i);
|
|
}
|
|
bytes2[text.length] = 0;
|
|
this.writer.write(bytes2);
|
|
}
|
|
writeUtf8String(text) {
|
|
const utf8Data = textEncoder.encode(text);
|
|
this.writer.write(utf8Data);
|
|
this.writeU8(0);
|
|
}
|
|
writeId3V2TextFrame(frameId, text) {
|
|
const useIso88591 = isIso88591Compatible(text);
|
|
const textDataLength = useIso88591 ? text.length : textEncoder.encode(text).byteLength;
|
|
const frameSize = 1 + textDataLength + 1;
|
|
this.writeAscii(frameId);
|
|
this.writeSynchsafeU32(frameSize);
|
|
this.writeU16(0);
|
|
this.writeU8(useIso88591 ? 0 /* ISO_8859_1 */ : 3 /* UTF_8 */);
|
|
if (useIso88591) {
|
|
this.writeIsoString(text);
|
|
} else {
|
|
this.writeUtf8String(text);
|
|
}
|
|
}
|
|
writeId3V2LyricsFrame(lyrics) {
|
|
const useIso88591 = isIso88591Compatible(lyrics);
|
|
const shortDescription = "";
|
|
const frameSize = 1 + 3 + shortDescription.length + 1 + lyrics.length + 1;
|
|
this.writeAscii("USLT");
|
|
this.writeSynchsafeU32(frameSize);
|
|
this.writeU16(0);
|
|
this.writeU8(useIso88591 ? 0 /* ISO_8859_1 */ : 3 /* UTF_8 */);
|
|
this.writeAscii("und");
|
|
if (useIso88591) {
|
|
this.writeIsoString(shortDescription);
|
|
this.writeIsoString(lyrics);
|
|
} else {
|
|
this.writeUtf8String(shortDescription);
|
|
this.writeUtf8String(lyrics);
|
|
}
|
|
}
|
|
writeId3V2CommentFrame(comment) {
|
|
const useIso88591 = isIso88591Compatible(comment);
|
|
const textDataLength = useIso88591 ? comment.length : textEncoder.encode(comment).byteLength;
|
|
const shortDescription = "";
|
|
const frameSize = 1 + 3 + shortDescription.length + 1 + textDataLength + 1;
|
|
this.writeAscii("COMM");
|
|
this.writeSynchsafeU32(frameSize);
|
|
this.writeU16(0);
|
|
this.writeU8(useIso88591 ? 0 /* ISO_8859_1 */ : 3 /* UTF_8 */);
|
|
this.writeU8(117);
|
|
this.writeU8(110);
|
|
this.writeU8(100);
|
|
if (useIso88591) {
|
|
this.writeIsoString(shortDescription);
|
|
this.writeIsoString(comment);
|
|
} else {
|
|
this.writeUtf8String(shortDescription);
|
|
this.writeUtf8String(comment);
|
|
}
|
|
}
|
|
writeId3V2ApicFrame(mimeType, pictureType, description, imageData) {
|
|
const useIso88591 = isIso88591Compatible(mimeType) && isIso88591Compatible(description);
|
|
const descriptionDataLength = useIso88591 ? description.length : textEncoder.encode(description).byteLength;
|
|
const frameSize = 1 + mimeType.length + 1 + 1 + descriptionDataLength + 1 + imageData.byteLength;
|
|
this.writeAscii("APIC");
|
|
this.writeSynchsafeU32(frameSize);
|
|
this.writeU16(0);
|
|
this.writeU8(useIso88591 ? 0 /* ISO_8859_1 */ : 3 /* UTF_8 */);
|
|
if (useIso88591) {
|
|
this.writeIsoString(mimeType);
|
|
} else {
|
|
this.writeUtf8String(mimeType);
|
|
}
|
|
this.writeU8(pictureType);
|
|
if (useIso88591) {
|
|
this.writeIsoString(description);
|
|
} else {
|
|
this.writeUtf8String(description);
|
|
}
|
|
this.writer.write(imageData);
|
|
}
|
|
};
|
|
|
|
// src/muxer.ts
|
|
var Muxer = class {
|
|
constructor(output) {
|
|
this.mutex = new AsyncMutex();
|
|
/**
|
|
* This field is used to synchronize multiple MediaStreamTracks. They use the same time coordinate system across
|
|
* tracks, and to ensure correct audio-video sync, we must use the same offset for all of them. The reason an offset
|
|
* is needed at all is because the timestamps typically don't start at zero.
|
|
*/
|
|
this.firstMediaStreamTimestamp = null;
|
|
this.trackTimestampInfo = /* @__PURE__ */ new WeakMap();
|
|
this.output = output;
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
onTrackClose(track) {
|
|
}
|
|
validateAndNormalizeTimestamp(track, timestampInSeconds, isKeyPacket) {
|
|
timestampInSeconds += track.source._timestampOffset;
|
|
let timestampInfo = this.trackTimestampInfo.get(track);
|
|
if (!timestampInfo) {
|
|
if (!isKeyPacket) {
|
|
throw new Error("First packet must be a key packet.");
|
|
}
|
|
timestampInfo = {
|
|
maxTimestamp: timestampInSeconds,
|
|
maxTimestampBeforeLastKeyPacket: timestampInSeconds
|
|
};
|
|
this.trackTimestampInfo.set(track, timestampInfo);
|
|
}
|
|
if (timestampInSeconds < 0) {
|
|
throw new Error(`Timestamps must be non-negative (got ${timestampInSeconds}s).`);
|
|
}
|
|
if (isKeyPacket) {
|
|
timestampInfo.maxTimestampBeforeLastKeyPacket = timestampInfo.maxTimestamp;
|
|
}
|
|
if (timestampInSeconds < timestampInfo.maxTimestampBeforeLastKeyPacket) {
|
|
throw new Error(
|
|
`Timestamps cannot be smaller than the largest timestamp of the previous GOP (a GOP begins with a key packet and ends right before the next key packet). Got ${timestampInSeconds}s, but largest timestamp is ${timestampInfo.maxTimestampBeforeLastKeyPacket}s.`
|
|
);
|
|
}
|
|
timestampInfo.maxTimestamp = Math.max(timestampInfo.maxTimestamp, timestampInSeconds);
|
|
return timestampInSeconds;
|
|
}
|
|
};
|
|
|
|
// src/adts/adts-misc.ts
|
|
var buildAdtsHeaderTemplate = (config) => {
|
|
const header = new Uint8Array(7);
|
|
const bitstream = new Bitstream(header);
|
|
const { objectType, frequencyIndex, channelConfiguration } = config;
|
|
const profile = objectType - 1;
|
|
bitstream.writeBits(12, 4095);
|
|
bitstream.writeBits(1, 0);
|
|
bitstream.writeBits(2, 0);
|
|
bitstream.writeBits(1, 1);
|
|
bitstream.writeBits(2, profile);
|
|
bitstream.writeBits(4, frequencyIndex);
|
|
bitstream.writeBits(1, 0);
|
|
bitstream.writeBits(3, channelConfiguration);
|
|
bitstream.writeBits(1, 0);
|
|
bitstream.writeBits(1, 0);
|
|
bitstream.writeBits(1, 0);
|
|
bitstream.writeBits(1, 0);
|
|
bitstream.skipBits(13);
|
|
bitstream.writeBits(11, 2047);
|
|
bitstream.writeBits(2, 0);
|
|
return { header, bitstream };
|
|
};
|
|
var writeAdtsFrameLength = (bitstream, frameLength) => {
|
|
bitstream.pos = 30;
|
|
bitstream.writeBits(13, frameLength);
|
|
};
|
|
|
|
// src/adts/adts-muxer.ts
|
|
var AdtsMuxer = class extends Muxer {
|
|
constructor(output, format) {
|
|
super(output);
|
|
this.header = null;
|
|
this.headerBitstream = null;
|
|
this.inputIsAdts = null;
|
|
this.format = format;
|
|
this.writer = output._writer;
|
|
}
|
|
async start() {
|
|
if (!metadataTagsAreEmpty(this.output._metadataTags)) {
|
|
const id3Writer = new Id3V2Writer(this.writer);
|
|
id3Writer.writeId3V2Tag(this.output._metadataTags);
|
|
}
|
|
}
|
|
async getMimeType() {
|
|
return "audio/aac";
|
|
}
|
|
async addEncodedVideoPacket() {
|
|
throw new Error("ADTS does not support video.");
|
|
}
|
|
async addEncodedAudioPacket(track, packet, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === "key");
|
|
if (this.inputIsAdts === null) {
|
|
validateAudioChunkMetadata(meta);
|
|
const description = meta?.decoderConfig?.description;
|
|
this.inputIsAdts = !description;
|
|
if (!this.inputIsAdts) {
|
|
const config = parseAacAudioSpecificConfig(toUint8Array(description));
|
|
const template = buildAdtsHeaderTemplate(config);
|
|
this.header = template.header;
|
|
this.headerBitstream = template.bitstream;
|
|
}
|
|
}
|
|
if (this.inputIsAdts) {
|
|
const startPos = this.writer.getPos();
|
|
this.writer.write(packet.data);
|
|
if (this.format._options.onFrame) {
|
|
this.format._options.onFrame(packet.data, startPos);
|
|
}
|
|
} else {
|
|
assert(this.header);
|
|
const frameLength = packet.data.byteLength + this.header.byteLength;
|
|
writeAdtsFrameLength(this.headerBitstream, frameLength);
|
|
const startPos = this.writer.getPos();
|
|
this.writer.write(this.header);
|
|
this.writer.write(packet.data);
|
|
if (this.format._options.onFrame) {
|
|
const frameBytes = new Uint8Array(frameLength);
|
|
frameBytes.set(this.header, 0);
|
|
frameBytes.set(packet.data, this.header.byteLength);
|
|
this.format._options.onFrame(frameBytes, startPos);
|
|
}
|
|
}
|
|
await this.writer.flush();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async addSubtitleCue() {
|
|
throw new Error("ADTS does not support subtitles.");
|
|
}
|
|
async finalize() {
|
|
}
|
|
};
|
|
|
|
// src/flac/flac-muxer.ts
|
|
var FLAC_HEADER = /* @__PURE__ */ new Uint8Array([102, 76, 97, 67]);
|
|
var STREAMINFO_SIZE = 38;
|
|
var STREAMINFO_BLOCK_SIZE = 34;
|
|
var FlacMuxer = class extends Muxer {
|
|
constructor(output, format) {
|
|
super(output);
|
|
this.metadataWritten = false;
|
|
this.blockSizes = [];
|
|
this.frameSizes = [];
|
|
this.sampleRate = null;
|
|
this.channels = null;
|
|
this.bitsPerSample = null;
|
|
this.writer = output._writer;
|
|
this.format = format;
|
|
}
|
|
async start() {
|
|
this.writer.write(FLAC_HEADER);
|
|
}
|
|
writeHeader({
|
|
bitsPerSample,
|
|
minimumBlockSize,
|
|
maximumBlockSize,
|
|
minimumFrameSize,
|
|
maximumFrameSize,
|
|
sampleRate,
|
|
channels,
|
|
totalSamples
|
|
}) {
|
|
assert(this.writer.getPos() === 4);
|
|
const hasMetadata = !metadataTagsAreEmpty(this.output._metadataTags);
|
|
const headerBitstream = new Bitstream(new Uint8Array(4));
|
|
headerBitstream.writeBits(1, Number(!hasMetadata));
|
|
headerBitstream.writeBits(7, 0 /* STREAMINFO */);
|
|
headerBitstream.writeBits(24, STREAMINFO_BLOCK_SIZE);
|
|
this.writer.write(headerBitstream.bytes);
|
|
const contentBitstream = new Bitstream(new Uint8Array(18));
|
|
contentBitstream.writeBits(16, minimumBlockSize);
|
|
contentBitstream.writeBits(16, maximumBlockSize);
|
|
contentBitstream.writeBits(24, minimumFrameSize);
|
|
contentBitstream.writeBits(24, maximumFrameSize);
|
|
contentBitstream.writeBits(20, sampleRate);
|
|
contentBitstream.writeBits(3, channels - 1);
|
|
contentBitstream.writeBits(5, bitsPerSample - 1);
|
|
if (totalSamples >= 2 ** 32) {
|
|
throw new Error("This muxer only supports writing up to 2 ** 32 samples");
|
|
}
|
|
contentBitstream.writeBits(4, 0);
|
|
contentBitstream.writeBits(32, totalSamples);
|
|
this.writer.write(contentBitstream.bytes);
|
|
this.writer.write(new Uint8Array(16));
|
|
}
|
|
writePictureBlock(picture) {
|
|
const headerSize = 32 + picture.mimeType.length + (picture.description?.length ?? 0) + picture.data.length;
|
|
const header = new Uint8Array(headerSize);
|
|
let offset = 0;
|
|
const dataView = toDataView(header);
|
|
dataView.setUint32(
|
|
offset,
|
|
picture.kind === "coverFront" ? 3 : picture.kind === "coverBack" ? 4 : 0
|
|
);
|
|
offset += 4;
|
|
dataView.setUint32(offset, picture.mimeType.length);
|
|
offset += 4;
|
|
header.set(textEncoder.encode(picture.mimeType), 8);
|
|
offset += picture.mimeType.length;
|
|
dataView.setUint32(offset, picture.description?.length ?? 0);
|
|
offset += 4;
|
|
header.set(textEncoder.encode(picture.description ?? ""), offset);
|
|
offset += picture.description?.length ?? 0;
|
|
offset += 4 + 4 + 4 + 4;
|
|
dataView.setUint32(offset, picture.data.length);
|
|
offset += 4;
|
|
header.set(picture.data, offset);
|
|
offset += picture.data.length;
|
|
assert(offset === headerSize);
|
|
const headerBitstream = new Bitstream(new Uint8Array(4));
|
|
headerBitstream.writeBits(1, 0);
|
|
headerBitstream.writeBits(7, 6 /* PICTURE */);
|
|
headerBitstream.writeBits(24, headerSize);
|
|
this.writer.write(headerBitstream.bytes);
|
|
this.writer.write(header);
|
|
}
|
|
writeVorbisCommentAndPictureBlock() {
|
|
this.writer.seek(STREAMINFO_SIZE + FLAC_HEADER.byteLength);
|
|
if (metadataTagsAreEmpty(this.output._metadataTags)) {
|
|
this.metadataWritten = true;
|
|
return;
|
|
}
|
|
const pictures = this.output._metadataTags.images ?? [];
|
|
for (const picture of pictures) {
|
|
this.writePictureBlock(picture);
|
|
}
|
|
const vorbisComment = createVorbisComments(
|
|
new Uint8Array(0),
|
|
this.output._metadataTags,
|
|
false
|
|
);
|
|
const headerBitstream = new Bitstream(new Uint8Array(4));
|
|
headerBitstream.writeBits(1, 1);
|
|
headerBitstream.writeBits(7, 4 /* VORBIS_COMMENT */);
|
|
headerBitstream.writeBits(24, vorbisComment.length);
|
|
this.writer.write(headerBitstream.bytes);
|
|
this.writer.write(vorbisComment);
|
|
this.metadataWritten = true;
|
|
}
|
|
async getMimeType() {
|
|
return "audio/flac";
|
|
}
|
|
async addEncodedVideoPacket() {
|
|
throw new Error("FLAC does not support video.");
|
|
}
|
|
async addEncodedAudioPacket(track, packet, meta) {
|
|
const release = await this.mutex.acquire();
|
|
validateAudioChunkMetadata(meta);
|
|
assert(meta);
|
|
assert(meta.decoderConfig);
|
|
assert(meta.decoderConfig.description);
|
|
try {
|
|
this.validateAndNormalizeTimestamp(
|
|
track,
|
|
packet.timestamp,
|
|
packet.type === "key"
|
|
);
|
|
if (this.sampleRate === null) {
|
|
this.sampleRate = meta.decoderConfig.sampleRate;
|
|
}
|
|
if (this.channels === null) {
|
|
this.channels = meta.decoderConfig.numberOfChannels;
|
|
}
|
|
if (this.bitsPerSample === null) {
|
|
const descriptionBitstream = new Bitstream(
|
|
toUint8Array(meta.decoderConfig.description)
|
|
);
|
|
descriptionBitstream.skipBits(103 + 64);
|
|
const bitsPerSample = descriptionBitstream.readBits(5) + 1;
|
|
this.bitsPerSample = bitsPerSample;
|
|
}
|
|
if (!this.metadataWritten) {
|
|
this.writeVorbisCommentAndPictureBlock();
|
|
}
|
|
const slice = FileSlice4.tempFromBytes(packet.data);
|
|
readBytes(slice, 2);
|
|
const bytes2 = readBytes(slice, 2);
|
|
const bitstream = new Bitstream(bytes2);
|
|
const blockSizeOrUncommon = getBlockSizeOrUncommon(bitstream.readBits(4));
|
|
if (blockSizeOrUncommon === null) {
|
|
throw new Error("Invalid FLAC frame: Invalid block size.");
|
|
}
|
|
readCodedNumber(slice);
|
|
const blockSize = readBlockSize(slice, blockSizeOrUncommon);
|
|
this.blockSizes.push(blockSize);
|
|
this.frameSizes.push(packet.data.length);
|
|
const startPos = this.writer.getPos();
|
|
this.writer.write(packet.data);
|
|
if (this.format._options.onFrame) {
|
|
this.format._options.onFrame(packet.data, startPos);
|
|
}
|
|
await this.writer.flush();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
addSubtitleCue() {
|
|
throw new Error("FLAC does not support subtitles.");
|
|
}
|
|
async finalize() {
|
|
const release = await this.mutex.acquire();
|
|
let minimumBlockSize = Infinity;
|
|
let maximumBlockSize = 0;
|
|
let minimumFrameSize = Infinity;
|
|
let maximumFrameSize = 0;
|
|
let totalSamples = 0;
|
|
for (let i = 0; i < this.blockSizes.length; i++) {
|
|
minimumFrameSize = Math.min(minimumFrameSize, this.frameSizes[i]);
|
|
maximumFrameSize = Math.max(maximumFrameSize, this.frameSizes[i]);
|
|
maximumBlockSize = Math.max(maximumBlockSize, this.blockSizes[i]);
|
|
totalSamples += this.blockSizes[i];
|
|
const isLastFrame = i === this.blockSizes.length - 1;
|
|
if (isLastFrame) {
|
|
continue;
|
|
}
|
|
minimumBlockSize = Math.min(minimumBlockSize, this.blockSizes[i]);
|
|
}
|
|
assert(this.sampleRate !== null);
|
|
assert(this.channels !== null);
|
|
assert(this.bitsPerSample !== null);
|
|
this.writer.seek(4);
|
|
this.writeHeader({
|
|
minimumBlockSize,
|
|
maximumBlockSize,
|
|
minimumFrameSize,
|
|
maximumFrameSize,
|
|
sampleRate: this.sampleRate,
|
|
channels: this.channels,
|
|
bitsPerSample: this.bitsPerSample,
|
|
totalSamples
|
|
});
|
|
release();
|
|
}
|
|
};
|
|
|
|
// src/subtitles.ts
|
|
var cueBlockHeaderRegex = /(?:(.+?)\n)?((?:\d{2}:)?\d{2}:\d{2}.\d{3})\s+-->\s+((?:\d{2}:)?\d{2}:\d{2}.\d{3})/g;
|
|
var preambleStartRegex = /^WEBVTT(.|\n)*?\n{2}/;
|
|
var inlineTimestampRegex = /<(?:(\d{2}):)?(\d{2}):(\d{2}).(\d{3})>/g;
|
|
var SubtitleParser = class {
|
|
constructor(options) {
|
|
this.preambleText = null;
|
|
this.preambleEmitted = false;
|
|
this.options = options;
|
|
}
|
|
parse(text) {
|
|
text = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
cueBlockHeaderRegex.lastIndex = 0;
|
|
let match;
|
|
if (!this.preambleText) {
|
|
if (!preambleStartRegex.test(text)) {
|
|
throw new Error("WebVTT preamble incorrect.");
|
|
}
|
|
match = cueBlockHeaderRegex.exec(text);
|
|
const preamble = text.slice(0, match?.index ?? text.length).trimEnd();
|
|
if (!preamble) {
|
|
throw new Error("No WebVTT preamble provided.");
|
|
}
|
|
this.preambleText = preamble;
|
|
if (match) {
|
|
text = text.slice(match.index);
|
|
cueBlockHeaderRegex.lastIndex = 0;
|
|
}
|
|
}
|
|
while (match = cueBlockHeaderRegex.exec(text)) {
|
|
const notes = text.slice(0, match.index);
|
|
const cueIdentifier = match[1];
|
|
const matchEnd = match.index + match[0].length;
|
|
const bodyStart = text.indexOf("\n", matchEnd) + 1;
|
|
const cueSettings = text.slice(matchEnd, bodyStart).trim();
|
|
let bodyEnd = text.indexOf("\n\n", matchEnd);
|
|
if (bodyEnd === -1) bodyEnd = text.length;
|
|
const startTime = parseSubtitleTimestamp(match[2]);
|
|
const endTime = parseSubtitleTimestamp(match[3]);
|
|
const duration = endTime - startTime;
|
|
const body = text.slice(bodyStart, bodyEnd).trim();
|
|
text = text.slice(bodyEnd).trimStart();
|
|
cueBlockHeaderRegex.lastIndex = 0;
|
|
const cue = {
|
|
timestamp: startTime / 1e3,
|
|
duration: duration / 1e3,
|
|
text: body,
|
|
identifier: cueIdentifier,
|
|
settings: cueSettings,
|
|
notes
|
|
};
|
|
const meta = {};
|
|
if (!this.preambleEmitted) {
|
|
meta.config = {
|
|
description: this.preambleText
|
|
};
|
|
this.preambleEmitted = true;
|
|
}
|
|
this.options.output(cue, meta);
|
|
}
|
|
}
|
|
};
|
|
var timestampRegex = /(?:(\d{2}):)?(\d{2}):(\d{2}).(\d{3})/;
|
|
var parseSubtitleTimestamp = (string) => {
|
|
const match = timestampRegex.exec(string);
|
|
if (!match) throw new Error("Expected match.");
|
|
return 60 * 60 * 1e3 * Number(match[1] || "0") + 60 * 1e3 * Number(match[2]) + 1e3 * Number(match[3]) + Number(match[4]);
|
|
};
|
|
var formatSubtitleTimestamp = (timestamp) => {
|
|
const hours = Math.floor(timestamp / (60 * 60 * 1e3));
|
|
const minutes = Math.floor(timestamp % (60 * 60 * 1e3) / (60 * 1e3));
|
|
const seconds = Math.floor(timestamp % (60 * 1e3) / 1e3);
|
|
const milliseconds = timestamp % 1e3;
|
|
return hours.toString().padStart(2, "0") + ":" + minutes.toString().padStart(2, "0") + ":" + seconds.toString().padStart(2, "0") + "." + milliseconds.toString().padStart(3, "0");
|
|
};
|
|
|
|
// src/isobmff/isobmff-boxes.ts
|
|
var IsobmffBoxWriter = class {
|
|
constructor(writer) {
|
|
this.writer = writer;
|
|
this.helper = new Uint8Array(8);
|
|
this.helperView = new DataView(this.helper.buffer);
|
|
/**
|
|
* Stores the position from the start of the file to where boxes elements have been written. This is used to
|
|
* rewrite/edit elements that were already added before, and to measure sizes of things.
|
|
*/
|
|
this.offsets = /* @__PURE__ */ new WeakMap();
|
|
}
|
|
writeU32(value) {
|
|
this.helperView.setUint32(0, value, false);
|
|
this.writer.write(this.helper.subarray(0, 4));
|
|
}
|
|
writeU64(value) {
|
|
this.helperView.setUint32(0, Math.floor(value / 2 ** 32), false);
|
|
this.helperView.setUint32(4, value, false);
|
|
this.writer.write(this.helper.subarray(0, 8));
|
|
}
|
|
writeAscii(text) {
|
|
for (let i = 0; i < text.length; i++) {
|
|
this.helperView.setUint8(i % 8, text.charCodeAt(i));
|
|
if (i % 8 === 7) this.writer.write(this.helper);
|
|
}
|
|
if (text.length % 8 !== 0) {
|
|
this.writer.write(this.helper.subarray(0, text.length % 8));
|
|
}
|
|
}
|
|
writeBox(box2) {
|
|
this.offsets.set(box2, this.writer.getPos());
|
|
if (box2.contents && !box2.children) {
|
|
this.writeBoxHeader(box2, box2.size ?? box2.contents.byteLength + 8);
|
|
this.writer.write(box2.contents);
|
|
} else {
|
|
const startPos = this.writer.getPos();
|
|
this.writeBoxHeader(box2, 0);
|
|
if (box2.contents) this.writer.write(box2.contents);
|
|
if (box2.children) {
|
|
for (const child of box2.children) if (child) this.writeBox(child);
|
|
}
|
|
const endPos = this.writer.getPos();
|
|
const size = box2.size ?? endPos - startPos;
|
|
this.writer.seek(startPos);
|
|
this.writeBoxHeader(box2, size);
|
|
this.writer.seek(endPos);
|
|
}
|
|
}
|
|
writeBoxHeader(box2, size) {
|
|
this.writeU32(box2.largeSize ? 1 : size);
|
|
this.writeAscii(box2.type);
|
|
if (box2.largeSize) this.writeU64(size);
|
|
}
|
|
measureBoxHeader(box2) {
|
|
return 8 + (box2.largeSize ? 8 : 0);
|
|
}
|
|
patchBox(box2) {
|
|
const boxOffset = this.offsets.get(box2);
|
|
assert(boxOffset !== void 0);
|
|
const endPos = this.writer.getPos();
|
|
this.writer.seek(boxOffset);
|
|
this.writeBox(box2);
|
|
this.writer.seek(endPos);
|
|
}
|
|
measureBox(box2) {
|
|
if (box2.contents && !box2.children) {
|
|
const headerSize = this.measureBoxHeader(box2);
|
|
return headerSize + box2.contents.byteLength;
|
|
} else {
|
|
let result = this.measureBoxHeader(box2);
|
|
if (box2.contents) result += box2.contents.byteLength;
|
|
if (box2.children) {
|
|
for (const child of box2.children) if (child) result += this.measureBox(child);
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
};
|
|
var bytes = /* @__PURE__ */ new Uint8Array(8);
|
|
var view = /* @__PURE__ */ new DataView(bytes.buffer);
|
|
var u8 = (value) => {
|
|
return [(value % 256 + 256) % 256];
|
|
};
|
|
var u16 = (value) => {
|
|
view.setUint16(0, value, false);
|
|
return [bytes[0], bytes[1]];
|
|
};
|
|
var i16 = (value) => {
|
|
view.setInt16(0, value, false);
|
|
return [bytes[0], bytes[1]];
|
|
};
|
|
var u24 = (value) => {
|
|
view.setUint32(0, value, false);
|
|
return [bytes[1], bytes[2], bytes[3]];
|
|
};
|
|
var u32 = (value) => {
|
|
view.setUint32(0, value, false);
|
|
return [bytes[0], bytes[1], bytes[2], bytes[3]];
|
|
};
|
|
var i32 = (value) => {
|
|
view.setInt32(0, value, false);
|
|
return [bytes[0], bytes[1], bytes[2], bytes[3]];
|
|
};
|
|
var u64 = (value) => {
|
|
view.setUint32(0, Math.floor(value / 2 ** 32), false);
|
|
view.setUint32(4, value, false);
|
|
return [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7]];
|
|
};
|
|
var fixed_8_8 = (value) => {
|
|
view.setInt16(0, 2 ** 8 * value, false);
|
|
return [bytes[0], bytes[1]];
|
|
};
|
|
var fixed_16_16 = (value) => {
|
|
view.setInt32(0, 2 ** 16 * value, false);
|
|
return [bytes[0], bytes[1], bytes[2], bytes[3]];
|
|
};
|
|
var fixed_2_30 = (value) => {
|
|
view.setInt32(0, 2 ** 30 * value, false);
|
|
return [bytes[0], bytes[1], bytes[2], bytes[3]];
|
|
};
|
|
var variableUnsignedInt = (value, byteLength) => {
|
|
const bytes2 = [];
|
|
let remaining = value;
|
|
do {
|
|
let byte = remaining & 127;
|
|
remaining >>= 7;
|
|
if (bytes2.length > 0) {
|
|
byte |= 128;
|
|
}
|
|
bytes2.push(byte);
|
|
if (byteLength !== void 0) {
|
|
byteLength--;
|
|
}
|
|
} while (remaining > 0 || byteLength);
|
|
return bytes2.reverse();
|
|
};
|
|
var ascii = (text, nullTerminated = false) => {
|
|
const bytes2 = Array(text.length).fill(null).map((_, i) => text.charCodeAt(i));
|
|
if (nullTerminated) bytes2.push(0);
|
|
return bytes2;
|
|
};
|
|
var lastPresentedSample = (samples) => {
|
|
let result = null;
|
|
for (const sample of samples) {
|
|
if (!result || sample.timestamp > result.timestamp) {
|
|
result = sample;
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
var rotationMatrix = (rotationInDegrees) => {
|
|
const theta = rotationInDegrees * (Math.PI / 180);
|
|
const cosTheta = Math.round(Math.cos(theta));
|
|
const sinTheta = Math.round(Math.sin(theta));
|
|
return [
|
|
cosTheta,
|
|
sinTheta,
|
|
0,
|
|
-sinTheta,
|
|
cosTheta,
|
|
0,
|
|
0,
|
|
0,
|
|
1
|
|
];
|
|
};
|
|
var IDENTITY_MATRIX = /* @__PURE__ */ rotationMatrix(0);
|
|
var matrixToBytes = (matrix) => {
|
|
return [
|
|
fixed_16_16(matrix[0]),
|
|
fixed_16_16(matrix[1]),
|
|
fixed_2_30(matrix[2]),
|
|
fixed_16_16(matrix[3]),
|
|
fixed_16_16(matrix[4]),
|
|
fixed_2_30(matrix[5]),
|
|
fixed_16_16(matrix[6]),
|
|
fixed_16_16(matrix[7]),
|
|
fixed_2_30(matrix[8])
|
|
];
|
|
};
|
|
var box = (type, contents, children) => ({
|
|
type,
|
|
contents: contents && new Uint8Array(contents.flat(10)),
|
|
children
|
|
});
|
|
var fullBox = (type, version, flags, contents, children) => box(
|
|
type,
|
|
[u8(version), u24(flags), contents ?? []],
|
|
children
|
|
);
|
|
var ftyp = (details) => {
|
|
const minorVersion = 512;
|
|
if (details.isQuickTime) {
|
|
return box("ftyp", [
|
|
ascii("qt "),
|
|
// Major brand
|
|
u32(minorVersion),
|
|
// Minor version
|
|
// Compatible brands
|
|
ascii("qt ")
|
|
]);
|
|
}
|
|
if (details.fragmented) {
|
|
return box("ftyp", [
|
|
ascii("iso5"),
|
|
// Major brand
|
|
u32(minorVersion),
|
|
// Minor version
|
|
// Compatible brands
|
|
ascii("iso5"),
|
|
ascii("iso6"),
|
|
ascii("mp41")
|
|
]);
|
|
}
|
|
return box("ftyp", [
|
|
ascii("isom"),
|
|
// Major brand
|
|
u32(minorVersion),
|
|
// Minor version
|
|
// Compatible brands
|
|
ascii("isom"),
|
|
details.holdsAvc ? ascii("avc1") : [],
|
|
ascii("mp41")
|
|
]);
|
|
};
|
|
var mdat = (reserveLargeSize) => ({ type: "mdat", largeSize: reserveLargeSize });
|
|
var free = (size) => ({ type: "free", size });
|
|
var moov = (muxer) => box("moov", void 0, [
|
|
mvhd(muxer.creationTime, muxer.trackDatas),
|
|
...muxer.trackDatas.map((x) => trak(x, muxer.creationTime)),
|
|
muxer.isFragmented ? mvex(muxer.trackDatas) : null,
|
|
udta(muxer)
|
|
]);
|
|
var mvhd = (creationTime, trackDatas) => {
|
|
const duration = intoTimescale(Math.max(
|
|
0,
|
|
...trackDatas.filter((x) => x.samples.length > 0).map((x) => {
|
|
const lastSample = lastPresentedSample(x.samples);
|
|
return lastSample.timestamp + lastSample.duration;
|
|
})
|
|
), GLOBAL_TIMESCALE);
|
|
const nextTrackId = Math.max(0, ...trackDatas.map((x) => x.track.id)) + 1;
|
|
const needsU64 = !isU32(creationTime) || !isU32(duration);
|
|
const u32OrU64 = needsU64 ? u64 : u32;
|
|
return fullBox("mvhd", +needsU64, 0, [
|
|
u32OrU64(creationTime),
|
|
// Creation time
|
|
u32OrU64(creationTime),
|
|
// Modification time
|
|
u32(GLOBAL_TIMESCALE),
|
|
// Timescale
|
|
u32OrU64(duration),
|
|
// Duration
|
|
fixed_16_16(1),
|
|
// Preferred rate
|
|
fixed_8_8(1),
|
|
// Preferred volume
|
|
Array(10).fill(0),
|
|
// Reserved
|
|
matrixToBytes(IDENTITY_MATRIX),
|
|
// Matrix
|
|
Array(24).fill(0),
|
|
// Pre-defined
|
|
u32(nextTrackId)
|
|
// Next track ID
|
|
]);
|
|
};
|
|
var trak = (trackData, creationTime) => {
|
|
const trackMetadata = getTrackMetadata(trackData);
|
|
return box("trak", void 0, [
|
|
tkhd(trackData, creationTime),
|
|
mdia(trackData, creationTime),
|
|
trackMetadata.name !== void 0 ? box("udta", void 0, [
|
|
box("name", [
|
|
// VLC (and Mediabunny) also recognize ©nam
|
|
...textEncoder.encode(trackMetadata.name)
|
|
])
|
|
]) : null
|
|
]);
|
|
};
|
|
var tkhd = (trackData, creationTime) => {
|
|
const lastSample = lastPresentedSample(trackData.samples);
|
|
const durationInGlobalTimescale = intoTimescale(
|
|
lastSample ? lastSample.timestamp + lastSample.duration : 0,
|
|
GLOBAL_TIMESCALE
|
|
);
|
|
const needsU64 = !isU32(creationTime) || !isU32(durationInGlobalTimescale);
|
|
const u32OrU64 = needsU64 ? u64 : u32;
|
|
let matrix;
|
|
if (trackData.type === "video") {
|
|
const rotation = trackData.track.metadata.rotation;
|
|
matrix = rotationMatrix(rotation ?? 0);
|
|
} else {
|
|
matrix = IDENTITY_MATRIX;
|
|
}
|
|
let flags = 2;
|
|
if (trackData.track.metadata.disposition?.default !== false) {
|
|
flags |= 1;
|
|
}
|
|
return fullBox("tkhd", +needsU64, flags, [
|
|
u32OrU64(creationTime),
|
|
// Creation time
|
|
u32OrU64(creationTime),
|
|
// Modification time
|
|
u32(trackData.track.id),
|
|
// Track ID
|
|
u32(0),
|
|
// Reserved
|
|
u32OrU64(durationInGlobalTimescale),
|
|
// Duration
|
|
Array(8).fill(0),
|
|
// Reserved
|
|
u16(0),
|
|
// Layer
|
|
u16(trackData.track.id),
|
|
// Alternate group
|
|
fixed_8_8(trackData.type === "audio" ? 1 : 0),
|
|
// Volume
|
|
u16(0),
|
|
// Reserved
|
|
matrixToBytes(matrix),
|
|
// Matrix
|
|
fixed_16_16(trackData.type === "video" ? trackData.info.width : 0),
|
|
// Track width
|
|
fixed_16_16(trackData.type === "video" ? trackData.info.height : 0)
|
|
// Track height
|
|
]);
|
|
};
|
|
var mdia = (trackData, creationTime) => box("mdia", void 0, [
|
|
mdhd(trackData, creationTime),
|
|
hdlr(true, TRACK_TYPE_TO_COMPONENT_SUBTYPE[trackData.type], TRACK_TYPE_TO_HANDLER_NAME[trackData.type]),
|
|
minf(trackData)
|
|
]);
|
|
var mdhd = (trackData, creationTime) => {
|
|
const lastSample = lastPresentedSample(trackData.samples);
|
|
const localDuration = intoTimescale(
|
|
lastSample ? lastSample.timestamp + lastSample.duration : 0,
|
|
trackData.timescale
|
|
);
|
|
const needsU64 = !isU32(creationTime) || !isU32(localDuration);
|
|
const u32OrU64 = needsU64 ? u64 : u32;
|
|
return fullBox("mdhd", +needsU64, 0, [
|
|
u32OrU64(creationTime),
|
|
// Creation time
|
|
u32OrU64(creationTime),
|
|
// Modification time
|
|
u32(trackData.timescale),
|
|
// Timescale
|
|
u32OrU64(localDuration),
|
|
// Duration
|
|
u16(getLanguageCodeInt(trackData.track.metadata.languageCode ?? UNDETERMINED_LANGUAGE)),
|
|
// Language
|
|
u16(0)
|
|
// Quality
|
|
]);
|
|
};
|
|
var TRACK_TYPE_TO_COMPONENT_SUBTYPE = {
|
|
video: "vide",
|
|
audio: "soun",
|
|
subtitle: "text"
|
|
};
|
|
var TRACK_TYPE_TO_HANDLER_NAME = {
|
|
video: "MediabunnyVideoHandler",
|
|
audio: "MediabunnySoundHandler",
|
|
subtitle: "MediabunnyTextHandler"
|
|
};
|
|
var hdlr = (hasComponentType, handlerType, name, manufacturer = "\0\0\0\0") => fullBox("hdlr", 0, 0, [
|
|
hasComponentType ? ascii("mhlr") : u32(0),
|
|
// Component type
|
|
ascii(handlerType),
|
|
// Component subtype
|
|
ascii(manufacturer),
|
|
// Component manufacturer
|
|
u32(0),
|
|
// Component flags
|
|
u32(0),
|
|
// Component flags mask
|
|
ascii(name, true)
|
|
// Component name
|
|
]);
|
|
var minf = (trackData) => box("minf", void 0, [
|
|
TRACK_TYPE_TO_HEADER_BOX[trackData.type](),
|
|
dinf(),
|
|
stbl(trackData)
|
|
]);
|
|
var vmhd = () => fullBox("vmhd", 0, 1, [
|
|
u16(0),
|
|
// Graphics mode
|
|
u16(0),
|
|
// Opcolor R
|
|
u16(0),
|
|
// Opcolor G
|
|
u16(0)
|
|
// Opcolor B
|
|
]);
|
|
var smhd = () => fullBox("smhd", 0, 0, [
|
|
u16(0),
|
|
// Balance
|
|
u16(0)
|
|
// Reserved
|
|
]);
|
|
var nmhd = () => fullBox("nmhd", 0, 0);
|
|
var TRACK_TYPE_TO_HEADER_BOX = {
|
|
video: vmhd,
|
|
audio: smhd,
|
|
subtitle: nmhd
|
|
};
|
|
var dinf = () => box("dinf", void 0, [
|
|
dref()
|
|
]);
|
|
var dref = () => fullBox("dref", 0, 0, [
|
|
u32(1)
|
|
// Entry count
|
|
], [
|
|
url()
|
|
]);
|
|
var url = () => fullBox("url ", 0, 1);
|
|
var stbl = (trackData) => {
|
|
const needsCtts = trackData.compositionTimeOffsetTable.length > 1 || trackData.compositionTimeOffsetTable.some((x) => x.sampleCompositionTimeOffset !== 0);
|
|
return box("stbl", void 0, [
|
|
stsd(trackData),
|
|
stts(trackData),
|
|
needsCtts ? ctts(trackData) : null,
|
|
needsCtts ? cslg(trackData) : null,
|
|
stsc(trackData),
|
|
stsz(trackData),
|
|
stco(trackData),
|
|
stss(trackData)
|
|
]);
|
|
};
|
|
var stsd = (trackData) => {
|
|
let sampleDescription;
|
|
if (trackData.type === "video") {
|
|
sampleDescription = videoSampleDescription(
|
|
videoCodecToBoxName(trackData.track.source._codec, trackData.info.decoderConfig.codec),
|
|
trackData
|
|
);
|
|
} else if (trackData.type === "audio") {
|
|
const boxName = audioCodecToBoxName(trackData.track.source._codec, trackData.muxer.isQuickTime);
|
|
assert(boxName);
|
|
sampleDescription = soundSampleDescription(
|
|
boxName,
|
|
trackData
|
|
);
|
|
} else if (trackData.type === "subtitle") {
|
|
sampleDescription = subtitleSampleDescription(
|
|
SUBTITLE_CODEC_TO_BOX_NAME[trackData.track.source._codec],
|
|
trackData
|
|
);
|
|
}
|
|
assert(sampleDescription);
|
|
return fullBox("stsd", 0, 0, [
|
|
u32(1)
|
|
// Entry count
|
|
], [
|
|
sampleDescription
|
|
]);
|
|
};
|
|
var videoSampleDescription = (compressionType, trackData) => box(compressionType, [
|
|
Array(6).fill(0),
|
|
// Reserved
|
|
u16(1),
|
|
// Data reference index
|
|
u16(0),
|
|
// Pre-defined
|
|
u16(0),
|
|
// Reserved
|
|
Array(12).fill(0),
|
|
// Pre-defined
|
|
u16(trackData.info.width),
|
|
// Width
|
|
u16(trackData.info.height),
|
|
// Height
|
|
u32(4718592),
|
|
// Horizontal resolution
|
|
u32(4718592),
|
|
// Vertical resolution
|
|
u32(0),
|
|
// Reserved
|
|
u16(1),
|
|
// Frame count
|
|
Array(32).fill(0),
|
|
// Compressor name
|
|
u16(24),
|
|
// Depth
|
|
i16(65535)
|
|
// Pre-defined
|
|
], [
|
|
VIDEO_CODEC_TO_CONFIGURATION_BOX[trackData.track.source._codec](trackData),
|
|
colorSpaceIsComplete(trackData.info.decoderConfig.colorSpace) ? colr(trackData) : null
|
|
]);
|
|
var colr = (trackData) => box("colr", [
|
|
ascii("nclx"),
|
|
// Colour type
|
|
u16(COLOR_PRIMARIES_MAP[trackData.info.decoderConfig.colorSpace.primaries]),
|
|
// Colour primaries
|
|
u16(TRANSFER_CHARACTERISTICS_MAP[trackData.info.decoderConfig.colorSpace.transfer]),
|
|
// Transfer characteristics
|
|
u16(MATRIX_COEFFICIENTS_MAP[trackData.info.decoderConfig.colorSpace.matrix]),
|
|
// Matrix coefficients
|
|
u8((trackData.info.decoderConfig.colorSpace.fullRange ? 1 : 0) << 7)
|
|
// Full range flag
|
|
]);
|
|
var avcC = (trackData) => trackData.info.decoderConfig && box("avcC", [
|
|
// For AVC, description is an AVCDecoderConfigurationRecord, so nothing else to do here
|
|
...toUint8Array(trackData.info.decoderConfig.description)
|
|
]);
|
|
var hvcC = (trackData) => trackData.info.decoderConfig && box("hvcC", [
|
|
// For HEVC, description is an HEVCDecoderConfigurationRecord, so nothing else to do here
|
|
...toUint8Array(trackData.info.decoderConfig.description)
|
|
]);
|
|
var vpcC = (trackData) => {
|
|
if (!trackData.info.decoderConfig) {
|
|
return null;
|
|
}
|
|
const decoderConfig = trackData.info.decoderConfig;
|
|
const parts = decoderConfig.codec.split(".");
|
|
const profile = Number(parts[1]);
|
|
const level = Number(parts[2]);
|
|
const bitDepth = Number(parts[3]);
|
|
const chromaSubsampling = parts[4] ? Number(parts[4]) : 1;
|
|
const videoFullRangeFlag = parts[8] ? Number(parts[8]) : Number(decoderConfig.colorSpace?.fullRange ?? 0);
|
|
const thirdByte = (bitDepth << 4) + (chromaSubsampling << 1) + videoFullRangeFlag;
|
|
const colourPrimaries = parts[5] ? Number(parts[5]) : decoderConfig.colorSpace?.primaries ? COLOR_PRIMARIES_MAP[decoderConfig.colorSpace.primaries] : 2;
|
|
const transferCharacteristics = parts[6] ? Number(parts[6]) : decoderConfig.colorSpace?.transfer ? TRANSFER_CHARACTERISTICS_MAP[decoderConfig.colorSpace.transfer] : 2;
|
|
const matrixCoefficients = parts[7] ? Number(parts[7]) : decoderConfig.colorSpace?.matrix ? MATRIX_COEFFICIENTS_MAP[decoderConfig.colorSpace.matrix] : 2;
|
|
return fullBox("vpcC", 1, 0, [
|
|
u8(profile),
|
|
// Profile
|
|
u8(level),
|
|
// Level
|
|
u8(thirdByte),
|
|
// Bit depth, chroma subsampling, full range
|
|
u8(colourPrimaries),
|
|
// Colour primaries
|
|
u8(transferCharacteristics),
|
|
// Transfer characteristics
|
|
u8(matrixCoefficients),
|
|
// Matrix coefficients
|
|
u16(0)
|
|
// Codec initialization data size
|
|
]);
|
|
};
|
|
var av1C = (trackData) => {
|
|
return box("av1C", generateAv1CodecConfigurationFromCodecString(trackData.info.decoderConfig.codec));
|
|
};
|
|
var soundSampleDescription = (compressionType, trackData) => {
|
|
let version = 0;
|
|
let contents;
|
|
let sampleSizeInBits = 16;
|
|
if (PCM_AUDIO_CODECS.includes(trackData.track.source._codec)) {
|
|
const codec = trackData.track.source._codec;
|
|
const { sampleSize } = parsePcmCodec(codec);
|
|
sampleSizeInBits = 8 * sampleSize;
|
|
if (sampleSizeInBits > 16) {
|
|
version = 1;
|
|
}
|
|
}
|
|
if (version === 0) {
|
|
contents = [
|
|
Array(6).fill(0),
|
|
// Reserved
|
|
u16(1),
|
|
// Data reference index
|
|
u16(version),
|
|
// Version
|
|
u16(0),
|
|
// Revision level
|
|
u32(0),
|
|
// Vendor
|
|
u16(trackData.info.numberOfChannels),
|
|
// Number of channels
|
|
u16(sampleSizeInBits),
|
|
// Sample size (bits)
|
|
u16(0),
|
|
// Compression ID
|
|
u16(0),
|
|
// Packet size
|
|
u16(trackData.info.sampleRate < 2 ** 16 ? trackData.info.sampleRate : 0),
|
|
// Sample rate (upper)
|
|
u16(0)
|
|
// Sample rate (lower)
|
|
];
|
|
} else {
|
|
contents = [
|
|
Array(6).fill(0),
|
|
// Reserved
|
|
u16(1),
|
|
// Data reference index
|
|
u16(version),
|
|
// Version
|
|
u16(0),
|
|
// Revision level
|
|
u32(0),
|
|
// Vendor
|
|
u16(trackData.info.numberOfChannels),
|
|
// Number of channels
|
|
u16(Math.min(sampleSizeInBits, 16)),
|
|
// Sample size (bits)
|
|
u16(0),
|
|
// Compression ID
|
|
u16(0),
|
|
// Packet size
|
|
u16(trackData.info.sampleRate < 2 ** 16 ? trackData.info.sampleRate : 0),
|
|
// Sample rate (upper)
|
|
u16(0),
|
|
// Sample rate (lower)
|
|
u32(1),
|
|
// Samples per packet (must be 1 for uncompressed formats)
|
|
u32(sampleSizeInBits / 8),
|
|
// Bytes per packet
|
|
u32(trackData.info.numberOfChannels * sampleSizeInBits / 8),
|
|
// Bytes per frame
|
|
u32(2)
|
|
// Bytes per sample (constant in FFmpeg)
|
|
];
|
|
}
|
|
return box(compressionType, contents, [
|
|
audioCodecToConfigurationBox(trackData.track.source._codec, trackData.muxer.isQuickTime)?.(trackData) ?? null
|
|
]);
|
|
};
|
|
var esds = (trackData) => {
|
|
let objectTypeIndication;
|
|
switch (trackData.track.source._codec) {
|
|
case "aac":
|
|
{
|
|
objectTypeIndication = 64;
|
|
}
|
|
;
|
|
break;
|
|
case "mp3":
|
|
{
|
|
objectTypeIndication = 107;
|
|
}
|
|
;
|
|
break;
|
|
case "vorbis":
|
|
{
|
|
objectTypeIndication = 221;
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
throw new Error(`Unhandled audio codec: ${trackData.track.source._codec}`);
|
|
}
|
|
let bytes2 = [
|
|
...u8(objectTypeIndication),
|
|
// Object type indication
|
|
...u8(21),
|
|
// stream type(6bits)=5 audio, flags(2bits)=1
|
|
...u24(0),
|
|
// 24bit buffer size
|
|
...u32(0),
|
|
// max bitrate
|
|
...u32(0)
|
|
// avg bitrate
|
|
];
|
|
if (trackData.info.decoderConfig.description) {
|
|
const description = toUint8Array(trackData.info.decoderConfig.description);
|
|
bytes2 = [
|
|
...bytes2,
|
|
...u8(5),
|
|
// TAG(5) = DecoderSpecificInfo
|
|
...variableUnsignedInt(description.byteLength),
|
|
...description
|
|
];
|
|
}
|
|
bytes2 = [
|
|
...u16(1),
|
|
// ES_ID = 1
|
|
...u8(0),
|
|
// flags etc = 0
|
|
...u8(4),
|
|
// TAG(4) = ES Descriptor
|
|
...variableUnsignedInt(bytes2.length),
|
|
...bytes2,
|
|
...u8(6),
|
|
// TAG(6)
|
|
...u8(1),
|
|
// length
|
|
...u8(2)
|
|
// data
|
|
];
|
|
bytes2 = [
|
|
...u8(3),
|
|
// TAG(3) = Object Descriptor
|
|
...variableUnsignedInt(bytes2.length),
|
|
...bytes2
|
|
];
|
|
return fullBox("esds", 0, 0, bytes2);
|
|
};
|
|
var wave = (trackData) => {
|
|
return box("wave", void 0, [
|
|
frma(trackData),
|
|
enda(trackData),
|
|
box("\0\0\0\0")
|
|
// NULL tag at the end
|
|
]);
|
|
};
|
|
var frma = (trackData) => {
|
|
return box("frma", [
|
|
ascii(audioCodecToBoxName(trackData.track.source._codec, trackData.muxer.isQuickTime))
|
|
]);
|
|
};
|
|
var enda = (trackData) => {
|
|
const { littleEndian } = parsePcmCodec(trackData.track.source._codec);
|
|
return box("enda", [
|
|
u16(+littleEndian)
|
|
]);
|
|
};
|
|
var dOps = (trackData) => {
|
|
let outputChannelCount = trackData.info.numberOfChannels;
|
|
let preSkip = 3840;
|
|
let inputSampleRate = trackData.info.sampleRate;
|
|
let outputGain = 0;
|
|
let channelMappingFamily = 0;
|
|
let channelMappingTable = new Uint8Array(0);
|
|
const description = trackData.info.decoderConfig?.description;
|
|
if (description) {
|
|
assert(description.byteLength >= 18);
|
|
const bytes2 = toUint8Array(description);
|
|
const header = parseOpusIdentificationHeader(bytes2);
|
|
outputChannelCount = header.outputChannelCount;
|
|
preSkip = header.preSkip;
|
|
inputSampleRate = header.inputSampleRate;
|
|
outputGain = header.outputGain;
|
|
channelMappingFamily = header.channelMappingFamily;
|
|
if (header.channelMappingTable) {
|
|
channelMappingTable = header.channelMappingTable;
|
|
}
|
|
}
|
|
return box("dOps", [
|
|
u8(0),
|
|
// Version
|
|
u8(outputChannelCount),
|
|
// OutputChannelCount
|
|
u16(preSkip),
|
|
// PreSkip
|
|
u32(inputSampleRate),
|
|
// InputSampleRate
|
|
i16(outputGain),
|
|
// OutputGain
|
|
u8(channelMappingFamily),
|
|
// ChannelMappingFamily
|
|
...channelMappingTable
|
|
]);
|
|
};
|
|
var dfLa = (trackData) => {
|
|
const description = trackData.info.decoderConfig?.description;
|
|
assert(description);
|
|
const bytes2 = toUint8Array(description);
|
|
return fullBox("dfLa", 0, 0, [
|
|
...bytes2.subarray(4)
|
|
]);
|
|
};
|
|
var pcmC = (trackData) => {
|
|
const { littleEndian, sampleSize } = parsePcmCodec(trackData.track.source._codec);
|
|
const formatFlags = +littleEndian;
|
|
return fullBox("pcmC", 0, 0, [
|
|
u8(formatFlags),
|
|
u8(8 * sampleSize)
|
|
]);
|
|
};
|
|
var dac3 = (trackData) => {
|
|
const frameInfo = parseAc3SyncFrame(trackData.info.firstPacket.data);
|
|
if (!frameInfo) {
|
|
throw new Error(
|
|
"Couldn't extract AC-3 frame info from the audio packet. Ensure the packets contain valid AC-3 sync frames (as specified in ETSI TS 102 366)."
|
|
);
|
|
}
|
|
const bytes2 = new Uint8Array(3);
|
|
const bitstream = new Bitstream(bytes2);
|
|
bitstream.writeBits(2, frameInfo.fscod);
|
|
bitstream.writeBits(5, frameInfo.bsid);
|
|
bitstream.writeBits(3, frameInfo.bsmod);
|
|
bitstream.writeBits(3, frameInfo.acmod);
|
|
bitstream.writeBits(1, frameInfo.lfeon);
|
|
bitstream.writeBits(5, frameInfo.bitRateCode);
|
|
bitstream.writeBits(5, 0);
|
|
return box("dac3", [...bytes2]);
|
|
};
|
|
var dec3 = (trackData) => {
|
|
const frameInfo = parseEac3SyncFrame(trackData.info.firstPacket.data);
|
|
if (!frameInfo) {
|
|
throw new Error(
|
|
"Couldn't extract E-AC-3 frame info from the audio packet. Ensure the packets contain valid E-AC-3 sync frames (as specified in ETSI TS 102 366)."
|
|
);
|
|
}
|
|
let totalBits = 16;
|
|
for (const sub of frameInfo.substreams) {
|
|
totalBits += 23;
|
|
if (sub.numDepSub > 0) {
|
|
totalBits += 9;
|
|
} else {
|
|
totalBits += 1;
|
|
}
|
|
}
|
|
const size = Math.ceil(totalBits / 8);
|
|
const bytes2 = new Uint8Array(size);
|
|
const bitstream = new Bitstream(bytes2);
|
|
bitstream.writeBits(13, frameInfo.dataRate);
|
|
bitstream.writeBits(3, frameInfo.substreams.length - 1);
|
|
for (const sub of frameInfo.substreams) {
|
|
bitstream.writeBits(2, sub.fscod);
|
|
bitstream.writeBits(5, sub.bsid);
|
|
bitstream.writeBits(1, 0);
|
|
bitstream.writeBits(1, 0);
|
|
bitstream.writeBits(3, sub.bsmod);
|
|
bitstream.writeBits(3, sub.acmod);
|
|
bitstream.writeBits(1, sub.lfeon);
|
|
bitstream.writeBits(3, 0);
|
|
bitstream.writeBits(4, sub.numDepSub);
|
|
if (sub.numDepSub > 0) {
|
|
bitstream.writeBits(9, sub.chanLoc);
|
|
} else {
|
|
bitstream.writeBits(1, 0);
|
|
}
|
|
}
|
|
return box("dec3", [...bytes2]);
|
|
};
|
|
var subtitleSampleDescription = (compressionType, trackData) => box(compressionType, [
|
|
Array(6).fill(0),
|
|
// Reserved
|
|
u16(1)
|
|
// Data reference index
|
|
], [
|
|
SUBTITLE_CODEC_TO_CONFIGURATION_BOX[trackData.track.source._codec](trackData)
|
|
]);
|
|
var vttC = (trackData) => box("vttC", [
|
|
...textEncoder.encode(trackData.info.config.description)
|
|
]);
|
|
var stts = (trackData) => {
|
|
return fullBox("stts", 0, 0, [
|
|
u32(trackData.timeToSampleTable.length),
|
|
// Number of entries
|
|
trackData.timeToSampleTable.map((x) => [
|
|
// Time-to-sample table
|
|
u32(x.sampleCount),
|
|
// Sample count
|
|
u32(x.sampleDelta)
|
|
// Sample duration
|
|
])
|
|
]);
|
|
};
|
|
var stss = (trackData) => {
|
|
if (trackData.samples.every((x) => x.type === "key")) return null;
|
|
const keySamples = [...trackData.samples.entries()].filter(([, sample]) => sample.type === "key");
|
|
return fullBox("stss", 0, 0, [
|
|
u32(keySamples.length),
|
|
// Number of entries
|
|
keySamples.map(([index]) => u32(index + 1))
|
|
// Sync sample table
|
|
]);
|
|
};
|
|
var stsc = (trackData) => {
|
|
return fullBox("stsc", 0, 0, [
|
|
u32(trackData.compactlyCodedChunkTable.length),
|
|
// Number of entries
|
|
trackData.compactlyCodedChunkTable.map((x) => [
|
|
// Sample-to-chunk table
|
|
u32(x.firstChunk),
|
|
// First chunk
|
|
u32(x.samplesPerChunk),
|
|
// Samples per chunk
|
|
u32(1)
|
|
// Sample description index
|
|
])
|
|
]);
|
|
};
|
|
var stsz = (trackData) => {
|
|
if (trackData.type === "audio" && trackData.info.requiresPcmTransformation) {
|
|
const { sampleSize } = parsePcmCodec(trackData.track.source._codec);
|
|
return fullBox("stsz", 0, 0, [
|
|
u32(sampleSize * trackData.info.numberOfChannels),
|
|
// Sample size
|
|
u32(trackData.samples.reduce((acc, x) => acc + intoTimescale(x.duration, trackData.timescale), 0))
|
|
]);
|
|
}
|
|
return fullBox("stsz", 0, 0, [
|
|
u32(0),
|
|
// Sample size (0 means non-constant size)
|
|
u32(trackData.samples.length),
|
|
// Number of entries
|
|
trackData.samples.map((x) => u32(x.size))
|
|
// Sample size table
|
|
]);
|
|
};
|
|
var stco = (trackData) => {
|
|
if (trackData.finalizedChunks.length > 0 && last(trackData.finalizedChunks).offset >= 2 ** 32) {
|
|
return fullBox("co64", 0, 0, [
|
|
u32(trackData.finalizedChunks.length),
|
|
// Number of entries
|
|
trackData.finalizedChunks.map((x) => u64(x.offset))
|
|
// Chunk offset table
|
|
]);
|
|
}
|
|
return fullBox("stco", 0, 0, [
|
|
u32(trackData.finalizedChunks.length),
|
|
// Number of entries
|
|
trackData.finalizedChunks.map((x) => u32(x.offset))
|
|
// Chunk offset table
|
|
]);
|
|
};
|
|
var ctts = (trackData) => {
|
|
return fullBox("ctts", 1, 0, [
|
|
u32(trackData.compositionTimeOffsetTable.length),
|
|
// Number of entries
|
|
trackData.compositionTimeOffsetTable.map((x) => [
|
|
// Time-to-sample table
|
|
u32(x.sampleCount),
|
|
// Sample count
|
|
i32(x.sampleCompositionTimeOffset)
|
|
// Sample offset
|
|
])
|
|
]);
|
|
};
|
|
var cslg = (trackData) => {
|
|
let leastDecodeToDisplayDelta = Infinity;
|
|
let greatestDecodeToDisplayDelta = -Infinity;
|
|
let compositionStartTime = Infinity;
|
|
let compositionEndTime = -Infinity;
|
|
assert(trackData.compositionTimeOffsetTable.length > 0);
|
|
assert(trackData.samples.length > 0);
|
|
for (let i = 0; i < trackData.compositionTimeOffsetTable.length; i++) {
|
|
const entry = trackData.compositionTimeOffsetTable[i];
|
|
leastDecodeToDisplayDelta = Math.min(leastDecodeToDisplayDelta, entry.sampleCompositionTimeOffset);
|
|
greatestDecodeToDisplayDelta = Math.max(greatestDecodeToDisplayDelta, entry.sampleCompositionTimeOffset);
|
|
}
|
|
for (let i = 0; i < trackData.samples.length; i++) {
|
|
const sample = trackData.samples[i];
|
|
compositionStartTime = Math.min(
|
|
compositionStartTime,
|
|
intoTimescale(sample.timestamp, trackData.timescale)
|
|
);
|
|
compositionEndTime = Math.max(
|
|
compositionEndTime,
|
|
intoTimescale(sample.timestamp + sample.duration, trackData.timescale)
|
|
);
|
|
}
|
|
const compositionToDtsShift = Math.max(-leastDecodeToDisplayDelta, 0);
|
|
if (compositionEndTime >= 2 ** 31) {
|
|
return null;
|
|
}
|
|
return fullBox("cslg", 0, 0, [
|
|
i32(compositionToDtsShift),
|
|
// Composition to DTS shift
|
|
i32(leastDecodeToDisplayDelta),
|
|
// Least decode to display delta
|
|
i32(greatestDecodeToDisplayDelta),
|
|
// Greatest decode to display delta
|
|
i32(compositionStartTime),
|
|
// Composition start time
|
|
i32(compositionEndTime)
|
|
// Composition end time
|
|
]);
|
|
};
|
|
var mvex = (trackDatas) => {
|
|
return box("mvex", void 0, trackDatas.map(trex));
|
|
};
|
|
var trex = (trackData) => {
|
|
return fullBox("trex", 0, 0, [
|
|
u32(trackData.track.id),
|
|
// Track ID
|
|
u32(1),
|
|
// Default sample description index
|
|
u32(0),
|
|
// Default sample duration
|
|
u32(0),
|
|
// Default sample size
|
|
u32(0)
|
|
// Default sample flags
|
|
]);
|
|
};
|
|
var moof = (sequenceNumber, trackDatas) => {
|
|
return box("moof", void 0, [
|
|
mfhd(sequenceNumber),
|
|
...trackDatas.map(traf)
|
|
]);
|
|
};
|
|
var mfhd = (sequenceNumber) => {
|
|
return fullBox("mfhd", 0, 0, [
|
|
u32(sequenceNumber)
|
|
// Sequence number
|
|
]);
|
|
};
|
|
var fragmentSampleFlags = (sample) => {
|
|
let byte1 = 0;
|
|
let byte2 = 0;
|
|
const byte3 = 0;
|
|
const byte4 = 0;
|
|
const sampleIsDifferenceSample = sample.type === "delta";
|
|
byte2 |= +sampleIsDifferenceSample;
|
|
if (sampleIsDifferenceSample) {
|
|
byte1 |= 1;
|
|
} else {
|
|
byte1 |= 2;
|
|
}
|
|
return byte1 << 24 | byte2 << 16 | byte3 << 8 | byte4;
|
|
};
|
|
var traf = (trackData) => {
|
|
return box("traf", void 0, [
|
|
tfhd(trackData),
|
|
tfdt(trackData),
|
|
trun(trackData)
|
|
]);
|
|
};
|
|
var tfhd = (trackData) => {
|
|
assert(trackData.currentChunk);
|
|
let tfFlags = 0;
|
|
tfFlags |= 8;
|
|
tfFlags |= 16;
|
|
tfFlags |= 32;
|
|
tfFlags |= 131072;
|
|
const referenceSample = trackData.currentChunk.samples[1] ?? trackData.currentChunk.samples[0];
|
|
const referenceSampleInfo = {
|
|
duration: referenceSample.timescaleUnitsToNextSample,
|
|
size: referenceSample.size,
|
|
flags: fragmentSampleFlags(referenceSample)
|
|
};
|
|
return fullBox("tfhd", 0, tfFlags, [
|
|
u32(trackData.track.id),
|
|
// Track ID
|
|
u32(referenceSampleInfo.duration),
|
|
// Default sample duration
|
|
u32(referenceSampleInfo.size),
|
|
// Default sample size
|
|
u32(referenceSampleInfo.flags)
|
|
// Default sample flags
|
|
]);
|
|
};
|
|
var tfdt = (trackData) => {
|
|
assert(trackData.currentChunk);
|
|
return fullBox("tfdt", 1, 0, [
|
|
u64(intoTimescale(trackData.currentChunk.startTimestamp, trackData.timescale))
|
|
// Base Media Decode Time
|
|
]);
|
|
};
|
|
var trun = (trackData) => {
|
|
assert(trackData.currentChunk);
|
|
const allSampleDurations = trackData.currentChunk.samples.map((x) => x.timescaleUnitsToNextSample);
|
|
const allSampleSizes = trackData.currentChunk.samples.map((x) => x.size);
|
|
const allSampleFlags = trackData.currentChunk.samples.map(fragmentSampleFlags);
|
|
const allSampleCompositionTimeOffsets = trackData.currentChunk.samples.map((x) => intoTimescale(x.timestamp - x.decodeTimestamp, trackData.timescale));
|
|
const uniqueSampleDurations = new Set(allSampleDurations);
|
|
const uniqueSampleSizes = new Set(allSampleSizes);
|
|
const uniqueSampleFlags = new Set(allSampleFlags);
|
|
const uniqueSampleCompositionTimeOffsets = new Set(allSampleCompositionTimeOffsets);
|
|
const firstSampleFlagsPresent = uniqueSampleFlags.size === 2 && allSampleFlags[0] !== allSampleFlags[1];
|
|
const sampleDurationPresent = uniqueSampleDurations.size > 1;
|
|
const sampleSizePresent = uniqueSampleSizes.size > 1;
|
|
const sampleFlagsPresent = !firstSampleFlagsPresent && uniqueSampleFlags.size > 1;
|
|
const sampleCompositionTimeOffsetsPresent = uniqueSampleCompositionTimeOffsets.size > 1 || [...uniqueSampleCompositionTimeOffsets].some((x) => x !== 0);
|
|
let flags = 0;
|
|
flags |= 1;
|
|
flags |= 4 * +firstSampleFlagsPresent;
|
|
flags |= 256 * +sampleDurationPresent;
|
|
flags |= 512 * +sampleSizePresent;
|
|
flags |= 1024 * +sampleFlagsPresent;
|
|
flags |= 2048 * +sampleCompositionTimeOffsetsPresent;
|
|
return fullBox("trun", 1, flags, [
|
|
u32(trackData.currentChunk.samples.length),
|
|
// Sample count
|
|
u32(trackData.currentChunk.offset - trackData.currentChunk.moofOffset || 0),
|
|
// Data offset
|
|
firstSampleFlagsPresent ? u32(allSampleFlags[0]) : [],
|
|
trackData.currentChunk.samples.map((_, i) => [
|
|
sampleDurationPresent ? u32(allSampleDurations[i]) : [],
|
|
// Sample duration
|
|
sampleSizePresent ? u32(allSampleSizes[i]) : [],
|
|
// Sample size
|
|
sampleFlagsPresent ? u32(allSampleFlags[i]) : [],
|
|
// Sample flags
|
|
// Sample composition time offsets
|
|
sampleCompositionTimeOffsetsPresent ? i32(allSampleCompositionTimeOffsets[i]) : []
|
|
])
|
|
]);
|
|
};
|
|
var mfra = (trackDatas) => {
|
|
return box("mfra", void 0, [
|
|
...trackDatas.map(tfra),
|
|
mfro()
|
|
]);
|
|
};
|
|
var tfra = (trackData, trackIndex) => {
|
|
const version = 1;
|
|
return fullBox("tfra", version, 0, [
|
|
u32(trackData.track.id),
|
|
// Track ID
|
|
u32(63),
|
|
// This specifies that traf number, trun number and sample number are 32-bit ints
|
|
u32(trackData.finalizedChunks.length),
|
|
// Number of entries
|
|
trackData.finalizedChunks.map((chunk) => [
|
|
u64(intoTimescale(chunk.samples[0].timestamp, trackData.timescale)),
|
|
// Time (in presentation time)
|
|
u64(chunk.moofOffset),
|
|
// moof offset
|
|
u32(trackIndex + 1),
|
|
// traf number
|
|
u32(1),
|
|
// trun number
|
|
u32(1)
|
|
// Sample number
|
|
])
|
|
]);
|
|
};
|
|
var mfro = () => {
|
|
return fullBox("mfro", 0, 0, [
|
|
// This value needs to be overwritten manually from the outside, where the actual size of the enclosing mfra box
|
|
// is known
|
|
u32(0)
|
|
// Size
|
|
]);
|
|
};
|
|
var vtte = () => box("vtte");
|
|
var vttc = (payload, timestamp, identifier, settings, sourceId) => box("vttc", void 0, [
|
|
sourceId !== null ? box("vsid", [i32(sourceId)]) : null,
|
|
identifier !== null ? box("iden", [...textEncoder.encode(identifier)]) : null,
|
|
timestamp !== null ? box("ctim", [...textEncoder.encode(formatSubtitleTimestamp(timestamp))]) : null,
|
|
settings !== null ? box("sttg", [...textEncoder.encode(settings)]) : null,
|
|
box("payl", [...textEncoder.encode(payload)])
|
|
]);
|
|
var vtta = (notes) => box("vtta", [...textEncoder.encode(notes)]);
|
|
var udta = (muxer) => {
|
|
const boxes = [];
|
|
const metadataFormat = muxer.format._options.metadataFormat ?? "auto";
|
|
const metadataTags = muxer.output._metadataTags;
|
|
if (metadataFormat === "mdir" || metadataFormat === "auto" && !muxer.isQuickTime) {
|
|
const metaBox = metaMdir(metadataTags);
|
|
if (metaBox) boxes.push(metaBox);
|
|
} else if (metadataFormat === "mdta") {
|
|
const metaBox = metaMdta(metadataTags);
|
|
if (metaBox) boxes.push(metaBox);
|
|
} else if (metadataFormat === "udta" || metadataFormat === "auto" && muxer.isQuickTime) {
|
|
addQuickTimeMetadataTagBoxes(boxes, muxer.output._metadataTags);
|
|
}
|
|
if (boxes.length === 0) {
|
|
return null;
|
|
}
|
|
return box("udta", void 0, boxes);
|
|
};
|
|
var addQuickTimeMetadataTagBoxes = (boxes, tags) => {
|
|
for (const { key, value } of keyValueIterator(tags)) {
|
|
switch (key) {
|
|
case "title":
|
|
{
|
|
boxes.push(metadataTagStringBoxShort("\xA9nam", value));
|
|
}
|
|
;
|
|
break;
|
|
case "description":
|
|
{
|
|
boxes.push(metadataTagStringBoxShort("\xA9des", value));
|
|
}
|
|
;
|
|
break;
|
|
case "artist":
|
|
{
|
|
boxes.push(metadataTagStringBoxShort("\xA9ART", value));
|
|
}
|
|
;
|
|
break;
|
|
case "album":
|
|
{
|
|
boxes.push(metadataTagStringBoxShort("\xA9alb", value));
|
|
}
|
|
;
|
|
break;
|
|
case "albumArtist":
|
|
{
|
|
boxes.push(metadataTagStringBoxShort("albr", value));
|
|
}
|
|
;
|
|
break;
|
|
case "genre":
|
|
{
|
|
boxes.push(metadataTagStringBoxShort("\xA9gen", value));
|
|
}
|
|
;
|
|
break;
|
|
case "date":
|
|
{
|
|
boxes.push(metadataTagStringBoxShort("\xA9day", value.toISOString().slice(0, 10)));
|
|
}
|
|
;
|
|
break;
|
|
case "comment":
|
|
{
|
|
boxes.push(metadataTagStringBoxShort("\xA9cmt", value));
|
|
}
|
|
;
|
|
break;
|
|
case "lyrics":
|
|
{
|
|
boxes.push(metadataTagStringBoxShort("\xA9lyr", value));
|
|
}
|
|
;
|
|
break;
|
|
case "raw":
|
|
{
|
|
}
|
|
;
|
|
break;
|
|
case "discNumber":
|
|
case "discsTotal":
|
|
case "trackNumber":
|
|
case "tracksTotal":
|
|
case "images":
|
|
{
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
assertNever(key);
|
|
}
|
|
}
|
|
if (tags.raw) {
|
|
for (const key in tags.raw) {
|
|
const value = tags.raw[key];
|
|
if (value == null || key.length !== 4 || boxes.some((x) => x.type === key)) {
|
|
continue;
|
|
}
|
|
if (typeof value === "string") {
|
|
boxes.push(metadataTagStringBoxShort(key, value));
|
|
} else if (value instanceof Uint8Array) {
|
|
boxes.push(box(key, Array.from(value)));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
var metadataTagStringBoxShort = (name, value) => {
|
|
const encoded = textEncoder.encode(value);
|
|
return box(name, [
|
|
u16(encoded.length),
|
|
u16(getLanguageCodeInt("und")),
|
|
Array.from(encoded)
|
|
]);
|
|
};
|
|
var DATA_BOX_MIME_TYPE_MAP = {
|
|
"image/jpeg": 13,
|
|
"image/png": 14,
|
|
"image/bmp": 27
|
|
};
|
|
var generateMetadataPairs = (tags, isMdta) => {
|
|
const pairs = [];
|
|
for (const { key, value } of keyValueIterator(tags)) {
|
|
switch (key) {
|
|
case "title":
|
|
{
|
|
pairs.push({ key: isMdta ? "title" : "\xA9nam", value: dataStringBoxLong(value) });
|
|
}
|
|
;
|
|
break;
|
|
case "description":
|
|
{
|
|
pairs.push({ key: isMdta ? "description" : "\xA9des", value: dataStringBoxLong(value) });
|
|
}
|
|
;
|
|
break;
|
|
case "artist":
|
|
{
|
|
pairs.push({ key: isMdta ? "artist" : "\xA9ART", value: dataStringBoxLong(value) });
|
|
}
|
|
;
|
|
break;
|
|
case "album":
|
|
{
|
|
pairs.push({ key: isMdta ? "album" : "\xA9alb", value: dataStringBoxLong(value) });
|
|
}
|
|
;
|
|
break;
|
|
case "albumArtist":
|
|
{
|
|
pairs.push({ key: isMdta ? "album_artist" : "aART", value: dataStringBoxLong(value) });
|
|
}
|
|
;
|
|
break;
|
|
case "comment":
|
|
{
|
|
pairs.push({ key: isMdta ? "comment" : "\xA9cmt", value: dataStringBoxLong(value) });
|
|
}
|
|
;
|
|
break;
|
|
case "genre":
|
|
{
|
|
pairs.push({ key: isMdta ? "genre" : "\xA9gen", value: dataStringBoxLong(value) });
|
|
}
|
|
;
|
|
break;
|
|
case "lyrics":
|
|
{
|
|
pairs.push({ key: isMdta ? "lyrics" : "\xA9lyr", value: dataStringBoxLong(value) });
|
|
}
|
|
;
|
|
break;
|
|
case "date":
|
|
{
|
|
pairs.push({
|
|
key: isMdta ? "date" : "\xA9day",
|
|
value: dataStringBoxLong(value.toISOString().slice(0, 10))
|
|
});
|
|
}
|
|
;
|
|
break;
|
|
case "images":
|
|
{
|
|
for (const image of value) {
|
|
if (image.kind !== "coverFront") {
|
|
continue;
|
|
}
|
|
pairs.push({ key: "covr", value: box("data", [
|
|
u32(DATA_BOX_MIME_TYPE_MAP[image.mimeType] ?? 0),
|
|
// Type indicator
|
|
u32(0),
|
|
// Locale indicator
|
|
Array.from(image.data)
|
|
// Kinda slow, hopefully temp
|
|
]) });
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "trackNumber":
|
|
{
|
|
if (isMdta) {
|
|
const string = tags.tracksTotal !== void 0 ? `${value}/${tags.tracksTotal}` : value.toString();
|
|
pairs.push({ key: "track", value: dataStringBoxLong(string) });
|
|
} else {
|
|
pairs.push({ key: "trkn", value: box("data", [
|
|
u32(0),
|
|
// 8 bytes empty
|
|
u32(0),
|
|
u16(0),
|
|
// Empty
|
|
u16(value),
|
|
u16(tags.tracksTotal ?? 0),
|
|
u16(0)
|
|
// Empty
|
|
]) });
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "discNumber":
|
|
{
|
|
if (!isMdta) {
|
|
pairs.push({ key: "disc", value: box("data", [
|
|
u32(0),
|
|
// 8 bytes empty
|
|
u32(0),
|
|
u16(0),
|
|
// Empty
|
|
u16(value),
|
|
u16(tags.discsTotal ?? 0),
|
|
u16(0)
|
|
// Empty
|
|
]) });
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "tracksTotal":
|
|
case "discsTotal":
|
|
{
|
|
}
|
|
;
|
|
break;
|
|
case "raw":
|
|
{
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
assertNever(key);
|
|
}
|
|
}
|
|
if (tags.raw) {
|
|
for (const key in tags.raw) {
|
|
const value = tags.raw[key];
|
|
if (value == null || !isMdta && key.length !== 4 || pairs.some((x) => x.key === key)) {
|
|
continue;
|
|
}
|
|
if (typeof value === "string") {
|
|
pairs.push({ key, value: dataStringBoxLong(value) });
|
|
} else if (value instanceof Uint8Array) {
|
|
pairs.push({ key, value: box("data", [
|
|
u32(0),
|
|
// Type indicator
|
|
u32(0),
|
|
// Locale indicator
|
|
Array.from(value)
|
|
]) });
|
|
} else if (value instanceof RichImageData) {
|
|
pairs.push({ key, value: box("data", [
|
|
u32(DATA_BOX_MIME_TYPE_MAP[value.mimeType] ?? 0),
|
|
// Type indicator
|
|
u32(0),
|
|
// Locale indicator
|
|
Array.from(value.data)
|
|
// Kinda slow, hopefully temp
|
|
]) });
|
|
}
|
|
}
|
|
}
|
|
return pairs;
|
|
};
|
|
var metaMdir = (tags) => {
|
|
const pairs = generateMetadataPairs(tags, false);
|
|
if (pairs.length === 0) {
|
|
return null;
|
|
}
|
|
return fullBox("meta", 0, 0, void 0, [
|
|
hdlr(false, "mdir", "", "appl"),
|
|
// mdir handler
|
|
box("ilst", void 0, pairs.map((pair) => box(pair.key, void 0, [pair.value])))
|
|
// Item list without keys box
|
|
]);
|
|
};
|
|
var metaMdta = (tags) => {
|
|
const pairs = generateMetadataPairs(tags, true);
|
|
if (pairs.length === 0) {
|
|
return null;
|
|
}
|
|
return box("meta", void 0, [
|
|
hdlr(false, "mdta", ""),
|
|
// mdta handler
|
|
fullBox("keys", 0, 0, [
|
|
u32(pairs.length)
|
|
], pairs.map((pair) => box("mdta", [
|
|
// Hacky since these aren't boxes technically, but if not box why box-shaped?
|
|
...textEncoder.encode(pair.key)
|
|
]))),
|
|
box("ilst", void 0, pairs.map((pair, i) => {
|
|
const boxName = String.fromCharCode(...u32(i + 1));
|
|
return box(boxName, void 0, [pair.value]);
|
|
}))
|
|
]);
|
|
};
|
|
var dataStringBoxLong = (value) => {
|
|
return box("data", [
|
|
u32(1),
|
|
// Type indicator (UTF-8)
|
|
u32(0),
|
|
// Locale indicator
|
|
...textEncoder.encode(value)
|
|
]);
|
|
};
|
|
var videoCodecToBoxName = (codec, fullCodecString) => {
|
|
switch (codec) {
|
|
case "avc":
|
|
return fullCodecString.startsWith("avc3") ? "avc3" : "avc1";
|
|
case "hevc":
|
|
return "hvc1";
|
|
case "vp8":
|
|
return "vp08";
|
|
case "vp9":
|
|
return "vp09";
|
|
case "av1":
|
|
return "av01";
|
|
}
|
|
};
|
|
var VIDEO_CODEC_TO_CONFIGURATION_BOX = {
|
|
avc: avcC,
|
|
hevc: hvcC,
|
|
vp8: vpcC,
|
|
vp9: vpcC,
|
|
av1: av1C
|
|
};
|
|
var audioCodecToBoxName = (codec, isQuickTime) => {
|
|
switch (codec) {
|
|
case "aac":
|
|
return "mp4a";
|
|
case "mp3":
|
|
return "mp4a";
|
|
case "opus":
|
|
return "Opus";
|
|
case "vorbis":
|
|
return "mp4a";
|
|
case "flac":
|
|
return "fLaC";
|
|
case "ulaw":
|
|
return "ulaw";
|
|
case "alaw":
|
|
return "alaw";
|
|
case "pcm-u8":
|
|
return "raw ";
|
|
case "pcm-s8":
|
|
return "sowt";
|
|
case "ac3":
|
|
return "ac-3";
|
|
case "eac3":
|
|
return "ec-3";
|
|
}
|
|
if (isQuickTime) {
|
|
switch (codec) {
|
|
case "pcm-s16":
|
|
return "sowt";
|
|
case "pcm-s16be":
|
|
return "twos";
|
|
case "pcm-s24":
|
|
return "in24";
|
|
case "pcm-s24be":
|
|
return "in24";
|
|
case "pcm-s32":
|
|
return "in32";
|
|
case "pcm-s32be":
|
|
return "in32";
|
|
case "pcm-f32":
|
|
return "fl32";
|
|
case "pcm-f32be":
|
|
return "fl32";
|
|
case "pcm-f64":
|
|
return "fl64";
|
|
case "pcm-f64be":
|
|
return "fl64";
|
|
}
|
|
} else {
|
|
switch (codec) {
|
|
case "pcm-s16":
|
|
return "ipcm";
|
|
case "pcm-s16be":
|
|
return "ipcm";
|
|
case "pcm-s24":
|
|
return "ipcm";
|
|
case "pcm-s24be":
|
|
return "ipcm";
|
|
case "pcm-s32":
|
|
return "ipcm";
|
|
case "pcm-s32be":
|
|
return "ipcm";
|
|
case "pcm-f32":
|
|
return "fpcm";
|
|
case "pcm-f32be":
|
|
return "fpcm";
|
|
case "pcm-f64":
|
|
return "fpcm";
|
|
case "pcm-f64be":
|
|
return "fpcm";
|
|
}
|
|
}
|
|
};
|
|
var audioCodecToConfigurationBox = (codec, isQuickTime) => {
|
|
switch (codec) {
|
|
case "aac":
|
|
return esds;
|
|
case "mp3":
|
|
return esds;
|
|
case "opus":
|
|
return dOps;
|
|
case "vorbis":
|
|
return esds;
|
|
case "flac":
|
|
return dfLa;
|
|
case "ac3":
|
|
return dac3;
|
|
case "eac3":
|
|
return dec3;
|
|
}
|
|
if (isQuickTime) {
|
|
switch (codec) {
|
|
case "pcm-s24":
|
|
return wave;
|
|
case "pcm-s24be":
|
|
return wave;
|
|
case "pcm-s32":
|
|
return wave;
|
|
case "pcm-s32be":
|
|
return wave;
|
|
case "pcm-f32":
|
|
return wave;
|
|
case "pcm-f32be":
|
|
return wave;
|
|
case "pcm-f64":
|
|
return wave;
|
|
case "pcm-f64be":
|
|
return wave;
|
|
}
|
|
} else {
|
|
switch (codec) {
|
|
case "pcm-s16":
|
|
return pcmC;
|
|
case "pcm-s16be":
|
|
return pcmC;
|
|
case "pcm-s24":
|
|
return pcmC;
|
|
case "pcm-s24be":
|
|
return pcmC;
|
|
case "pcm-s32":
|
|
return pcmC;
|
|
case "pcm-s32be":
|
|
return pcmC;
|
|
case "pcm-f32":
|
|
return pcmC;
|
|
case "pcm-f32be":
|
|
return pcmC;
|
|
case "pcm-f64":
|
|
return pcmC;
|
|
case "pcm-f64be":
|
|
return pcmC;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
var SUBTITLE_CODEC_TO_BOX_NAME = {
|
|
webvtt: "wvtt"
|
|
};
|
|
var SUBTITLE_CODEC_TO_CONFIGURATION_BOX = {
|
|
webvtt: vttC
|
|
};
|
|
var getLanguageCodeInt = (code) => {
|
|
assert(code.length === 3);
|
|
;
|
|
let language = 0;
|
|
for (let i = 0; i < 3; i++) {
|
|
language <<= 5;
|
|
language += code.charCodeAt(i) - 96;
|
|
}
|
|
return language;
|
|
};
|
|
|
|
// src/writer.ts
|
|
var Writer = class {
|
|
constructor() {
|
|
/** Setting this to true will cause the writer to ensure data is written in a strictly monotonic, streamable way. */
|
|
this.ensureMonotonicity = false;
|
|
this.trackedWrites = null;
|
|
this.trackedStart = -1;
|
|
this.trackedEnd = -1;
|
|
}
|
|
start() {
|
|
}
|
|
maybeTrackWrites(data) {
|
|
if (!this.trackedWrites) {
|
|
return;
|
|
}
|
|
let pos = this.getPos();
|
|
if (pos < this.trackedStart) {
|
|
if (pos + data.byteLength <= this.trackedStart) {
|
|
return;
|
|
}
|
|
data = data.subarray(this.trackedStart - pos);
|
|
pos = 0;
|
|
}
|
|
const neededSize = pos + data.byteLength - this.trackedStart;
|
|
let newLength = this.trackedWrites.byteLength;
|
|
while (newLength < neededSize) {
|
|
newLength *= 2;
|
|
}
|
|
if (newLength !== this.trackedWrites.byteLength) {
|
|
const copy = new Uint8Array(newLength);
|
|
copy.set(this.trackedWrites, 0);
|
|
this.trackedWrites = copy;
|
|
}
|
|
this.trackedWrites.set(data, pos - this.trackedStart);
|
|
this.trackedEnd = Math.max(this.trackedEnd, pos + data.byteLength);
|
|
}
|
|
startTrackingWrites() {
|
|
this.trackedWrites = new Uint8Array(2 ** 10);
|
|
this.trackedStart = this.getPos();
|
|
this.trackedEnd = this.trackedStart;
|
|
}
|
|
stopTrackingWrites() {
|
|
if (!this.trackedWrites) {
|
|
throw new Error("Internal error: Can't get tracked writes since nothing was tracked.");
|
|
}
|
|
const slice = this.trackedWrites.subarray(0, this.trackedEnd - this.trackedStart);
|
|
const result = {
|
|
data: slice,
|
|
start: this.trackedStart,
|
|
end: this.trackedEnd
|
|
};
|
|
this.trackedWrites = null;
|
|
return result;
|
|
}
|
|
};
|
|
var ARRAY_BUFFER_INITIAL_SIZE = 2 ** 16;
|
|
var ARRAY_BUFFER_MAX_SIZE = 2 ** 32;
|
|
var BufferTargetWriter = class extends Writer {
|
|
constructor(target) {
|
|
super();
|
|
this.pos = 0;
|
|
this.maxPos = 0;
|
|
this.target = target;
|
|
this.supportsResize = "resize" in new ArrayBuffer(0);
|
|
if (this.supportsResize) {
|
|
try {
|
|
this.buffer = new ArrayBuffer(ARRAY_BUFFER_INITIAL_SIZE, { maxByteLength: ARRAY_BUFFER_MAX_SIZE });
|
|
} catch {
|
|
this.buffer = new ArrayBuffer(ARRAY_BUFFER_INITIAL_SIZE);
|
|
this.supportsResize = false;
|
|
}
|
|
} else {
|
|
this.buffer = new ArrayBuffer(ARRAY_BUFFER_INITIAL_SIZE);
|
|
}
|
|
this.bytes = new Uint8Array(this.buffer);
|
|
}
|
|
ensureSize(size) {
|
|
let newLength = this.buffer.byteLength;
|
|
while (newLength < size) newLength *= 2;
|
|
if (newLength === this.buffer.byteLength) return;
|
|
if (newLength > ARRAY_BUFFER_MAX_SIZE) {
|
|
throw new Error(
|
|
`ArrayBuffer exceeded maximum size of ${ARRAY_BUFFER_MAX_SIZE} bytes. Please consider using another target.`
|
|
);
|
|
}
|
|
if (this.supportsResize) {
|
|
this.buffer.resize(newLength);
|
|
} else {
|
|
const newBuffer = new ArrayBuffer(newLength);
|
|
const newBytes = new Uint8Array(newBuffer);
|
|
newBytes.set(this.bytes, 0);
|
|
this.buffer = newBuffer;
|
|
this.bytes = newBytes;
|
|
}
|
|
}
|
|
write(data) {
|
|
this.maybeTrackWrites(data);
|
|
this.ensureSize(this.pos + data.byteLength);
|
|
this.bytes.set(data, this.pos);
|
|
this.target.onwrite?.(this.pos, this.pos + data.byteLength);
|
|
this.pos += data.byteLength;
|
|
this.maxPos = Math.max(this.maxPos, this.pos);
|
|
}
|
|
seek(newPos) {
|
|
this.pos = newPos;
|
|
}
|
|
getPos() {
|
|
return this.pos;
|
|
}
|
|
async flush() {
|
|
}
|
|
async finalize() {
|
|
this.ensureSize(this.pos);
|
|
this.target.buffer = this.buffer.slice(0, Math.max(this.maxPos, this.pos));
|
|
}
|
|
async close() {
|
|
}
|
|
getSlice(start, end) {
|
|
return this.bytes.slice(start, end);
|
|
}
|
|
};
|
|
var DEFAULT_CHUNK_SIZE = 2 ** 24;
|
|
var MAX_CHUNKS_AT_ONCE = 2;
|
|
var StreamTargetWriter = class extends Writer {
|
|
constructor(target) {
|
|
super();
|
|
this.pos = 0;
|
|
this.sections = [];
|
|
this.lastWriteEnd = 0;
|
|
this.lastFlushEnd = 0;
|
|
this.writer = null;
|
|
/**
|
|
* The data is divided up into fixed-size chunks, whose contents are first filled in RAM and then flushed out.
|
|
* A chunk is flushed if all of its contents have been written.
|
|
*/
|
|
this.chunks = [];
|
|
this.target = target;
|
|
this.chunked = target._options.chunked ?? false;
|
|
this.chunkSize = target._options.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
}
|
|
start() {
|
|
this.writer = this.target._writable.getWriter();
|
|
}
|
|
write(data) {
|
|
if (this.pos > this.lastWriteEnd) {
|
|
const paddingBytesNeeded = this.pos - this.lastWriteEnd;
|
|
this.pos = this.lastWriteEnd;
|
|
this.write(new Uint8Array(paddingBytesNeeded));
|
|
}
|
|
this.maybeTrackWrites(data);
|
|
this.sections.push({
|
|
data: data.slice(),
|
|
start: this.pos
|
|
});
|
|
this.target.onwrite?.(this.pos, this.pos + data.byteLength);
|
|
this.pos += data.byteLength;
|
|
this.lastWriteEnd = Math.max(this.lastWriteEnd, this.pos);
|
|
}
|
|
seek(newPos) {
|
|
this.pos = newPos;
|
|
}
|
|
getPos() {
|
|
return this.pos;
|
|
}
|
|
async flush() {
|
|
if (this.pos > this.lastWriteEnd) {
|
|
const paddingBytesNeeded = this.pos - this.lastWriteEnd;
|
|
this.pos = this.lastWriteEnd;
|
|
this.write(new Uint8Array(paddingBytesNeeded));
|
|
}
|
|
assert(this.writer);
|
|
if (this.sections.length === 0) return;
|
|
const chunks = [];
|
|
const sorted = [...this.sections].sort((a, b) => a.start - b.start);
|
|
chunks.push({
|
|
start: sorted[0].start,
|
|
size: sorted[0].data.byteLength
|
|
});
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
const lastChunk = chunks[chunks.length - 1];
|
|
const section = sorted[i];
|
|
if (section.start <= lastChunk.start + lastChunk.size) {
|
|
lastChunk.size = Math.max(lastChunk.size, section.start + section.data.byteLength - lastChunk.start);
|
|
} else {
|
|
chunks.push({
|
|
start: section.start,
|
|
size: section.data.byteLength
|
|
});
|
|
}
|
|
}
|
|
for (const chunk of chunks) {
|
|
chunk.data = new Uint8Array(chunk.size);
|
|
for (const section of this.sections) {
|
|
if (chunk.start <= section.start && section.start < chunk.start + chunk.size) {
|
|
chunk.data.set(section.data, section.start - chunk.start);
|
|
}
|
|
}
|
|
if (this.writer.desiredSize !== null && this.writer.desiredSize <= 0) {
|
|
await this.writer.ready;
|
|
}
|
|
if (this.chunked) {
|
|
this.writeDataIntoChunks(chunk.data, chunk.start);
|
|
this.tryToFlushChunks();
|
|
} else {
|
|
if (this.ensureMonotonicity && chunk.start !== this.lastFlushEnd) {
|
|
throw new Error("Internal error: Monotonicity violation.");
|
|
}
|
|
void this.writer.write({
|
|
type: "write",
|
|
data: chunk.data,
|
|
position: chunk.start
|
|
});
|
|
this.lastFlushEnd = chunk.start + chunk.data.byteLength;
|
|
}
|
|
}
|
|
this.sections.length = 0;
|
|
}
|
|
writeDataIntoChunks(data, position) {
|
|
let chunkIndex = this.chunks.findIndex((x) => x.start <= position && position < x.start + this.chunkSize);
|
|
if (chunkIndex === -1) chunkIndex = this.createChunk(position);
|
|
const chunk = this.chunks[chunkIndex];
|
|
const relativePosition = position - chunk.start;
|
|
const toWrite = data.subarray(0, Math.min(this.chunkSize - relativePosition, data.byteLength));
|
|
chunk.data.set(toWrite, relativePosition);
|
|
const section = {
|
|
start: relativePosition,
|
|
end: relativePosition + toWrite.byteLength
|
|
};
|
|
this.insertSectionIntoChunk(chunk, section);
|
|
if (chunk.written[0].start === 0 && chunk.written[0].end === this.chunkSize) {
|
|
chunk.shouldFlush = true;
|
|
}
|
|
if (this.chunks.length > MAX_CHUNKS_AT_ONCE) {
|
|
for (let i = 0; i < this.chunks.length - 1; i++) {
|
|
this.chunks[i].shouldFlush = true;
|
|
}
|
|
this.tryToFlushChunks();
|
|
}
|
|
if (toWrite.byteLength < data.byteLength) {
|
|
this.writeDataIntoChunks(data.subarray(toWrite.byteLength), position + toWrite.byteLength);
|
|
}
|
|
}
|
|
insertSectionIntoChunk(chunk, section) {
|
|
let low = 0;
|
|
let high = chunk.written.length - 1;
|
|
let index = -1;
|
|
while (low <= high) {
|
|
const mid = Math.floor(low + (high - low + 1) / 2);
|
|
if (chunk.written[mid].start <= section.start) {
|
|
low = mid + 1;
|
|
index = mid;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
chunk.written.splice(index + 1, 0, section);
|
|
if (index === -1 || chunk.written[index].end < section.start) index++;
|
|
while (index < chunk.written.length - 1 && chunk.written[index].end >= chunk.written[index + 1].start) {
|
|
chunk.written[index].end = Math.max(chunk.written[index].end, chunk.written[index + 1].end);
|
|
chunk.written.splice(index + 1, 1);
|
|
}
|
|
}
|
|
createChunk(includesPosition) {
|
|
const start = Math.floor(includesPosition / this.chunkSize) * this.chunkSize;
|
|
const chunk = {
|
|
start,
|
|
data: new Uint8Array(this.chunkSize),
|
|
written: [],
|
|
shouldFlush: false
|
|
};
|
|
this.chunks.push(chunk);
|
|
this.chunks.sort((a, b) => a.start - b.start);
|
|
return this.chunks.indexOf(chunk);
|
|
}
|
|
tryToFlushChunks(force = false) {
|
|
assert(this.writer);
|
|
for (let i = 0; i < this.chunks.length; i++) {
|
|
const chunk = this.chunks[i];
|
|
if (!chunk.shouldFlush && !force) continue;
|
|
for (const section of chunk.written) {
|
|
const position = chunk.start + section.start;
|
|
if (this.ensureMonotonicity && position !== this.lastFlushEnd) {
|
|
throw new Error("Internal error: Monotonicity violation.");
|
|
}
|
|
void this.writer.write({
|
|
type: "write",
|
|
data: chunk.data.subarray(section.start, section.end),
|
|
position
|
|
});
|
|
this.lastFlushEnd = chunk.start + section.end;
|
|
}
|
|
this.chunks.splice(i--, 1);
|
|
}
|
|
}
|
|
finalize() {
|
|
if (this.chunked) {
|
|
this.tryToFlushChunks(true);
|
|
}
|
|
assert(this.writer);
|
|
return this.writer.close();
|
|
}
|
|
async close() {
|
|
return this.writer?.close();
|
|
}
|
|
};
|
|
var NullTargetWriter = class extends Writer {
|
|
constructor(target) {
|
|
super();
|
|
this.target = target;
|
|
this.pos = 0;
|
|
}
|
|
write(data) {
|
|
this.maybeTrackWrites(data);
|
|
this.target.onwrite?.(this.pos, this.pos + data.byteLength);
|
|
this.pos += data.byteLength;
|
|
}
|
|
getPos() {
|
|
return this.pos;
|
|
}
|
|
seek(newPos) {
|
|
this.pos = newPos;
|
|
}
|
|
async flush() {
|
|
}
|
|
async finalize() {
|
|
}
|
|
async close() {
|
|
}
|
|
};
|
|
|
|
// src/target.ts
|
|
var nodeAlias2 = __toESM(require_node(), 1);
|
|
var node2 = typeof nodeAlias2 !== "undefined" ? nodeAlias2 : void 0;
|
|
var Target = class {
|
|
constructor() {
|
|
/** @internal */
|
|
this._output = null;
|
|
/**
|
|
* Called each time data is written to the target. Will be called with the byte range into which data was written.
|
|
*
|
|
* Use this callback to track the size of the output file as it grows. But be warned, this function is chatty and
|
|
* gets called *extremely* often.
|
|
*/
|
|
this.onwrite = null;
|
|
}
|
|
};
|
|
var BufferTarget = class extends Target {
|
|
constructor() {
|
|
super(...arguments);
|
|
/** Stores the final output buffer. Until the output is finalized, this will be `null`. */
|
|
this.buffer = null;
|
|
}
|
|
/** @internal */
|
|
_createWriter() {
|
|
return new BufferTargetWriter(this);
|
|
}
|
|
};
|
|
var StreamTarget = class extends Target {
|
|
/** Creates a new {@link StreamTarget} which writes to the specified `writable`. */
|
|
constructor(writable, options = {}) {
|
|
super();
|
|
if (!(writable instanceof WritableStream)) {
|
|
throw new TypeError("StreamTarget requires a WritableStream instance.");
|
|
}
|
|
if (options != null && typeof options !== "object") {
|
|
throw new TypeError("StreamTarget options, when provided, must be an object.");
|
|
}
|
|
if (options.chunked !== void 0 && typeof options.chunked !== "boolean") {
|
|
throw new TypeError("options.chunked, when provided, must be a boolean.");
|
|
}
|
|
if (options.chunkSize !== void 0 && (!Number.isInteger(options.chunkSize) || options.chunkSize < 1024)) {
|
|
throw new TypeError("options.chunkSize, when provided, must be an integer and not smaller than 1024.");
|
|
}
|
|
this._writable = writable;
|
|
this._options = options;
|
|
}
|
|
/** @internal */
|
|
_createWriter() {
|
|
return new StreamTargetWriter(this);
|
|
}
|
|
};
|
|
var FilePathTarget = class extends Target {
|
|
/** Creates a new {@link FilePathTarget} that writes to the file at the specified file path. */
|
|
constructor(filePath, options = {}) {
|
|
if (typeof filePath !== "string") {
|
|
throw new TypeError("filePath must be a string.");
|
|
}
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
super();
|
|
/** @internal */
|
|
this._fileHandle = null;
|
|
const writable = new WritableStream({
|
|
start: async () => {
|
|
this._fileHandle = await node2.fs.open(filePath, "w");
|
|
},
|
|
write: async (chunk) => {
|
|
assert(this._fileHandle);
|
|
await this._fileHandle.write(chunk.data, 0, chunk.data.byteLength, chunk.position);
|
|
},
|
|
close: async () => {
|
|
if (this._fileHandle) {
|
|
await this._fileHandle.close();
|
|
this._fileHandle = null;
|
|
}
|
|
}
|
|
});
|
|
this._streamTarget = new StreamTarget(writable, {
|
|
chunked: true,
|
|
...options
|
|
});
|
|
this._streamTarget._output = this._output;
|
|
}
|
|
/** @internal */
|
|
_createWriter() {
|
|
return this._streamTarget._createWriter();
|
|
}
|
|
};
|
|
var NullTarget = class extends Target {
|
|
/** @internal */
|
|
_createWriter() {
|
|
return new NullTargetWriter(this);
|
|
}
|
|
};
|
|
|
|
// src/isobmff/isobmff-muxer.ts
|
|
var GLOBAL_TIMESCALE = 1e3;
|
|
var TIMESTAMP_OFFSET = 2082844800;
|
|
var getTrackMetadata = (trackData) => {
|
|
const metadata = {};
|
|
const track = trackData.track;
|
|
if (track.metadata.name !== void 0) {
|
|
metadata.name = track.metadata.name;
|
|
}
|
|
return metadata;
|
|
};
|
|
var intoTimescale = (timeInSeconds, timescale, round = true) => {
|
|
const value = timeInSeconds * timescale;
|
|
return round ? Math.round(value) : value;
|
|
};
|
|
var IsobmffMuxer2 = class extends Muxer {
|
|
constructor(output, format) {
|
|
super(output);
|
|
this.auxTarget = new BufferTarget();
|
|
this.auxWriter = this.auxTarget._createWriter();
|
|
this.auxBoxWriter = new IsobmffBoxWriter(this.auxWriter);
|
|
this.mdat = null;
|
|
this.ftypSize = null;
|
|
this.trackDatas = [];
|
|
this.allTracksKnown = promiseWithResolvers();
|
|
this.creationTime = Math.floor(Date.now() / 1e3) + TIMESTAMP_OFFSET;
|
|
this.finalizedChunks = [];
|
|
this.nextFragmentNumber = 1;
|
|
// Only relevant for fragmented files, to make sure new fragments start with the highest timestamp seen so far
|
|
this.maxWrittenTimestamp = -Infinity;
|
|
this.format = format;
|
|
this.writer = output._writer;
|
|
this.boxWriter = new IsobmffBoxWriter(this.writer);
|
|
this.isQuickTime = format instanceof MovOutputFormat;
|
|
const fastStartDefault = this.writer instanceof BufferTargetWriter ? "in-memory" : false;
|
|
this.fastStart = format._options.fastStart ?? fastStartDefault;
|
|
this.isFragmented = this.fastStart === "fragmented";
|
|
if (this.fastStart === "in-memory" || this.isFragmented) {
|
|
this.writer.ensureMonotonicity = true;
|
|
}
|
|
this.minimumFragmentDuration = format._options.minimumFragmentDuration ?? 1;
|
|
}
|
|
async start() {
|
|
const release = await this.mutex.acquire();
|
|
const holdsAvc = this.output._tracks.some((x) => x.type === "video" && x.source._codec === "avc");
|
|
{
|
|
if (this.format._options.onFtyp) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
this.boxWriter.writeBox(ftyp({
|
|
isQuickTime: this.isQuickTime,
|
|
holdsAvc,
|
|
fragmented: this.isFragmented
|
|
}));
|
|
if (this.format._options.onFtyp) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onFtyp(data, start);
|
|
}
|
|
}
|
|
this.ftypSize = this.writer.getPos();
|
|
if (this.fastStart === "in-memory") {
|
|
} else if (this.fastStart === "reserve") {
|
|
for (const track of this.output._tracks) {
|
|
if (track.metadata.maximumPacketCount === void 0) {
|
|
throw new Error(
|
|
"All tracks must specify maximumPacketCount in their metadata when using fastStart: 'reserve'."
|
|
);
|
|
}
|
|
}
|
|
} else if (this.isFragmented) {
|
|
} else {
|
|
if (this.format._options.onMdat) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
this.mdat = mdat(true);
|
|
this.boxWriter.writeBox(this.mdat);
|
|
}
|
|
await this.writer.flush();
|
|
release();
|
|
}
|
|
allTracksAreKnown() {
|
|
for (const track of this.output._tracks) {
|
|
if (!track.source._closed && !this.trackDatas.some((x) => x.track === track)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
async getMimeType() {
|
|
await this.allTracksKnown.promise;
|
|
const codecStrings = this.trackDatas.map((trackData) => {
|
|
if (trackData.type === "video") {
|
|
return trackData.info.decoderConfig.codec;
|
|
} else if (trackData.type === "audio") {
|
|
return trackData.info.decoderConfig.codec;
|
|
} else {
|
|
const map = {
|
|
webvtt: "wvtt"
|
|
};
|
|
return map[trackData.track.source._codec];
|
|
}
|
|
});
|
|
return buildIsobmffMimeType({
|
|
isQuickTime: this.isQuickTime,
|
|
hasVideo: this.trackDatas.some((x) => x.type === "video"),
|
|
hasAudio: this.trackDatas.some((x) => x.type === "audio"),
|
|
codecStrings
|
|
});
|
|
}
|
|
getVideoTrackData(track, packet, meta) {
|
|
const existingTrackData = this.trackDatas.find((x) => x.track === track);
|
|
if (existingTrackData) {
|
|
return existingTrackData;
|
|
}
|
|
validateVideoChunkMetadata(meta);
|
|
assert(meta);
|
|
assert(meta.decoderConfig);
|
|
const decoderConfig = { ...meta.decoderConfig };
|
|
assert(decoderConfig.codedWidth !== void 0);
|
|
assert(decoderConfig.codedHeight !== void 0);
|
|
let requiresAnnexBTransformation = false;
|
|
if (track.source._codec === "avc" && !decoderConfig.description) {
|
|
const decoderConfigurationRecord = extractAvcDecoderConfigurationRecord(packet.data);
|
|
if (!decoderConfigurationRecord) {
|
|
throw new Error(
|
|
"Couldn't extract an AVCDecoderConfigurationRecord from the AVC packet. Make sure the packets are in Annex B format (as specified in ITU-T-REC-H.264) when not providing a description, or provide a description (must be an AVCDecoderConfigurationRecord as specified in ISO 14496-15) and ensure the packets are in AVCC format."
|
|
);
|
|
}
|
|
decoderConfig.description = serializeAvcDecoderConfigurationRecord(decoderConfigurationRecord);
|
|
requiresAnnexBTransformation = true;
|
|
} else if (track.source._codec === "hevc" && !decoderConfig.description) {
|
|
const decoderConfigurationRecord = extractHevcDecoderConfigurationRecord(packet.data);
|
|
if (!decoderConfigurationRecord) {
|
|
throw new Error(
|
|
"Couldn't extract an HEVCDecoderConfigurationRecord from the HEVC packet. Make sure the packets are in Annex B format (as specified in ITU-T-REC-H.265) when not providing a description, or provide a description (must be an HEVCDecoderConfigurationRecord as specified in ISO 14496-15) and ensure the packets are in HEVC format."
|
|
);
|
|
}
|
|
decoderConfig.description = serializeHevcDecoderConfigurationRecord(decoderConfigurationRecord);
|
|
requiresAnnexBTransformation = true;
|
|
}
|
|
const timescale = computeRationalApproximation(1 / (track.metadata.frameRate ?? 57600), 1e6).denominator;
|
|
const newTrackData = {
|
|
muxer: this,
|
|
track,
|
|
type: "video",
|
|
info: {
|
|
width: decoderConfig.codedWidth,
|
|
height: decoderConfig.codedHeight,
|
|
decoderConfig,
|
|
requiresAnnexBTransformation
|
|
},
|
|
timescale,
|
|
samples: [],
|
|
sampleQueue: [],
|
|
timestampProcessingQueue: [],
|
|
timeToSampleTable: [],
|
|
compositionTimeOffsetTable: [],
|
|
lastTimescaleUnits: null,
|
|
lastSample: null,
|
|
finalizedChunks: [],
|
|
currentChunk: null,
|
|
compactlyCodedChunkTable: []
|
|
};
|
|
this.trackDatas.push(newTrackData);
|
|
this.trackDatas.sort((a, b) => a.track.id - b.track.id);
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
return newTrackData;
|
|
}
|
|
getAudioTrackData(track, packet, meta) {
|
|
const existingTrackData = this.trackDatas.find((x) => x.track === track);
|
|
if (existingTrackData) {
|
|
return existingTrackData;
|
|
}
|
|
validateAudioChunkMetadata(meta);
|
|
assert(meta);
|
|
assert(meta.decoderConfig);
|
|
const decoderConfig = { ...meta.decoderConfig };
|
|
let requiresAdtsStripping = false;
|
|
if (track.source._codec === "aac" && !decoderConfig.description) {
|
|
const adtsFrame = readAdtsFrameHeader(FileSlice4.tempFromBytes(packet.data));
|
|
if (!adtsFrame) {
|
|
throw new Error(
|
|
"Couldn't parse ADTS header from the AAC packet. Make sure the packets are in ADTS format (as specified in ISO 13818-7) when not providing a description, or provide a description (must be an AudioSpecificConfig as specified in ISO 14496-3) and ensure the packets are raw AAC data."
|
|
);
|
|
}
|
|
const sampleRate = aacFrequencyTable[adtsFrame.samplingFrequencyIndex];
|
|
const numberOfChannels = aacChannelMap[adtsFrame.channelConfiguration];
|
|
if (sampleRate === void 0 || numberOfChannels === void 0) {
|
|
throw new Error("Invalid ADTS frame header.");
|
|
}
|
|
decoderConfig.description = buildAacAudioSpecificConfig({
|
|
objectType: adtsFrame.objectType,
|
|
sampleRate,
|
|
numberOfChannels
|
|
});
|
|
requiresAdtsStripping = true;
|
|
}
|
|
const newTrackData = {
|
|
muxer: this,
|
|
track,
|
|
type: "audio",
|
|
info: {
|
|
numberOfChannels: meta.decoderConfig.numberOfChannels,
|
|
sampleRate: meta.decoderConfig.sampleRate,
|
|
decoderConfig,
|
|
requiresPcmTransformation: !this.isFragmented && PCM_AUDIO_CODECS.includes(track.source._codec),
|
|
requiresAdtsStripping,
|
|
firstPacket: packet
|
|
},
|
|
timescale: decoderConfig.sampleRate,
|
|
samples: [],
|
|
sampleQueue: [],
|
|
timestampProcessingQueue: [],
|
|
timeToSampleTable: [],
|
|
compositionTimeOffsetTable: [],
|
|
lastTimescaleUnits: null,
|
|
lastSample: null,
|
|
finalizedChunks: [],
|
|
currentChunk: null,
|
|
compactlyCodedChunkTable: []
|
|
};
|
|
this.trackDatas.push(newTrackData);
|
|
this.trackDatas.sort((a, b) => a.track.id - b.track.id);
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
return newTrackData;
|
|
}
|
|
getSubtitleTrackData(track, meta) {
|
|
const existingTrackData = this.trackDatas.find((x) => x.track === track);
|
|
if (existingTrackData) {
|
|
return existingTrackData;
|
|
}
|
|
validateSubtitleMetadata(meta);
|
|
assert(meta);
|
|
assert(meta.config);
|
|
const newTrackData = {
|
|
muxer: this,
|
|
track,
|
|
type: "subtitle",
|
|
info: {
|
|
config: meta.config
|
|
},
|
|
timescale: 1e3,
|
|
// Reasonable
|
|
samples: [],
|
|
sampleQueue: [],
|
|
timestampProcessingQueue: [],
|
|
timeToSampleTable: [],
|
|
compositionTimeOffsetTable: [],
|
|
lastTimescaleUnits: null,
|
|
lastSample: null,
|
|
finalizedChunks: [],
|
|
currentChunk: null,
|
|
compactlyCodedChunkTable: [],
|
|
lastCueEndTimestamp: 0,
|
|
cueQueue: [],
|
|
nextSourceId: 0,
|
|
cueToSourceId: /* @__PURE__ */ new WeakMap()
|
|
};
|
|
this.trackDatas.push(newTrackData);
|
|
this.trackDatas.sort((a, b) => a.track.id - b.track.id);
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
return newTrackData;
|
|
}
|
|
async addEncodedVideoPacket(track, packet, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getVideoTrackData(track, packet, meta);
|
|
let packetData = packet.data;
|
|
if (trackData.info.requiresAnnexBTransformation) {
|
|
const nalUnits = [...iterateNalUnitsInAnnexB(packetData)].map((loc) => packetData.subarray(loc.offset, loc.offset + loc.length));
|
|
if (nalUnits.length === 0) {
|
|
throw new Error(
|
|
"Failed to transform packet data. Make sure all packets are provided in Annex B format, as specified in ITU-T-REC-H.264 and ITU-T-REC-H.265."
|
|
);
|
|
}
|
|
packetData = concatNalUnitsInLengthPrefixed(nalUnits, 4);
|
|
}
|
|
const timestamp = this.validateAndNormalizeTimestamp(
|
|
trackData.track,
|
|
packet.timestamp,
|
|
packet.type === "key"
|
|
);
|
|
const internalSample = this.createSampleForTrack(
|
|
trackData,
|
|
packetData,
|
|
timestamp,
|
|
packet.duration,
|
|
packet.type
|
|
);
|
|
await this.registerSample(trackData, internalSample);
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async addEncodedAudioPacket(track, packet, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getAudioTrackData(track, packet, meta);
|
|
let packetData = packet.data;
|
|
if (trackData.info.requiresAdtsStripping) {
|
|
const adtsFrame = readAdtsFrameHeader(FileSlice4.tempFromBytes(packetData));
|
|
if (!adtsFrame) {
|
|
throw new Error("Expected ADTS frame, didn't get one.");
|
|
}
|
|
const headerLength = adtsFrame.crcCheck === null ? MIN_ADTS_FRAME_HEADER_SIZE : MAX_ADTS_FRAME_HEADER_SIZE;
|
|
packetData = packetData.subarray(headerLength);
|
|
}
|
|
const timestamp = this.validateAndNormalizeTimestamp(
|
|
trackData.track,
|
|
packet.timestamp,
|
|
packet.type === "key"
|
|
);
|
|
const internalSample = this.createSampleForTrack(
|
|
trackData,
|
|
packetData,
|
|
timestamp,
|
|
packet.duration,
|
|
packet.type
|
|
);
|
|
if (trackData.info.requiresPcmTransformation) {
|
|
await this.maybePadWithSilence(trackData, timestamp);
|
|
}
|
|
await this.registerSample(trackData, internalSample);
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async maybePadWithSilence(trackData, untilTimestamp) {
|
|
const lastSample = last(trackData.samples);
|
|
const lastEndTimestamp = lastSample ? lastSample.timestamp + lastSample.duration : 0;
|
|
const delta = untilTimestamp - lastEndTimestamp;
|
|
const deltaInTimescale = intoTimescale(delta, trackData.timescale);
|
|
if (deltaInTimescale > 0) {
|
|
const { sampleSize, silentValue } = parsePcmCodec(
|
|
trackData.info.decoderConfig.codec
|
|
);
|
|
const samplesNeeded = deltaInTimescale * trackData.info.numberOfChannels;
|
|
const data = new Uint8Array(sampleSize * samplesNeeded).fill(silentValue);
|
|
const paddingSample = this.createSampleForTrack(
|
|
trackData,
|
|
new Uint8Array(data.buffer),
|
|
lastEndTimestamp,
|
|
delta,
|
|
"key"
|
|
);
|
|
await this.registerSample(trackData, paddingSample);
|
|
}
|
|
}
|
|
async addSubtitleCue(track, cue, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getSubtitleTrackData(track, meta);
|
|
this.validateAndNormalizeTimestamp(trackData.track, cue.timestamp, true);
|
|
if (track.source._codec === "webvtt") {
|
|
trackData.cueQueue.push(cue);
|
|
await this.processWebVTTCues(trackData, cue.timestamp);
|
|
} else {
|
|
}
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async processWebVTTCues(trackData, until) {
|
|
while (trackData.cueQueue.length > 0) {
|
|
const timestamps = /* @__PURE__ */ new Set([]);
|
|
for (const cue of trackData.cueQueue) {
|
|
assert(cue.timestamp <= until);
|
|
assert(trackData.lastCueEndTimestamp <= cue.timestamp + cue.duration);
|
|
timestamps.add(Math.max(cue.timestamp, trackData.lastCueEndTimestamp));
|
|
timestamps.add(cue.timestamp + cue.duration);
|
|
}
|
|
const sortedTimestamps = [...timestamps].sort((a, b) => a - b);
|
|
const sampleStart = sortedTimestamps[0];
|
|
const sampleEnd = sortedTimestamps[1] ?? sampleStart;
|
|
if (until < sampleEnd) {
|
|
break;
|
|
}
|
|
if (trackData.lastCueEndTimestamp < sampleStart) {
|
|
this.auxWriter.seek(0);
|
|
const box2 = vtte();
|
|
this.auxBoxWriter.writeBox(box2);
|
|
const body2 = this.auxWriter.getSlice(0, this.auxWriter.getPos());
|
|
const sample2 = this.createSampleForTrack(
|
|
trackData,
|
|
body2,
|
|
trackData.lastCueEndTimestamp,
|
|
sampleStart - trackData.lastCueEndTimestamp,
|
|
"key"
|
|
);
|
|
await this.registerSample(trackData, sample2);
|
|
trackData.lastCueEndTimestamp = sampleStart;
|
|
}
|
|
this.auxWriter.seek(0);
|
|
for (let i = 0; i < trackData.cueQueue.length; i++) {
|
|
const cue = trackData.cueQueue[i];
|
|
if (cue.timestamp >= sampleEnd) {
|
|
break;
|
|
}
|
|
inlineTimestampRegex.lastIndex = 0;
|
|
const containsTimestamp = inlineTimestampRegex.test(cue.text);
|
|
const endTimestamp = cue.timestamp + cue.duration;
|
|
let sourceId = trackData.cueToSourceId.get(cue);
|
|
if (sourceId === void 0 && sampleEnd < endTimestamp) {
|
|
sourceId = trackData.nextSourceId++;
|
|
trackData.cueToSourceId.set(cue, sourceId);
|
|
}
|
|
if (cue.notes) {
|
|
const box3 = vtta(cue.notes);
|
|
this.auxBoxWriter.writeBox(box3);
|
|
}
|
|
const box2 = vttc(
|
|
cue.text,
|
|
containsTimestamp ? sampleStart : null,
|
|
cue.identifier ?? null,
|
|
cue.settings ?? null,
|
|
sourceId ?? null
|
|
);
|
|
this.auxBoxWriter.writeBox(box2);
|
|
if (endTimestamp === sampleEnd) {
|
|
trackData.cueQueue.splice(i--, 1);
|
|
}
|
|
}
|
|
const body = this.auxWriter.getSlice(0, this.auxWriter.getPos());
|
|
const sample = this.createSampleForTrack(trackData, body, sampleStart, sampleEnd - sampleStart, "key");
|
|
await this.registerSample(trackData, sample);
|
|
trackData.lastCueEndTimestamp = sampleEnd;
|
|
}
|
|
}
|
|
createSampleForTrack(trackData, data, timestamp, duration, type) {
|
|
const sample = {
|
|
timestamp,
|
|
decodeTimestamp: timestamp,
|
|
// This may be refined later
|
|
duration,
|
|
data,
|
|
size: data.byteLength,
|
|
type,
|
|
timescaleUnitsToNextSample: intoTimescale(duration, trackData.timescale)
|
|
// Will be refined
|
|
};
|
|
return sample;
|
|
}
|
|
processTimestamps(trackData, nextSample) {
|
|
if (trackData.timestampProcessingQueue.length === 0) {
|
|
return;
|
|
}
|
|
if (trackData.type === "audio" && trackData.info.requiresPcmTransformation) {
|
|
let totalDuration = 0;
|
|
for (let i = 0; i < trackData.timestampProcessingQueue.length; i++) {
|
|
const sample = trackData.timestampProcessingQueue[i];
|
|
const duration = intoTimescale(sample.duration, trackData.timescale);
|
|
totalDuration += duration;
|
|
}
|
|
if (trackData.timeToSampleTable.length === 0) {
|
|
trackData.timeToSampleTable.push({
|
|
sampleCount: totalDuration,
|
|
sampleDelta: 1
|
|
});
|
|
} else {
|
|
const lastEntry = last(trackData.timeToSampleTable);
|
|
lastEntry.sampleCount += totalDuration;
|
|
}
|
|
trackData.timestampProcessingQueue.length = 0;
|
|
return;
|
|
}
|
|
const sortedTimestamps = trackData.timestampProcessingQueue.map((x) => x.timestamp).sort((a, b) => a - b);
|
|
for (let i = 0; i < trackData.timestampProcessingQueue.length; i++) {
|
|
const sample = trackData.timestampProcessingQueue[i];
|
|
sample.decodeTimestamp = sortedTimestamps[i];
|
|
if (!this.isFragmented && trackData.lastTimescaleUnits === null) {
|
|
sample.decodeTimestamp = 0;
|
|
}
|
|
const sampleCompositionTimeOffset = intoTimescale(sample.timestamp - sample.decodeTimestamp, trackData.timescale);
|
|
const durationInTimescale = intoTimescale(sample.duration, trackData.timescale);
|
|
if (trackData.lastTimescaleUnits !== null) {
|
|
assert(trackData.lastSample);
|
|
const timescaleUnits = intoTimescale(sample.decodeTimestamp, trackData.timescale, false);
|
|
const delta = Math.round(timescaleUnits - trackData.lastTimescaleUnits);
|
|
assert(delta >= 0);
|
|
trackData.lastTimescaleUnits += delta;
|
|
trackData.lastSample.timescaleUnitsToNextSample = delta;
|
|
if (!this.isFragmented) {
|
|
let lastTableEntry = last(trackData.timeToSampleTable);
|
|
assert(lastTableEntry);
|
|
if (lastTableEntry.sampleCount === 1) {
|
|
lastTableEntry.sampleDelta = delta;
|
|
const entryBefore = trackData.timeToSampleTable[trackData.timeToSampleTable.length - 2];
|
|
if (entryBefore && entryBefore.sampleDelta === delta) {
|
|
entryBefore.sampleCount++;
|
|
trackData.timeToSampleTable.pop();
|
|
lastTableEntry = entryBefore;
|
|
}
|
|
} else if (lastTableEntry.sampleDelta !== delta) {
|
|
lastTableEntry.sampleCount--;
|
|
trackData.timeToSampleTable.push(lastTableEntry = {
|
|
sampleCount: 1,
|
|
sampleDelta: delta
|
|
});
|
|
}
|
|
if (lastTableEntry.sampleDelta === durationInTimescale) {
|
|
lastTableEntry.sampleCount++;
|
|
} else {
|
|
trackData.timeToSampleTable.push({
|
|
sampleCount: 1,
|
|
sampleDelta: durationInTimescale
|
|
});
|
|
}
|
|
const lastCompositionTimeOffsetTableEntry = last(trackData.compositionTimeOffsetTable);
|
|
assert(lastCompositionTimeOffsetTableEntry);
|
|
if (lastCompositionTimeOffsetTableEntry.sampleCompositionTimeOffset === sampleCompositionTimeOffset) {
|
|
lastCompositionTimeOffsetTableEntry.sampleCount++;
|
|
} else {
|
|
trackData.compositionTimeOffsetTable.push({
|
|
sampleCount: 1,
|
|
sampleCompositionTimeOffset
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
trackData.lastTimescaleUnits = intoTimescale(sample.decodeTimestamp, trackData.timescale, false);
|
|
if (!this.isFragmented) {
|
|
trackData.timeToSampleTable.push({
|
|
sampleCount: 1,
|
|
sampleDelta: durationInTimescale
|
|
});
|
|
trackData.compositionTimeOffsetTable.push({
|
|
sampleCount: 1,
|
|
sampleCompositionTimeOffset
|
|
});
|
|
}
|
|
}
|
|
trackData.lastSample = sample;
|
|
}
|
|
trackData.timestampProcessingQueue.length = 0;
|
|
assert(trackData.lastSample);
|
|
assert(trackData.lastTimescaleUnits !== null);
|
|
if (nextSample !== void 0 && trackData.lastSample.timescaleUnitsToNextSample === 0) {
|
|
assert(nextSample.type === "key");
|
|
const timescaleUnits = intoTimescale(nextSample.timestamp, trackData.timescale, false);
|
|
const delta = Math.round(timescaleUnits - trackData.lastTimescaleUnits);
|
|
trackData.lastSample.timescaleUnitsToNextSample = delta;
|
|
}
|
|
}
|
|
async registerSample(trackData, sample) {
|
|
if (sample.type === "key") {
|
|
this.processTimestamps(trackData, sample);
|
|
}
|
|
trackData.timestampProcessingQueue.push(sample);
|
|
if (this.isFragmented) {
|
|
trackData.sampleQueue.push(sample);
|
|
await this.interleaveSamples();
|
|
} else if (this.fastStart === "reserve") {
|
|
await this.registerSampleFastStartReserve(trackData, sample);
|
|
} else {
|
|
await this.addSampleToTrack(trackData, sample);
|
|
}
|
|
}
|
|
async addSampleToTrack(trackData, sample) {
|
|
if (!this.isFragmented) {
|
|
trackData.samples.push(sample);
|
|
if (this.fastStart === "reserve") {
|
|
const maximumPacketCount = trackData.track.metadata.maximumPacketCount;
|
|
assert(maximumPacketCount !== void 0);
|
|
if (trackData.samples.length > maximumPacketCount) {
|
|
throw new Error(
|
|
`Track #${trackData.track.id} has already reached the maximum packet count (${maximumPacketCount}). Either add less packets or increase the maximum packet count.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
let beginNewChunk = false;
|
|
if (!trackData.currentChunk) {
|
|
beginNewChunk = true;
|
|
} else {
|
|
trackData.currentChunk.startTimestamp = Math.min(
|
|
trackData.currentChunk.startTimestamp,
|
|
sample.timestamp
|
|
);
|
|
const currentChunkDuration = sample.timestamp - trackData.currentChunk.startTimestamp;
|
|
if (this.isFragmented) {
|
|
const keyFrameQueuedEverywhere = this.trackDatas.every((otherTrackData) => {
|
|
if (trackData === otherTrackData) {
|
|
return sample.type === "key";
|
|
}
|
|
const firstQueuedSample = otherTrackData.sampleQueue[0];
|
|
if (firstQueuedSample) {
|
|
return firstQueuedSample.type === "key";
|
|
}
|
|
return otherTrackData.track.source._closed;
|
|
});
|
|
if (currentChunkDuration >= this.minimumFragmentDuration && keyFrameQueuedEverywhere && sample.timestamp > this.maxWrittenTimestamp) {
|
|
beginNewChunk = true;
|
|
await this.finalizeFragment();
|
|
}
|
|
} else {
|
|
beginNewChunk = currentChunkDuration >= 0.5;
|
|
}
|
|
}
|
|
if (beginNewChunk) {
|
|
if (trackData.currentChunk) {
|
|
await this.finalizeCurrentChunk(trackData);
|
|
}
|
|
trackData.currentChunk = {
|
|
startTimestamp: sample.timestamp,
|
|
samples: [],
|
|
offset: null,
|
|
moofOffset: null
|
|
};
|
|
}
|
|
assert(trackData.currentChunk);
|
|
trackData.currentChunk.samples.push(sample);
|
|
if (this.isFragmented) {
|
|
this.maxWrittenTimestamp = Math.max(this.maxWrittenTimestamp, sample.timestamp);
|
|
}
|
|
}
|
|
async finalizeCurrentChunk(trackData) {
|
|
assert(!this.isFragmented);
|
|
if (!trackData.currentChunk) return;
|
|
trackData.finalizedChunks.push(trackData.currentChunk);
|
|
this.finalizedChunks.push(trackData.currentChunk);
|
|
let sampleCount = trackData.currentChunk.samples.length;
|
|
if (trackData.type === "audio" && trackData.info.requiresPcmTransformation) {
|
|
sampleCount = trackData.currentChunk.samples.reduce((acc, sample) => acc + intoTimescale(sample.duration, trackData.timescale), 0);
|
|
}
|
|
if (trackData.compactlyCodedChunkTable.length === 0 || last(trackData.compactlyCodedChunkTable).samplesPerChunk !== sampleCount) {
|
|
trackData.compactlyCodedChunkTable.push({
|
|
firstChunk: trackData.finalizedChunks.length,
|
|
// 1-indexed
|
|
samplesPerChunk: sampleCount
|
|
});
|
|
}
|
|
if (this.fastStart === "in-memory") {
|
|
trackData.currentChunk.offset = 0;
|
|
return;
|
|
}
|
|
trackData.currentChunk.offset = this.writer.getPos();
|
|
for (const sample of trackData.currentChunk.samples) {
|
|
assert(sample.data);
|
|
this.writer.write(sample.data);
|
|
sample.data = null;
|
|
}
|
|
await this.writer.flush();
|
|
}
|
|
async interleaveSamples(isFinalCall = false) {
|
|
assert(this.isFragmented);
|
|
if (!isFinalCall && !this.allTracksAreKnown()) {
|
|
return;
|
|
}
|
|
outer:
|
|
while (true) {
|
|
let trackWithMinTimestamp = null;
|
|
let minTimestamp = Infinity;
|
|
for (const trackData of this.trackDatas) {
|
|
if (!isFinalCall && trackData.sampleQueue.length === 0 && !trackData.track.source._closed) {
|
|
break outer;
|
|
}
|
|
if (trackData.sampleQueue.length > 0 && trackData.sampleQueue[0].timestamp < minTimestamp) {
|
|
trackWithMinTimestamp = trackData;
|
|
minTimestamp = trackData.sampleQueue[0].timestamp;
|
|
}
|
|
}
|
|
if (!trackWithMinTimestamp) {
|
|
break;
|
|
}
|
|
const sample = trackWithMinTimestamp.sampleQueue.shift();
|
|
await this.addSampleToTrack(trackWithMinTimestamp, sample);
|
|
}
|
|
}
|
|
async finalizeFragment(flushWriter = true) {
|
|
assert(this.isFragmented);
|
|
const fragmentNumber = this.nextFragmentNumber++;
|
|
if (fragmentNumber === 1) {
|
|
if (this.format._options.onMoov) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
const movieBox = moov(this);
|
|
this.boxWriter.writeBox(movieBox);
|
|
if (this.format._options.onMoov) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onMoov(data, start);
|
|
}
|
|
}
|
|
const tracksInFragment = this.trackDatas.filter((x) => x.currentChunk);
|
|
const moofBox = moof(fragmentNumber, tracksInFragment);
|
|
const moofOffset = this.writer.getPos();
|
|
const mdatStartPos = moofOffset + this.boxWriter.measureBox(moofBox);
|
|
let currentPos = mdatStartPos + MIN_BOX_HEADER_SIZE;
|
|
let fragmentStartTimestamp = Infinity;
|
|
for (const trackData of tracksInFragment) {
|
|
trackData.currentChunk.offset = currentPos;
|
|
trackData.currentChunk.moofOffset = moofOffset;
|
|
for (const sample of trackData.currentChunk.samples) {
|
|
currentPos += sample.size;
|
|
}
|
|
fragmentStartTimestamp = Math.min(fragmentStartTimestamp, trackData.currentChunk.startTimestamp);
|
|
}
|
|
const mdatSize = currentPos - mdatStartPos;
|
|
const needsLargeMdatSize = mdatSize >= 2 ** 32;
|
|
if (needsLargeMdatSize) {
|
|
for (const trackData of tracksInFragment) {
|
|
trackData.currentChunk.offset += MAX_BOX_HEADER_SIZE - MIN_BOX_HEADER_SIZE;
|
|
}
|
|
}
|
|
if (this.format._options.onMoof) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
const newMoofBox = moof(fragmentNumber, tracksInFragment);
|
|
this.boxWriter.writeBox(newMoofBox);
|
|
if (this.format._options.onMoof) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onMoof(data, start, fragmentStartTimestamp);
|
|
}
|
|
assert(this.writer.getPos() === mdatStartPos);
|
|
if (this.format._options.onMdat) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
const mdatBox = mdat(needsLargeMdatSize);
|
|
mdatBox.size = mdatSize;
|
|
this.boxWriter.writeBox(mdatBox);
|
|
this.writer.seek(mdatStartPos + (needsLargeMdatSize ? MAX_BOX_HEADER_SIZE : MIN_BOX_HEADER_SIZE));
|
|
for (const trackData of tracksInFragment) {
|
|
for (const sample of trackData.currentChunk.samples) {
|
|
this.writer.write(sample.data);
|
|
sample.data = null;
|
|
}
|
|
}
|
|
if (this.format._options.onMdat) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onMdat(data, start);
|
|
}
|
|
for (const trackData of tracksInFragment) {
|
|
trackData.finalizedChunks.push(trackData.currentChunk);
|
|
this.finalizedChunks.push(trackData.currentChunk);
|
|
trackData.currentChunk = null;
|
|
}
|
|
if (flushWriter) {
|
|
await this.writer.flush();
|
|
}
|
|
}
|
|
async registerSampleFastStartReserve(trackData, sample) {
|
|
if (this.allTracksAreKnown()) {
|
|
if (!this.mdat) {
|
|
const moovBox = moov(this);
|
|
const moovSize = this.boxWriter.measureBox(moovBox);
|
|
const reservedSize = moovSize + this.computeSampleTableSizeUpperBound() + 4096;
|
|
assert(this.ftypSize !== null);
|
|
this.writer.seek(this.ftypSize + reservedSize);
|
|
if (this.format._options.onMdat) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
this.mdat = mdat(true);
|
|
this.boxWriter.writeBox(this.mdat);
|
|
for (const trackData2 of this.trackDatas) {
|
|
for (const sample2 of trackData2.sampleQueue) {
|
|
await this.addSampleToTrack(trackData2, sample2);
|
|
}
|
|
trackData2.sampleQueue.length = 0;
|
|
}
|
|
}
|
|
await this.addSampleToTrack(trackData, sample);
|
|
} else {
|
|
trackData.sampleQueue.push(sample);
|
|
}
|
|
}
|
|
computeSampleTableSizeUpperBound() {
|
|
assert(this.fastStart === "reserve");
|
|
let upperBound = 0;
|
|
for (const trackData of this.trackDatas) {
|
|
const n = trackData.track.metadata.maximumPacketCount;
|
|
assert(n !== void 0);
|
|
upperBound += (4 + 4) * Math.ceil(2 / 3 * n);
|
|
upperBound += 4 * n;
|
|
upperBound += (4 + 4) * Math.ceil(2 / 3 * n);
|
|
upperBound += (4 + 4 + 4) * Math.ceil(2 / 3 * n);
|
|
upperBound += 4 * n;
|
|
upperBound += 8 * n;
|
|
}
|
|
return upperBound;
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
async onTrackClose(track) {
|
|
const release = await this.mutex.acquire();
|
|
if (track.type === "subtitle" && track.source._codec === "webvtt") {
|
|
const trackData = this.trackDatas.find((x) => x.track === track);
|
|
if (trackData) {
|
|
await this.processWebVTTCues(trackData, Infinity);
|
|
}
|
|
}
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
if (this.isFragmented) {
|
|
await this.interleaveSamples();
|
|
}
|
|
release();
|
|
}
|
|
/** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */
|
|
async finalize() {
|
|
const release = await this.mutex.acquire();
|
|
this.allTracksKnown.resolve();
|
|
for (const trackData of this.trackDatas) {
|
|
if (trackData.type === "subtitle" && trackData.track.source._codec === "webvtt") {
|
|
await this.processWebVTTCues(trackData, Infinity);
|
|
}
|
|
}
|
|
if (this.isFragmented) {
|
|
await this.interleaveSamples(true);
|
|
for (const trackData of this.trackDatas) {
|
|
this.processTimestamps(trackData);
|
|
}
|
|
await this.finalizeFragment(false);
|
|
} else {
|
|
for (const trackData of this.trackDatas) {
|
|
this.processTimestamps(trackData);
|
|
await this.finalizeCurrentChunk(trackData);
|
|
}
|
|
}
|
|
if (this.fastStart === "in-memory") {
|
|
this.mdat = mdat(false);
|
|
let mdatSize;
|
|
for (let i = 0; i < 2; i++) {
|
|
const movieBox2 = moov(this);
|
|
const movieBoxSize = this.boxWriter.measureBox(movieBox2);
|
|
mdatSize = this.boxWriter.measureBox(this.mdat);
|
|
let currentChunkPos = this.writer.getPos() + movieBoxSize + mdatSize;
|
|
for (const chunk of this.finalizedChunks) {
|
|
chunk.offset = currentChunkPos;
|
|
for (const { data } of chunk.samples) {
|
|
assert(data);
|
|
currentChunkPos += data.byteLength;
|
|
mdatSize += data.byteLength;
|
|
}
|
|
}
|
|
if (currentChunkPos < 2 ** 32) break;
|
|
if (mdatSize >= 2 ** 32) this.mdat.largeSize = true;
|
|
}
|
|
if (this.format._options.onMoov) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
const movieBox = moov(this);
|
|
this.boxWriter.writeBox(movieBox);
|
|
if (this.format._options.onMoov) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onMoov(data, start);
|
|
}
|
|
if (this.format._options.onMdat) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
this.mdat.size = mdatSize;
|
|
this.boxWriter.writeBox(this.mdat);
|
|
for (const chunk of this.finalizedChunks) {
|
|
for (const sample of chunk.samples) {
|
|
assert(sample.data);
|
|
this.writer.write(sample.data);
|
|
sample.data = null;
|
|
}
|
|
}
|
|
if (this.format._options.onMdat) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onMdat(data, start);
|
|
}
|
|
} else if (this.isFragmented) {
|
|
const startPos = this.writer.getPos();
|
|
const mfraBox = mfra(this.trackDatas);
|
|
this.boxWriter.writeBox(mfraBox);
|
|
const mfraBoxSize = this.writer.getPos() - startPos;
|
|
this.writer.seek(this.writer.getPos() - 4);
|
|
this.boxWriter.writeU32(mfraBoxSize);
|
|
} else {
|
|
assert(this.mdat);
|
|
const mdatPos = this.boxWriter.offsets.get(this.mdat);
|
|
assert(mdatPos !== void 0);
|
|
const mdatSize = this.writer.getPos() - mdatPos;
|
|
this.mdat.size = mdatSize;
|
|
this.mdat.largeSize = mdatSize >= 2 ** 32;
|
|
this.boxWriter.patchBox(this.mdat);
|
|
if (this.format._options.onMdat) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onMdat(data, start);
|
|
}
|
|
const movieBox = moov(this);
|
|
if (this.fastStart === "reserve") {
|
|
assert(this.ftypSize !== null);
|
|
this.writer.seek(this.ftypSize);
|
|
if (this.format._options.onMoov) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
this.boxWriter.writeBox(movieBox);
|
|
const remainingSpace = this.boxWriter.offsets.get(this.mdat) - this.writer.getPos();
|
|
this.boxWriter.writeBox(free(remainingSpace));
|
|
} else {
|
|
if (this.format._options.onMoov) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
this.boxWriter.writeBox(movieBox);
|
|
}
|
|
if (this.format._options.onMoov) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onMoov(data, start);
|
|
}
|
|
}
|
|
release();
|
|
}
|
|
};
|
|
|
|
// src/matroska/matroska-muxer.ts
|
|
var MIN_CLUSTER_TIMESTAMP_MS = -(2 ** 15);
|
|
var MAX_CLUSTER_TIMESTAMP_MS = 2 ** 15 - 1;
|
|
var APP_NAME = "Mediabunny";
|
|
var SEGMENT_SIZE_BYTES = 6;
|
|
var CLUSTER_SIZE_BYTES = 5;
|
|
var TRACK_TYPE_MAP = {
|
|
video: 1,
|
|
audio: 2,
|
|
subtitle: 17
|
|
};
|
|
var MatroskaMuxer = class extends Muxer {
|
|
constructor(output, format) {
|
|
super(output);
|
|
this.trackDatas = [];
|
|
this.allTracksKnown = promiseWithResolvers();
|
|
this.segment = null;
|
|
this.segmentInfo = null;
|
|
this.seekHead = null;
|
|
this.tracksElement = null;
|
|
this.tagsElement = null;
|
|
this.attachmentsElement = null;
|
|
this.segmentDuration = null;
|
|
this.cues = null;
|
|
this.currentCluster = null;
|
|
this.currentClusterStartMsTimestamp = null;
|
|
this.currentClusterMaxMsTimestamp = null;
|
|
this.trackDatasInCurrentCluster = /* @__PURE__ */ new Map();
|
|
this.duration = 0;
|
|
this.writer = output._writer;
|
|
this.format = format;
|
|
this.ebmlWriter = new EBMLWriter(this.writer);
|
|
if (this.format._options.appendOnly) {
|
|
this.writer.ensureMonotonicity = true;
|
|
}
|
|
}
|
|
async start() {
|
|
const release = await this.mutex.acquire();
|
|
this.writeEBMLHeader();
|
|
this.createSegmentInfo();
|
|
this.createCues();
|
|
await this.writer.flush();
|
|
release();
|
|
}
|
|
writeEBMLHeader() {
|
|
if (this.format._options.onEbmlHeader) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
const ebmlHeader = { id: 440786851 /* EBML */, data: [
|
|
{ id: 17030 /* EBMLVersion */, data: 1 },
|
|
{ id: 17143 /* EBMLReadVersion */, data: 1 },
|
|
{ id: 17138 /* EBMLMaxIDLength */, data: 4 },
|
|
{ id: 17139 /* EBMLMaxSizeLength */, data: 8 },
|
|
{ id: 17026 /* DocType */, data: this.format instanceof WebMOutputFormat ? "webm" : "matroska" },
|
|
{ id: 17031 /* DocTypeVersion */, data: 2 },
|
|
{ id: 17029 /* DocTypeReadVersion */, data: 2 }
|
|
] };
|
|
this.ebmlWriter.writeEBML(ebmlHeader);
|
|
if (this.format._options.onEbmlHeader) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onEbmlHeader(data, start);
|
|
}
|
|
}
|
|
/**
|
|
* Creates a SeekHead element which is positioned near the start of the file and allows the media player to seek to
|
|
* relevant sections more easily. Since we don't know the positions of those sections yet, we'll set them later.
|
|
*/
|
|
maybeCreateSeekHead(writeOffsets) {
|
|
if (this.format._options.appendOnly) {
|
|
return;
|
|
}
|
|
const kaxCues = new Uint8Array([28, 83, 187, 107]);
|
|
const kaxInfo = new Uint8Array([21, 73, 169, 102]);
|
|
const kaxTracks = new Uint8Array([22, 84, 174, 107]);
|
|
const kaxAttachments = new Uint8Array([25, 65, 164, 105]);
|
|
const kaxTags = new Uint8Array([18, 84, 195, 103]);
|
|
const seekHead = { id: 290298740 /* SeekHead */, data: [
|
|
{ id: 19899 /* Seek */, data: [
|
|
{ id: 21419 /* SeekID */, data: kaxCues },
|
|
{
|
|
id: 21420 /* SeekPosition */,
|
|
size: 5,
|
|
data: writeOffsets ? this.ebmlWriter.offsets.get(this.cues) - this.segmentDataOffset : 0
|
|
}
|
|
] },
|
|
{ id: 19899 /* Seek */, data: [
|
|
{ id: 21419 /* SeekID */, data: kaxInfo },
|
|
{
|
|
id: 21420 /* SeekPosition */,
|
|
size: 5,
|
|
data: writeOffsets ? this.ebmlWriter.offsets.get(this.segmentInfo) - this.segmentDataOffset : 0
|
|
}
|
|
] },
|
|
{ id: 19899 /* Seek */, data: [
|
|
{ id: 21419 /* SeekID */, data: kaxTracks },
|
|
{
|
|
id: 21420 /* SeekPosition */,
|
|
size: 5,
|
|
data: writeOffsets ? this.ebmlWriter.offsets.get(this.tracksElement) - this.segmentDataOffset : 0
|
|
}
|
|
] },
|
|
this.attachmentsElement ? { id: 19899 /* Seek */, data: [
|
|
{ id: 21419 /* SeekID */, data: kaxAttachments },
|
|
{
|
|
id: 21420 /* SeekPosition */,
|
|
size: 5,
|
|
data: writeOffsets ? this.ebmlWriter.offsets.get(this.attachmentsElement) - this.segmentDataOffset : 0
|
|
}
|
|
] } : null,
|
|
this.tagsElement ? { id: 19899 /* Seek */, data: [
|
|
{ id: 21419 /* SeekID */, data: kaxTags },
|
|
{
|
|
id: 21420 /* SeekPosition */,
|
|
size: 5,
|
|
data: writeOffsets ? this.ebmlWriter.offsets.get(this.tagsElement) - this.segmentDataOffset : 0
|
|
}
|
|
] } : null
|
|
] };
|
|
this.seekHead = seekHead;
|
|
}
|
|
createSegmentInfo() {
|
|
const segmentDuration = { id: 17545 /* Duration */, data: new EBMLFloat64(0) };
|
|
this.segmentDuration = segmentDuration;
|
|
const segmentInfo = { id: 357149030 /* Info */, data: [
|
|
{ id: 2807729 /* TimestampScale */, data: 1e6 },
|
|
{ id: 19840 /* MuxingApp */, data: APP_NAME },
|
|
{ id: 22337 /* WritingApp */, data: APP_NAME },
|
|
!this.format._options.appendOnly ? segmentDuration : null
|
|
] };
|
|
this.segmentInfo = segmentInfo;
|
|
}
|
|
createTracks() {
|
|
const tracksElement = { id: 374648427 /* Tracks */, data: [] };
|
|
this.tracksElement = tracksElement;
|
|
for (const trackData of this.trackDatas) {
|
|
const codecId = CODEC_STRING_MAP[trackData.track.source._codec];
|
|
assert(codecId);
|
|
let seekPreRollNs = 0;
|
|
if (trackData.type === "audio" && trackData.track.source._codec === "opus") {
|
|
seekPreRollNs = 1e6 * 80;
|
|
const description = trackData.info.decoderConfig.description;
|
|
if (description) {
|
|
const bytes2 = toUint8Array(description);
|
|
const header = parseOpusIdentificationHeader(bytes2);
|
|
seekPreRollNs = Math.round(1e9 * (header.preSkip / OPUS_SAMPLE_RATE));
|
|
}
|
|
}
|
|
tracksElement.data.push({ id: 174 /* TrackEntry */, data: [
|
|
{ id: 215 /* TrackNumber */, data: trackData.track.id },
|
|
{ id: 29637 /* TrackUID */, data: trackData.track.id },
|
|
{ id: 131 /* TrackType */, data: TRACK_TYPE_MAP[trackData.type] },
|
|
trackData.track.metadata.disposition?.default === false ? { id: 136 /* FlagDefault */, data: 0 } : null,
|
|
trackData.track.metadata.disposition?.forced ? { id: 21930 /* FlagForced */, data: 1 } : null,
|
|
trackData.track.metadata.disposition?.hearingImpaired ? { id: 21931 /* FlagHearingImpaired */, data: 1 } : null,
|
|
trackData.track.metadata.disposition?.visuallyImpaired ? { id: 21932 /* FlagVisualImpaired */, data: 1 } : null,
|
|
trackData.track.metadata.disposition?.original ? { id: 21934 /* FlagOriginal */, data: 1 } : null,
|
|
trackData.track.metadata.disposition?.commentary ? { id: 21935 /* FlagCommentary */, data: 1 } : null,
|
|
{ id: 156 /* FlagLacing */, data: 0 },
|
|
{ id: 2274716 /* Language */, data: trackData.track.metadata.languageCode ?? UNDETERMINED_LANGUAGE },
|
|
{ id: 134 /* CodecID */, data: codecId },
|
|
{ id: 22186 /* CodecDelay */, data: 0 },
|
|
{ id: 22203 /* SeekPreRoll */, data: seekPreRollNs },
|
|
trackData.track.metadata.name !== void 0 ? { id: 21358 /* Name */, data: new EBMLUnicodeString(trackData.track.metadata.name) } : null,
|
|
trackData.type === "video" ? this.videoSpecificTrackInfo(trackData) : null,
|
|
trackData.type === "audio" ? this.audioSpecificTrackInfo(trackData) : null,
|
|
trackData.type === "subtitle" ? this.subtitleSpecificTrackInfo(trackData) : null
|
|
] });
|
|
}
|
|
}
|
|
videoSpecificTrackInfo(trackData) {
|
|
const { frameRate, rotation } = trackData.track.metadata;
|
|
const elements = [
|
|
trackData.info.decoderConfig.description ? {
|
|
id: 25506 /* CodecPrivate */,
|
|
data: toUint8Array(trackData.info.decoderConfig.description)
|
|
} : null,
|
|
frameRate ? {
|
|
id: 2352003 /* DefaultDuration */,
|
|
data: 1e9 / frameRate
|
|
} : null
|
|
];
|
|
const flippedRotation = rotation ? normalizeRotation(-rotation) : 0;
|
|
const colorSpace = trackData.info.decoderConfig.colorSpace;
|
|
const videoElement = { id: 224 /* Video */, data: [
|
|
{ id: 176 /* PixelWidth */, data: trackData.info.width },
|
|
{ id: 186 /* PixelHeight */, data: trackData.info.height },
|
|
trackData.info.alphaMode ? { id: 21440 /* AlphaMode */, data: 1 } : null,
|
|
colorSpaceIsComplete(colorSpace) ? {
|
|
id: 21936 /* Colour */,
|
|
data: [
|
|
{
|
|
id: 21937 /* MatrixCoefficients */,
|
|
data: MATRIX_COEFFICIENTS_MAP[colorSpace.matrix]
|
|
},
|
|
{
|
|
id: 21946 /* TransferCharacteristics */,
|
|
data: TRANSFER_CHARACTERISTICS_MAP[colorSpace.transfer]
|
|
},
|
|
{
|
|
id: 21947 /* Primaries */,
|
|
data: COLOR_PRIMARIES_MAP[colorSpace.primaries]
|
|
},
|
|
{
|
|
id: 21945 /* Range */,
|
|
data: colorSpace.fullRange ? 2 : 1
|
|
}
|
|
]
|
|
} : null,
|
|
flippedRotation ? {
|
|
id: 30320 /* Projection */,
|
|
data: [
|
|
{
|
|
id: 30321 /* ProjectionType */,
|
|
data: 0
|
|
// rectangular
|
|
},
|
|
{
|
|
id: 30325 /* ProjectionPoseRoll */,
|
|
data: new EBMLFloat32((flippedRotation + 180) % 360 - 180)
|
|
// [0, 270] -> [-180, 90]
|
|
}
|
|
]
|
|
} : null
|
|
] };
|
|
elements.push(videoElement);
|
|
return elements;
|
|
}
|
|
audioSpecificTrackInfo(trackData) {
|
|
const pcmInfo = PCM_AUDIO_CODECS.includes(trackData.track.source._codec) ? parsePcmCodec(trackData.track.source._codec) : null;
|
|
return [
|
|
trackData.info.decoderConfig.description ? {
|
|
id: 25506 /* CodecPrivate */,
|
|
data: toUint8Array(trackData.info.decoderConfig.description)
|
|
} : null,
|
|
{ id: 225 /* Audio */, data: [
|
|
{ id: 181 /* SamplingFrequency */, data: new EBMLFloat32(trackData.info.sampleRate) },
|
|
{ id: 159 /* Channels */, data: trackData.info.numberOfChannels },
|
|
pcmInfo ? { id: 25188 /* BitDepth */, data: 8 * pcmInfo.sampleSize } : null
|
|
] }
|
|
];
|
|
}
|
|
subtitleSpecificTrackInfo(trackData) {
|
|
return [
|
|
{ id: 25506 /* CodecPrivate */, data: textEncoder.encode(trackData.info.config.description) }
|
|
];
|
|
}
|
|
maybeCreateTags() {
|
|
const simpleTags = [];
|
|
const addSimpleTag = (key, value) => {
|
|
simpleTags.push({ id: 26568 /* SimpleTag */, data: [
|
|
{ id: 17827 /* TagName */, data: new EBMLUnicodeString(key) },
|
|
typeof value === "string" ? { id: 17543 /* TagString */, data: new EBMLUnicodeString(value) } : { id: 17541 /* TagBinary */, data: value }
|
|
] });
|
|
};
|
|
const metadataTags = this.output._metadataTags;
|
|
const writtenTags = /* @__PURE__ */ new Set();
|
|
for (const { key, value } of keyValueIterator(metadataTags)) {
|
|
switch (key) {
|
|
case "title":
|
|
{
|
|
addSimpleTag("TITLE", value);
|
|
writtenTags.add("TITLE");
|
|
}
|
|
;
|
|
break;
|
|
case "description":
|
|
{
|
|
addSimpleTag("DESCRIPTION", value);
|
|
writtenTags.add("DESCRIPTION");
|
|
}
|
|
;
|
|
break;
|
|
case "artist":
|
|
{
|
|
addSimpleTag("ARTIST", value);
|
|
writtenTags.add("ARTIST");
|
|
}
|
|
;
|
|
break;
|
|
case "album":
|
|
{
|
|
addSimpleTag("ALBUM", value);
|
|
writtenTags.add("ALBUM");
|
|
}
|
|
;
|
|
break;
|
|
case "albumArtist":
|
|
{
|
|
addSimpleTag("ALBUM_ARTIST", value);
|
|
writtenTags.add("ALBUM_ARTIST");
|
|
}
|
|
;
|
|
break;
|
|
case "genre":
|
|
{
|
|
addSimpleTag("GENRE", value);
|
|
writtenTags.add("GENRE");
|
|
}
|
|
;
|
|
break;
|
|
case "comment":
|
|
{
|
|
addSimpleTag("COMMENT", value);
|
|
writtenTags.add("COMMENT");
|
|
}
|
|
;
|
|
break;
|
|
case "lyrics":
|
|
{
|
|
addSimpleTag("LYRICS", value);
|
|
writtenTags.add("LYRICS");
|
|
}
|
|
;
|
|
break;
|
|
case "date":
|
|
{
|
|
addSimpleTag("DATE", value.toISOString().slice(0, 10));
|
|
writtenTags.add("DATE");
|
|
}
|
|
;
|
|
break;
|
|
case "trackNumber":
|
|
{
|
|
const string = metadataTags.tracksTotal !== void 0 ? `${value}/${metadataTags.tracksTotal}` : value.toString();
|
|
addSimpleTag("PART_NUMBER", string);
|
|
writtenTags.add("PART_NUMBER");
|
|
}
|
|
;
|
|
break;
|
|
case "discNumber":
|
|
{
|
|
const string = metadataTags.discsTotal !== void 0 ? `${value}/${metadataTags.discsTotal}` : value.toString();
|
|
addSimpleTag("DISC", string);
|
|
writtenTags.add("DISC");
|
|
}
|
|
;
|
|
break;
|
|
case "tracksTotal":
|
|
case "discsTotal":
|
|
{
|
|
}
|
|
;
|
|
break;
|
|
case "images":
|
|
case "raw":
|
|
{
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
assertNever(key);
|
|
}
|
|
}
|
|
if (metadataTags.raw) {
|
|
for (const key in metadataTags.raw) {
|
|
const value = metadataTags.raw[key];
|
|
if (value == null || writtenTags.has(key)) {
|
|
continue;
|
|
}
|
|
if (typeof value === "string" || value instanceof Uint8Array) {
|
|
addSimpleTag(key, value);
|
|
}
|
|
}
|
|
}
|
|
if (simpleTags.length === 0) {
|
|
return;
|
|
}
|
|
this.tagsElement = {
|
|
id: 307544935 /* Tags */,
|
|
data: [{ id: 29555 /* Tag */, data: [
|
|
{ id: 25536 /* Targets */, data: [
|
|
{ id: 26826 /* TargetTypeValue */, data: 50 },
|
|
{ id: 25546 /* TargetType */, data: "MOVIE" }
|
|
] },
|
|
...simpleTags
|
|
] }]
|
|
};
|
|
}
|
|
maybeCreateAttachments() {
|
|
const metadataTags = this.output._metadataTags;
|
|
const elements = [];
|
|
const existingFileUids = /* @__PURE__ */ new Set();
|
|
const images = metadataTags.images ?? [];
|
|
for (const image of images) {
|
|
let imageName = image.name;
|
|
if (imageName === void 0) {
|
|
const baseName = image.kind === "coverFront" ? "cover" : image.kind === "coverBack" ? "back" : "image";
|
|
imageName = baseName + (imageMimeTypeToExtension(image.mimeType) ?? "");
|
|
}
|
|
let fileUid;
|
|
while (true) {
|
|
fileUid = 0n;
|
|
for (let i = 0; i < 8; i++) {
|
|
fileUid <<= 8n;
|
|
fileUid |= BigInt(Math.floor(Math.random() * 256));
|
|
}
|
|
if (fileUid !== 0n && !existingFileUids.has(fileUid)) {
|
|
break;
|
|
}
|
|
}
|
|
existingFileUids.add(fileUid);
|
|
elements.push({
|
|
id: 24999 /* AttachedFile */,
|
|
data: [
|
|
image.description !== void 0 ? { id: 18046 /* FileDescription */, data: new EBMLUnicodeString(image.description) } : null,
|
|
{ id: 18030 /* FileName */, data: new EBMLUnicodeString(imageName) },
|
|
{ id: 18016 /* FileMediaType */, data: image.mimeType },
|
|
{ id: 18012 /* FileData */, data: image.data },
|
|
{ id: 18094 /* FileUID */, data: fileUid }
|
|
]
|
|
});
|
|
}
|
|
for (const [key, value] of Object.entries(metadataTags.raw ?? {})) {
|
|
if (!(value instanceof AttachedFile)) {
|
|
continue;
|
|
}
|
|
const keyIsNumeric = /^\d+$/.test(key);
|
|
if (!keyIsNumeric) {
|
|
continue;
|
|
}
|
|
if (images.find((x) => x.mimeType === value.mimeType && uint8ArraysAreEqual(x.data, value.data))) {
|
|
continue;
|
|
}
|
|
elements.push({
|
|
id: 24999 /* AttachedFile */,
|
|
data: [
|
|
value.description !== void 0 ? { id: 18046 /* FileDescription */, data: new EBMLUnicodeString(value.description) } : null,
|
|
{ id: 18030 /* FileName */, data: new EBMLUnicodeString(value.name ?? "") },
|
|
{ id: 18016 /* FileMediaType */, data: value.mimeType ?? "" },
|
|
{ id: 18012 /* FileData */, data: value.data },
|
|
{ id: 18094 /* FileUID */, data: BigInt(key) }
|
|
]
|
|
});
|
|
}
|
|
if (elements.length === 0) {
|
|
return;
|
|
}
|
|
this.attachmentsElement = { id: 423732329 /* Attachments */, data: elements };
|
|
}
|
|
createSegment() {
|
|
this.createTracks();
|
|
this.maybeCreateTags();
|
|
this.maybeCreateAttachments();
|
|
this.maybeCreateSeekHead(false);
|
|
const segment = {
|
|
id: 408125543 /* Segment */,
|
|
size: this.format._options.appendOnly ? -1 : SEGMENT_SIZE_BYTES,
|
|
data: [
|
|
this.seekHead,
|
|
// null if append-only
|
|
this.segmentInfo,
|
|
this.tracksElement,
|
|
// Matroska spec says put this at the end of the file, but I think placing it before the first cluster
|
|
// makes more sense, and FFmpeg agrees (argumentum ad ffmpegum fallacy)
|
|
this.attachmentsElement,
|
|
this.tagsElement
|
|
]
|
|
};
|
|
this.segment = segment;
|
|
if (this.format._options.onSegmentHeader) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
this.ebmlWriter.writeEBML(segment);
|
|
if (this.format._options.onSegmentHeader) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onSegmentHeader(data, start);
|
|
}
|
|
}
|
|
createCues() {
|
|
this.cues = { id: 475249515 /* Cues */, data: [] };
|
|
}
|
|
get segmentDataOffset() {
|
|
assert(this.segment);
|
|
return this.ebmlWriter.dataOffsets.get(this.segment);
|
|
}
|
|
allTracksAreKnown() {
|
|
for (const track of this.output._tracks) {
|
|
if (!track.source._closed && !this.trackDatas.some((x) => x.track === track)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
async getMimeType() {
|
|
await this.allTracksKnown.promise;
|
|
const codecStrings = this.trackDatas.map((trackData) => {
|
|
if (trackData.type === "video") {
|
|
return trackData.info.decoderConfig.codec;
|
|
} else if (trackData.type === "audio") {
|
|
return trackData.info.decoderConfig.codec;
|
|
} else {
|
|
const map = {
|
|
webvtt: "wvtt"
|
|
};
|
|
return map[trackData.track.source._codec];
|
|
}
|
|
});
|
|
return buildMatroskaMimeType({
|
|
isWebM: this.format instanceof WebMOutputFormat,
|
|
hasVideo: this.trackDatas.some((x) => x.type === "video"),
|
|
hasAudio: this.trackDatas.some((x) => x.type === "audio"),
|
|
codecStrings
|
|
});
|
|
}
|
|
getVideoTrackData(track, packet, meta) {
|
|
const existingTrackData = this.trackDatas.find((x) => x.track === track);
|
|
if (existingTrackData) {
|
|
return existingTrackData;
|
|
}
|
|
validateVideoChunkMetadata(meta);
|
|
assert(meta);
|
|
assert(meta.decoderConfig);
|
|
assert(meta.decoderConfig.codedWidth !== void 0);
|
|
assert(meta.decoderConfig.codedHeight !== void 0);
|
|
const newTrackData = {
|
|
track,
|
|
type: "video",
|
|
info: {
|
|
width: meta.decoderConfig.codedWidth,
|
|
height: meta.decoderConfig.codedHeight,
|
|
decoderConfig: meta.decoderConfig,
|
|
alphaMode: !!packet.sideData.alpha
|
|
// The first packet determines if this track has alpha or not
|
|
},
|
|
chunkQueue: [],
|
|
lastWrittenMsTimestamp: null
|
|
};
|
|
if (track.source._codec === "vp9") {
|
|
newTrackData.info.decoderConfig = {
|
|
...newTrackData.info.decoderConfig,
|
|
description: new Uint8Array(
|
|
generateVp9CodecConfigurationFromCodecString(newTrackData.info.decoderConfig.codec)
|
|
)
|
|
};
|
|
} else if (track.source._codec === "av1") {
|
|
newTrackData.info.decoderConfig = {
|
|
...newTrackData.info.decoderConfig,
|
|
description: new Uint8Array(
|
|
generateAv1CodecConfigurationFromCodecString(newTrackData.info.decoderConfig.codec)
|
|
)
|
|
};
|
|
}
|
|
this.trackDatas.push(newTrackData);
|
|
this.trackDatas.sort((a, b) => a.track.id - b.track.id);
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
return newTrackData;
|
|
}
|
|
getAudioTrackData(track, packet, meta) {
|
|
const existingTrackData = this.trackDatas.find((x) => x.track === track);
|
|
if (existingTrackData) {
|
|
return existingTrackData;
|
|
}
|
|
validateAudioChunkMetadata(meta);
|
|
assert(meta);
|
|
assert(meta.decoderConfig);
|
|
const decoderConfig = { ...meta.decoderConfig };
|
|
let requiresAdtsStripping = false;
|
|
if (track.source._codec === "aac" && !decoderConfig.description) {
|
|
const adtsFrame = readAdtsFrameHeader(FileSlice4.tempFromBytes(packet.data));
|
|
if (!adtsFrame) {
|
|
throw new Error(
|
|
"Couldn't parse ADTS header from the AAC packet. Make sure the packets are in ADTS format (as specified in ISO 13818-7) when not providing a description, or provide a description (must be an AudioSpecificConfig as specified in ISO 14496-3) and ensure the packets are raw AAC data."
|
|
);
|
|
}
|
|
const sampleRate = aacFrequencyTable[adtsFrame.samplingFrequencyIndex];
|
|
const numberOfChannels = aacChannelMap[adtsFrame.channelConfiguration];
|
|
if (sampleRate === void 0 || numberOfChannels === void 0) {
|
|
throw new Error("Invalid ADTS frame header.");
|
|
}
|
|
decoderConfig.description = buildAacAudioSpecificConfig({
|
|
objectType: adtsFrame.objectType,
|
|
sampleRate,
|
|
numberOfChannels
|
|
});
|
|
requiresAdtsStripping = true;
|
|
}
|
|
const newTrackData = {
|
|
track,
|
|
type: "audio",
|
|
info: {
|
|
numberOfChannels: meta.decoderConfig.numberOfChannels,
|
|
sampleRate: meta.decoderConfig.sampleRate,
|
|
decoderConfig,
|
|
requiresAdtsStripping
|
|
},
|
|
chunkQueue: [],
|
|
lastWrittenMsTimestamp: null
|
|
};
|
|
this.trackDatas.push(newTrackData);
|
|
this.trackDatas.sort((a, b) => a.track.id - b.track.id);
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
return newTrackData;
|
|
}
|
|
getSubtitleTrackData(track, meta) {
|
|
const existingTrackData = this.trackDatas.find((x) => x.track === track);
|
|
if (existingTrackData) {
|
|
return existingTrackData;
|
|
}
|
|
validateSubtitleMetadata(meta);
|
|
assert(meta);
|
|
assert(meta.config);
|
|
const newTrackData = {
|
|
track,
|
|
type: "subtitle",
|
|
info: {
|
|
config: meta.config
|
|
},
|
|
chunkQueue: [],
|
|
lastWrittenMsTimestamp: null
|
|
};
|
|
this.trackDatas.push(newTrackData);
|
|
this.trackDatas.sort((a, b) => a.track.id - b.track.id);
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
return newTrackData;
|
|
}
|
|
async addEncodedVideoPacket(track, packet, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getVideoTrackData(track, packet, meta);
|
|
const isKeyFrame = packet.type === "key";
|
|
let timestamp = this.validateAndNormalizeTimestamp(trackData.track, packet.timestamp, isKeyFrame);
|
|
let duration = packet.duration;
|
|
if (track.metadata.frameRate !== void 0) {
|
|
timestamp = roundToMultiple(timestamp, 1 / track.metadata.frameRate);
|
|
duration = roundToMultiple(duration, 1 / track.metadata.frameRate);
|
|
}
|
|
const additions = trackData.info.alphaMode ? packet.sideData.alpha ?? null : null;
|
|
const videoChunk = this.createInternalChunk(packet.data, timestamp, duration, packet.type, additions);
|
|
if (track.source._codec === "vp9") this.fixVP9ColorSpace(trackData, videoChunk);
|
|
trackData.chunkQueue.push(videoChunk);
|
|
await this.interleaveChunks();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async addEncodedAudioPacket(track, packet, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getAudioTrackData(track, packet, meta);
|
|
let packetData = packet.data;
|
|
if (trackData.info.requiresAdtsStripping) {
|
|
const adtsFrame = readAdtsFrameHeader(FileSlice4.tempFromBytes(packetData));
|
|
if (!adtsFrame) {
|
|
throw new Error("Expected ADTS frame, didn't get one.");
|
|
}
|
|
const headerLength = adtsFrame.crcCheck === null ? MIN_ADTS_FRAME_HEADER_SIZE : MAX_ADTS_FRAME_HEADER_SIZE;
|
|
packetData = packetData.subarray(headerLength);
|
|
}
|
|
const isKeyFrame = packet.type === "key";
|
|
const timestamp = this.validateAndNormalizeTimestamp(trackData.track, packet.timestamp, isKeyFrame);
|
|
const audioChunk = this.createInternalChunk(packetData, timestamp, packet.duration, packet.type);
|
|
trackData.chunkQueue.push(audioChunk);
|
|
await this.interleaveChunks();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async addSubtitleCue(track, cue, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getSubtitleTrackData(track, meta);
|
|
const timestamp = this.validateAndNormalizeTimestamp(trackData.track, cue.timestamp, true);
|
|
let bodyText = cue.text;
|
|
const timestampMs = Math.round(timestamp * 1e3);
|
|
inlineTimestampRegex.lastIndex = 0;
|
|
bodyText = bodyText.replace(inlineTimestampRegex, (match) => {
|
|
const time = parseSubtitleTimestamp(match.slice(1, -1));
|
|
const offsetTime = time - timestampMs;
|
|
return `<${formatSubtitleTimestamp(offsetTime)}>`;
|
|
});
|
|
const body = textEncoder.encode(bodyText);
|
|
const additions = `${cue.settings ?? ""}
|
|
${cue.identifier ?? ""}
|
|
${cue.notes ?? ""}`;
|
|
const subtitleChunk = this.createInternalChunk(
|
|
body,
|
|
timestamp,
|
|
cue.duration,
|
|
"key",
|
|
additions.trim() ? textEncoder.encode(additions) : null
|
|
);
|
|
trackData.chunkQueue.push(subtitleChunk);
|
|
await this.interleaveChunks();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async interleaveChunks(isFinalCall = false) {
|
|
if (!isFinalCall && !this.allTracksAreKnown()) {
|
|
return;
|
|
}
|
|
outer:
|
|
while (true) {
|
|
let trackWithMinTimestamp = null;
|
|
let minTimestamp = Infinity;
|
|
for (const trackData of this.trackDatas) {
|
|
if (!isFinalCall && trackData.chunkQueue.length === 0 && !trackData.track.source._closed) {
|
|
break outer;
|
|
}
|
|
if (trackData.chunkQueue.length > 0 && trackData.chunkQueue[0].timestamp < minTimestamp) {
|
|
trackWithMinTimestamp = trackData;
|
|
minTimestamp = trackData.chunkQueue[0].timestamp;
|
|
}
|
|
}
|
|
if (!trackWithMinTimestamp) {
|
|
break;
|
|
}
|
|
const chunk = trackWithMinTimestamp.chunkQueue.shift();
|
|
this.writeBlock(trackWithMinTimestamp, chunk);
|
|
}
|
|
if (!isFinalCall) {
|
|
await this.writer.flush();
|
|
}
|
|
}
|
|
/**
|
|
* Due to [a bug in Chromium](https://bugs.chromium.org/p/chromium/issues/detail?id=1377842), VP9 streams often
|
|
* lack color space information. This method patches in that information.
|
|
*/
|
|
fixVP9ColorSpace(trackData, chunk) {
|
|
if (chunk.type !== "key") return;
|
|
if (!trackData.info.decoderConfig.colorSpace || !trackData.info.decoderConfig.colorSpace.matrix) return;
|
|
const bitstream = new Bitstream(chunk.data);
|
|
bitstream.skipBits(2);
|
|
const profileLowBit = bitstream.readBits(1);
|
|
const profileHighBit = bitstream.readBits(1);
|
|
const profile = (profileHighBit << 1) + profileLowBit;
|
|
if (profile === 3) bitstream.skipBits(1);
|
|
const showExistingFrame = bitstream.readBits(1);
|
|
if (showExistingFrame) return;
|
|
const frameType = bitstream.readBits(1);
|
|
if (frameType !== 0) return;
|
|
bitstream.skipBits(2);
|
|
const syncCode = bitstream.readBits(24);
|
|
if (syncCode !== 4817730) return;
|
|
if (profile >= 2) bitstream.skipBits(1);
|
|
const colorSpaceID = {
|
|
rgb: 7,
|
|
bt709: 2,
|
|
bt470bg: 1,
|
|
smpte170m: 3
|
|
}[trackData.info.decoderConfig.colorSpace.matrix];
|
|
writeBits(chunk.data, bitstream.pos, bitstream.pos + 3, colorSpaceID);
|
|
}
|
|
/** Converts a read-only external chunk into an internal one for easier use. */
|
|
createInternalChunk(data, timestamp, duration, type, additions = null) {
|
|
const internalChunk = {
|
|
data,
|
|
type,
|
|
timestamp,
|
|
duration,
|
|
additions
|
|
};
|
|
return internalChunk;
|
|
}
|
|
/** Writes a block containing media data to the file. */
|
|
writeBlock(trackData, chunk) {
|
|
if (!this.segment) {
|
|
this.createSegment();
|
|
}
|
|
const msTimestamp = Math.round(1e3 * chunk.timestamp);
|
|
const keyFrameQueuedEverywhere = this.trackDatas.every((otherTrackData) => {
|
|
if (trackData === otherTrackData) {
|
|
return chunk.type === "key";
|
|
}
|
|
const firstQueuedSample = otherTrackData.chunkQueue[0];
|
|
if (firstQueuedSample) {
|
|
return firstQueuedSample.type === "key";
|
|
}
|
|
return otherTrackData.track.source._closed;
|
|
});
|
|
let shouldCreateNewCluster = false;
|
|
if (!this.currentCluster) {
|
|
shouldCreateNewCluster = true;
|
|
} else {
|
|
assert(this.currentClusterStartMsTimestamp !== null);
|
|
assert(this.currentClusterMaxMsTimestamp !== null);
|
|
const relativeTimestamp2 = msTimestamp - this.currentClusterStartMsTimestamp;
|
|
shouldCreateNewCluster = keyFrameQueuedEverywhere && msTimestamp > this.currentClusterMaxMsTimestamp && relativeTimestamp2 >= 1e3 * (this.format._options.minimumClusterDuration ?? 1) || relativeTimestamp2 > MAX_CLUSTER_TIMESTAMP_MS;
|
|
}
|
|
if (shouldCreateNewCluster) {
|
|
this.createNewCluster(msTimestamp);
|
|
}
|
|
const relativeTimestamp = msTimestamp - this.currentClusterStartMsTimestamp;
|
|
if (relativeTimestamp < MIN_CLUSTER_TIMESTAMP_MS) {
|
|
return;
|
|
}
|
|
const prelude = new Uint8Array(4);
|
|
const view2 = new DataView(prelude.buffer);
|
|
view2.setUint8(0, 128 | trackData.track.id);
|
|
view2.setInt16(1, relativeTimestamp, false);
|
|
const msDuration = Math.round(1e3 * chunk.duration);
|
|
if (!chunk.additions) {
|
|
view2.setUint8(3, Number(chunk.type === "key") << 7);
|
|
const simpleBlock = { id: 163 /* SimpleBlock */, data: [
|
|
prelude,
|
|
chunk.data
|
|
] };
|
|
this.ebmlWriter.writeEBML(simpleBlock);
|
|
} else {
|
|
const blockGroup = { id: 160 /* BlockGroup */, data: [
|
|
{ id: 161 /* Block */, data: [
|
|
prelude,
|
|
chunk.data
|
|
] },
|
|
chunk.type === "delta" ? {
|
|
id: 251 /* ReferenceBlock */,
|
|
data: new EBMLSignedInt(trackData.lastWrittenMsTimestamp - msTimestamp)
|
|
} : null,
|
|
chunk.additions ? { id: 30113 /* BlockAdditions */, data: [
|
|
{ id: 166 /* BlockMore */, data: [
|
|
{ id: 238 /* BlockAddID */, data: 1 },
|
|
// Some players expect BlockAddID to come first
|
|
{ id: 165 /* BlockAdditional */, data: chunk.additions }
|
|
] }
|
|
] } : null,
|
|
msDuration > 0 ? { id: 155 /* BlockDuration */, data: msDuration } : null
|
|
] };
|
|
this.ebmlWriter.writeEBML(blockGroup);
|
|
}
|
|
this.duration = Math.max(this.duration, msTimestamp + msDuration);
|
|
trackData.lastWrittenMsTimestamp = msTimestamp;
|
|
if (!this.trackDatasInCurrentCluster.has(trackData)) {
|
|
this.trackDatasInCurrentCluster.set(trackData, {
|
|
firstMsTimestamp: msTimestamp
|
|
});
|
|
}
|
|
this.currentClusterMaxMsTimestamp = Math.max(this.currentClusterMaxMsTimestamp, msTimestamp);
|
|
}
|
|
/** Creates a new Cluster element to contain media chunks. */
|
|
createNewCluster(msTimestamp) {
|
|
if (this.currentCluster) {
|
|
this.finalizeCurrentCluster();
|
|
}
|
|
if (this.format._options.onCluster) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
this.currentCluster = {
|
|
id: 524531317 /* Cluster */,
|
|
size: this.format._options.appendOnly ? -1 : CLUSTER_SIZE_BYTES,
|
|
data: [
|
|
{ id: 231 /* Timestamp */, data: msTimestamp }
|
|
]
|
|
};
|
|
this.ebmlWriter.writeEBML(this.currentCluster);
|
|
this.currentClusterStartMsTimestamp = msTimestamp;
|
|
this.currentClusterMaxMsTimestamp = msTimestamp;
|
|
this.trackDatasInCurrentCluster.clear();
|
|
}
|
|
finalizeCurrentCluster() {
|
|
assert(this.currentCluster);
|
|
if (!this.format._options.appendOnly) {
|
|
const clusterSize = this.writer.getPos() - this.ebmlWriter.dataOffsets.get(this.currentCluster);
|
|
const endPos = this.writer.getPos();
|
|
this.writer.seek(this.ebmlWriter.offsets.get(this.currentCluster) + 4);
|
|
this.ebmlWriter.writeVarInt(clusterSize, CLUSTER_SIZE_BYTES);
|
|
this.writer.seek(endPos);
|
|
}
|
|
if (this.format._options.onCluster) {
|
|
assert(this.currentClusterStartMsTimestamp !== null);
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onCluster(data, start, this.currentClusterStartMsTimestamp / 1e3);
|
|
}
|
|
const clusterOffsetFromSegment = this.ebmlWriter.offsets.get(this.currentCluster) - this.segmentDataOffset;
|
|
const groupedByTimestamp = /* @__PURE__ */ new Map();
|
|
for (const [trackData, { firstMsTimestamp }] of this.trackDatasInCurrentCluster) {
|
|
if (!groupedByTimestamp.has(firstMsTimestamp)) {
|
|
groupedByTimestamp.set(firstMsTimestamp, []);
|
|
}
|
|
groupedByTimestamp.get(firstMsTimestamp).push(trackData);
|
|
}
|
|
const groupedAndSortedByTimestamp = [...groupedByTimestamp.entries()].sort((a, b) => a[0] - b[0]);
|
|
for (const [msTimestamp, trackDatas] of groupedAndSortedByTimestamp) {
|
|
assert(this.cues);
|
|
this.cues.data.push({ id: 187 /* CuePoint */, data: [
|
|
{ id: 179 /* CueTime */, data: msTimestamp },
|
|
// Create CueTrackPositions for each track that starts at this timestamp
|
|
...trackDatas.map((trackData) => {
|
|
return { id: 183 /* CueTrackPositions */, data: [
|
|
{ id: 247 /* CueTrack */, data: trackData.track.id },
|
|
{ id: 241 /* CueClusterPosition */, data: clusterOffsetFromSegment }
|
|
] };
|
|
})
|
|
] });
|
|
}
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
async onTrackClose() {
|
|
const release = await this.mutex.acquire();
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
await this.interleaveChunks();
|
|
release();
|
|
}
|
|
/** Finalizes the file, making it ready for use. Must be called after all media chunks have been added. */
|
|
async finalize() {
|
|
const release = await this.mutex.acquire();
|
|
this.allTracksKnown.resolve();
|
|
if (!this.segment) {
|
|
this.createSegment();
|
|
}
|
|
await this.interleaveChunks(true);
|
|
if (this.currentCluster) {
|
|
this.finalizeCurrentCluster();
|
|
}
|
|
assert(this.cues);
|
|
this.ebmlWriter.writeEBML(this.cues);
|
|
if (!this.format._options.appendOnly) {
|
|
const endPos = this.writer.getPos();
|
|
const segmentSize = this.writer.getPos() - this.segmentDataOffset;
|
|
this.writer.seek(this.ebmlWriter.offsets.get(this.segment) + 4);
|
|
this.ebmlWriter.writeVarInt(segmentSize, SEGMENT_SIZE_BYTES);
|
|
this.segmentDuration.data = new EBMLFloat64(this.duration);
|
|
this.writer.seek(this.ebmlWriter.offsets.get(this.segmentDuration));
|
|
this.ebmlWriter.writeEBML(this.segmentDuration);
|
|
assert(this.seekHead);
|
|
this.writer.seek(this.ebmlWriter.offsets.get(this.seekHead));
|
|
this.maybeCreateSeekHead(true);
|
|
this.ebmlWriter.writeEBML(this.seekHead);
|
|
this.writer.seek(endPos);
|
|
}
|
|
release();
|
|
}
|
|
};
|
|
|
|
// src/mp3/mp3-writer.ts
|
|
var Mp3Writer = class {
|
|
constructor(writer) {
|
|
this.writer = writer;
|
|
this.helper = new Uint8Array(8);
|
|
this.helperView = new DataView(this.helper.buffer);
|
|
}
|
|
writeU32(value) {
|
|
this.helperView.setUint32(0, value, false);
|
|
this.writer.write(this.helper.subarray(0, 4));
|
|
}
|
|
writeXingFrame(data) {
|
|
const startPos = this.writer.getPos();
|
|
const firstByte = 255;
|
|
const secondByte = 224 | data.mpegVersionId << 3 | data.layer << 1;
|
|
let lowSamplingFrequency;
|
|
if (data.mpegVersionId & 2) {
|
|
lowSamplingFrequency = data.mpegVersionId & 1 ? 0 : 1;
|
|
} else {
|
|
lowSamplingFrequency = 1;
|
|
}
|
|
const padding = 0;
|
|
const neededBytes = 155;
|
|
let bitrateIndex = -1;
|
|
const bitrateOffset = lowSamplingFrequency * 16 * 4 + data.layer * 16;
|
|
for (let i = 0; i < 16; i++) {
|
|
const kbr = KILOBIT_RATES[bitrateOffset + i];
|
|
const size = computeMp3FrameSize(lowSamplingFrequency, data.layer, 1e3 * kbr, data.sampleRate, padding);
|
|
if (size >= neededBytes) {
|
|
bitrateIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
if (bitrateIndex === -1) {
|
|
throw new Error("No suitable bitrate found.");
|
|
}
|
|
const thirdByte = bitrateIndex << 4 | data.frequencyIndex << 2 | padding << 1;
|
|
const fourthByte = data.channel << 6 | data.modeExtension << 4 | data.copyright << 3 | data.original << 2 | data.emphasis;
|
|
this.helper[0] = firstByte;
|
|
this.helper[1] = secondByte;
|
|
this.helper[2] = thirdByte;
|
|
this.helper[3] = fourthByte;
|
|
this.writer.write(this.helper.subarray(0, 4));
|
|
const xingOffset = getXingOffset(data.mpegVersionId, data.channel);
|
|
this.writer.seek(startPos + xingOffset);
|
|
this.writeU32(XING);
|
|
let flags = 0;
|
|
if (data.frameCount !== null) {
|
|
flags |= 1;
|
|
}
|
|
if (data.fileSize !== null) {
|
|
flags |= 2;
|
|
}
|
|
if (data.toc !== null) {
|
|
flags |= 4;
|
|
}
|
|
this.writeU32(flags);
|
|
this.writeU32(data.frameCount ?? 0);
|
|
this.writeU32(data.fileSize ?? 0);
|
|
this.writer.write(data.toc ?? new Uint8Array(100));
|
|
const kilobitRate = KILOBIT_RATES[bitrateOffset + bitrateIndex];
|
|
const frameSize = computeMp3FrameSize(
|
|
lowSamplingFrequency,
|
|
data.layer,
|
|
1e3 * kilobitRate,
|
|
data.sampleRate,
|
|
padding
|
|
);
|
|
this.writer.seek(startPos + frameSize);
|
|
}
|
|
};
|
|
|
|
// src/mp3/mp3-muxer.ts
|
|
var Mp3Muxer = class extends Muxer {
|
|
constructor(output, format) {
|
|
super(output);
|
|
this.xingFrameData = null;
|
|
this.frameCount = 0;
|
|
this.framePositions = [];
|
|
this.xingFramePos = null;
|
|
this.format = format;
|
|
this.writer = output._writer;
|
|
this.mp3Writer = new Mp3Writer(output._writer);
|
|
}
|
|
async start() {
|
|
if (!metadataTagsAreEmpty(this.output._metadataTags)) {
|
|
const id3Writer = new Id3V2Writer(this.writer);
|
|
id3Writer.writeId3V2Tag(this.output._metadataTags);
|
|
}
|
|
}
|
|
async getMimeType() {
|
|
return "audio/mpeg";
|
|
}
|
|
async addEncodedVideoPacket() {
|
|
throw new Error("MP3 does not support video.");
|
|
}
|
|
async addEncodedAudioPacket(track, packet) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const writeXingHeader = this.format._options.xingHeader !== false;
|
|
if (!this.xingFrameData && writeXingHeader) {
|
|
const view2 = toDataView(packet.data);
|
|
if (view2.byteLength < 4) {
|
|
throw new Error("Invalid MP3 header in sample.");
|
|
}
|
|
const word = view2.getUint32(0, false);
|
|
const header = readMp3FrameHeader(word, null).header;
|
|
if (!header) {
|
|
throw new Error("Invalid MP3 header in sample.");
|
|
}
|
|
const xingOffset = getXingOffset(header.mpegVersionId, header.channel);
|
|
if (view2.byteLength >= xingOffset + 4) {
|
|
const word2 = view2.getUint32(xingOffset, false);
|
|
const isXing = word2 === XING || word2 === INFO;
|
|
if (isXing) {
|
|
return;
|
|
}
|
|
}
|
|
this.xingFrameData = {
|
|
mpegVersionId: header.mpegVersionId,
|
|
layer: header.layer,
|
|
frequencyIndex: header.frequencyIndex,
|
|
sampleRate: header.sampleRate,
|
|
channel: header.channel,
|
|
modeExtension: header.modeExtension,
|
|
copyright: header.copyright,
|
|
original: header.original,
|
|
emphasis: header.emphasis,
|
|
frameCount: null,
|
|
fileSize: null,
|
|
toc: null
|
|
};
|
|
this.xingFramePos = this.writer.getPos();
|
|
this.mp3Writer.writeXingFrame(this.xingFrameData);
|
|
this.frameCount++;
|
|
}
|
|
this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === "key");
|
|
this.writer.write(packet.data);
|
|
this.frameCount++;
|
|
await this.writer.flush();
|
|
if (writeXingHeader) {
|
|
this.framePositions.push(this.writer.getPos());
|
|
}
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async addSubtitleCue() {
|
|
throw new Error("MP3 does not support subtitles.");
|
|
}
|
|
async finalize() {
|
|
if (!this.xingFrameData || this.xingFramePos === null) {
|
|
return;
|
|
}
|
|
const release = await this.mutex.acquire();
|
|
const endPos = this.writer.getPos();
|
|
this.writer.seek(this.xingFramePos);
|
|
const toc = new Uint8Array(100);
|
|
for (let i = 0; i < 100; i++) {
|
|
const index = Math.floor(this.framePositions.length * (i / 100));
|
|
assert(index !== -1 && index < this.framePositions.length);
|
|
const byteOffset = this.framePositions[index];
|
|
toc[i] = 256 * (byteOffset / endPos);
|
|
}
|
|
this.xingFrameData.frameCount = this.frameCount;
|
|
this.xingFrameData.fileSize = endPos;
|
|
this.xingFrameData.toc = toc;
|
|
if (this.format._options.onXingFrame) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
this.mp3Writer.writeXingFrame(this.xingFrameData);
|
|
if (this.format._options.onXingFrame) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onXingFrame(data, start);
|
|
}
|
|
this.writer.seek(endPos);
|
|
release();
|
|
}
|
|
};
|
|
|
|
// src/ogg/ogg-muxer.ts
|
|
var PAGE_SIZE_TARGET = 8192;
|
|
var OggMuxer = class extends Muxer {
|
|
constructor(output, format) {
|
|
super(output);
|
|
this.trackDatas = [];
|
|
this.bosPagesWritten = false;
|
|
this.allTracksKnown = promiseWithResolvers();
|
|
this.pageBytes = new Uint8Array(MAX_PAGE_SIZE);
|
|
this.pageView = new DataView(this.pageBytes.buffer);
|
|
this.format = format;
|
|
this.writer = output._writer;
|
|
this.writer.ensureMonotonicity = true;
|
|
}
|
|
async start() {
|
|
}
|
|
async getMimeType() {
|
|
await this.allTracksKnown.promise;
|
|
return buildOggMimeType({
|
|
codecStrings: this.trackDatas.map((x) => x.codecInfo.codec)
|
|
});
|
|
}
|
|
addEncodedVideoPacket() {
|
|
throw new Error("Video tracks are not supported.");
|
|
}
|
|
getTrackData(track, meta) {
|
|
const existingTrackData = this.trackDatas.find((td) => td.track === track);
|
|
if (existingTrackData) {
|
|
return existingTrackData;
|
|
}
|
|
let serialNumber;
|
|
do {
|
|
serialNumber = Math.floor(2 ** 32 * Math.random());
|
|
} while (this.trackDatas.some((td) => td.serialNumber === serialNumber));
|
|
assert(track.source._codec === "vorbis" || track.source._codec === "opus");
|
|
validateAudioChunkMetadata(meta);
|
|
assert(meta);
|
|
assert(meta.decoderConfig);
|
|
const newTrackData = {
|
|
track,
|
|
serialNumber,
|
|
internalSampleRate: track.source._codec === "opus" ? OPUS_SAMPLE_RATE : meta.decoderConfig.sampleRate,
|
|
codecInfo: {
|
|
codec: track.source._codec,
|
|
vorbisInfo: null,
|
|
opusInfo: null
|
|
},
|
|
vorbisLastBlocksize: null,
|
|
packetQueue: [],
|
|
currentTimestampInSamples: 0,
|
|
pagesWritten: 0,
|
|
currentGranulePosition: 0,
|
|
currentLacingValues: [],
|
|
currentPageData: [],
|
|
currentPageSize: 27,
|
|
currentPageStartsWithFreshPacket: true,
|
|
currentPageStartTimestampInSamples: 0
|
|
};
|
|
this.queueHeaderPackets(newTrackData, meta);
|
|
this.trackDatas.push(newTrackData);
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
return newTrackData;
|
|
}
|
|
queueHeaderPackets(trackData, meta) {
|
|
assert(meta.decoderConfig);
|
|
if (trackData.track.source._codec === "vorbis") {
|
|
assert(meta.decoderConfig.description);
|
|
const bytes2 = toUint8Array(meta.decoderConfig.description);
|
|
if (bytes2[0] !== 2) {
|
|
throw new TypeError("First byte of Vorbis decoder description must be 2.");
|
|
}
|
|
let pos = 1;
|
|
const readPacketLength = () => {
|
|
let length = 0;
|
|
while (true) {
|
|
const value = bytes2[pos++];
|
|
if (value === void 0) {
|
|
throw new TypeError("Vorbis decoder description is too short.");
|
|
}
|
|
length += value;
|
|
if (value < 255) {
|
|
return length;
|
|
}
|
|
}
|
|
};
|
|
const identificationHeaderLength = readPacketLength();
|
|
const commentHeaderLength = readPacketLength();
|
|
const setupHeaderLength = bytes2.length - pos;
|
|
if (setupHeaderLength <= 0) {
|
|
throw new TypeError("Vorbis decoder description is too short.");
|
|
}
|
|
const identificationHeader = bytes2.subarray(pos, pos += identificationHeaderLength);
|
|
pos += commentHeaderLength;
|
|
const setupHeader = bytes2.subarray(pos);
|
|
const commentHeaderHeader = new Uint8Array(7);
|
|
commentHeaderHeader[0] = 3;
|
|
commentHeaderHeader[1] = 118;
|
|
commentHeaderHeader[2] = 111;
|
|
commentHeaderHeader[3] = 114;
|
|
commentHeaderHeader[4] = 98;
|
|
commentHeaderHeader[5] = 105;
|
|
commentHeaderHeader[6] = 115;
|
|
const commentHeader = createVorbisComments(commentHeaderHeader, this.output._metadataTags, true);
|
|
trackData.packetQueue.push({
|
|
data: identificationHeader,
|
|
timestampInSamples: 0,
|
|
durationInSamples: 0,
|
|
forcePageFlush: true
|
|
}, {
|
|
data: commentHeader,
|
|
timestampInSamples: 0,
|
|
durationInSamples: 0,
|
|
forcePageFlush: false
|
|
}, {
|
|
data: setupHeader,
|
|
timestampInSamples: 0,
|
|
durationInSamples: 0,
|
|
forcePageFlush: true
|
|
// The last header packet must flush the page
|
|
});
|
|
const view2 = toDataView(identificationHeader);
|
|
const blockSizeByte = view2.getUint8(28);
|
|
trackData.codecInfo.vorbisInfo = {
|
|
blocksizes: [
|
|
1 << (blockSizeByte & 15),
|
|
1 << (blockSizeByte >> 4)
|
|
],
|
|
modeBlockflags: parseModesFromVorbisSetupPacket(setupHeader).modeBlockflags
|
|
};
|
|
} else if (trackData.track.source._codec === "opus") {
|
|
if (!meta.decoderConfig.description) {
|
|
throw new TypeError("For Ogg, Opus decoder description is required.");
|
|
}
|
|
const identificationHeader = toUint8Array(meta.decoderConfig.description);
|
|
const commentHeaderHeader = new Uint8Array(8);
|
|
const commentHeaderHeaderView = toDataView(commentHeaderHeader);
|
|
commentHeaderHeaderView.setUint32(0, 1332770163, false);
|
|
commentHeaderHeaderView.setUint32(4, 1415669619, false);
|
|
const commentHeader = createVorbisComments(commentHeaderHeader, this.output._metadataTags, true);
|
|
trackData.packetQueue.push({
|
|
data: identificationHeader,
|
|
timestampInSamples: 0,
|
|
durationInSamples: 0,
|
|
forcePageFlush: true
|
|
}, {
|
|
data: commentHeader,
|
|
timestampInSamples: 0,
|
|
durationInSamples: 0,
|
|
forcePageFlush: true
|
|
// The last header packet must flush the page
|
|
});
|
|
trackData.codecInfo.opusInfo = {
|
|
preSkip: parseOpusIdentificationHeader(identificationHeader).preSkip
|
|
};
|
|
}
|
|
}
|
|
async addEncodedAudioPacket(track, packet, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getTrackData(track, meta);
|
|
this.validateAndNormalizeTimestamp(trackData.track, packet.timestamp, packet.type === "key");
|
|
const currentTimestampInSamples = trackData.currentTimestampInSamples;
|
|
const { durationInSamples, vorbisBlockSize } = extractSampleMetadata(
|
|
packet.data,
|
|
trackData.codecInfo,
|
|
trackData.vorbisLastBlocksize
|
|
);
|
|
trackData.currentTimestampInSamples += durationInSamples;
|
|
trackData.vorbisLastBlocksize = vorbisBlockSize;
|
|
trackData.packetQueue.push({
|
|
data: packet.data,
|
|
timestampInSamples: currentTimestampInSamples,
|
|
durationInSamples,
|
|
forcePageFlush: false
|
|
});
|
|
await this.interleavePages();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
addSubtitleCue() {
|
|
throw new Error("Subtitle tracks are not supported.");
|
|
}
|
|
allTracksAreKnown() {
|
|
for (const track of this.output._tracks) {
|
|
if (!track.source._closed && !this.trackDatas.some((x) => x.track === track)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
async interleavePages(isFinalCall = false) {
|
|
if (!this.bosPagesWritten) {
|
|
if (!this.allTracksAreKnown() && !isFinalCall) {
|
|
return;
|
|
}
|
|
for (const trackData of this.trackDatas) {
|
|
while (trackData.packetQueue.length > 0) {
|
|
const packet = trackData.packetQueue.shift();
|
|
this.writePacket(trackData, packet, false);
|
|
if (packet.forcePageFlush) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
this.bosPagesWritten = true;
|
|
}
|
|
outer:
|
|
while (true) {
|
|
let trackWithMinTimestamp = null;
|
|
let minTimestamp = Infinity;
|
|
for (const trackData of this.trackDatas) {
|
|
if (!isFinalCall && trackData.packetQueue.length <= 1 && !trackData.track.source._closed) {
|
|
break outer;
|
|
}
|
|
if (trackData.packetQueue.length > 0 && trackData.packetQueue[0].timestampInSamples < minTimestamp) {
|
|
trackWithMinTimestamp = trackData;
|
|
minTimestamp = trackData.packetQueue[0].timestampInSamples;
|
|
}
|
|
}
|
|
if (!trackWithMinTimestamp) {
|
|
break;
|
|
}
|
|
const packet = trackWithMinTimestamp.packetQueue.shift();
|
|
const isFinalPacket = trackWithMinTimestamp.packetQueue.length === 0;
|
|
this.writePacket(trackWithMinTimestamp, packet, isFinalPacket);
|
|
}
|
|
if (!isFinalCall) {
|
|
await this.writer.flush();
|
|
}
|
|
}
|
|
writePacket(trackData, packet, isFinalPacket) {
|
|
const packetEndTimestampInSamples = packet.timestampInSamples + packet.durationInSamples;
|
|
if (this.format._options.maximumPageDuration !== void 0) {
|
|
const maxDurationInSamples = this.format._options.maximumPageDuration * trackData.internalSampleRate;
|
|
if (trackData.currentLacingValues.length > 0 && packetEndTimestampInSamples - trackData.currentPageStartTimestampInSamples > maxDurationInSamples) {
|
|
this.writePage(trackData, false);
|
|
}
|
|
}
|
|
let remainingLength = packet.data.length;
|
|
let dataStartOffset = 0;
|
|
let dataOffset = 0;
|
|
while (true) {
|
|
if (trackData.currentLacingValues.length === 0 && dataStartOffset > 0) {
|
|
trackData.currentPageStartsWithFreshPacket = false;
|
|
}
|
|
const segmentSize = Math.min(255, remainingLength);
|
|
trackData.currentLacingValues.push(segmentSize);
|
|
trackData.currentPageSize++;
|
|
dataOffset += segmentSize;
|
|
const segmentIsLastOfPacket = remainingLength < 255;
|
|
if (trackData.currentLacingValues.length === 255) {
|
|
const slice2 = packet.data.subarray(dataStartOffset, dataOffset);
|
|
dataStartOffset = dataOffset;
|
|
trackData.currentPageData.push(slice2);
|
|
trackData.currentPageSize += slice2.length;
|
|
this.writePage(trackData, isFinalPacket && segmentIsLastOfPacket);
|
|
if (segmentIsLastOfPacket) {
|
|
return;
|
|
}
|
|
}
|
|
if (segmentIsLastOfPacket) {
|
|
break;
|
|
}
|
|
remainingLength -= 255;
|
|
}
|
|
const slice = packet.data.subarray(dataStartOffset);
|
|
trackData.currentPageData.push(slice);
|
|
trackData.currentPageSize += slice.length;
|
|
trackData.currentGranulePosition = packetEndTimestampInSamples;
|
|
if (trackData.currentPageSize >= PAGE_SIZE_TARGET || packet.forcePageFlush) {
|
|
this.writePage(trackData, isFinalPacket);
|
|
}
|
|
}
|
|
writePage(trackData, isEos) {
|
|
this.pageView.setUint32(0, OGGS, true);
|
|
this.pageView.setUint8(4, 0);
|
|
let headerType = 0;
|
|
if (!trackData.currentPageStartsWithFreshPacket) {
|
|
headerType |= 1;
|
|
}
|
|
if (trackData.pagesWritten === 0) {
|
|
headerType |= 2;
|
|
}
|
|
if (isEos) {
|
|
headerType |= 4;
|
|
}
|
|
this.pageView.setUint8(5, headerType);
|
|
const granulePosition = trackData.currentLacingValues.every((x) => x === 255) ? -1 : trackData.currentGranulePosition;
|
|
setInt64(this.pageView, 6, granulePosition, true);
|
|
this.pageView.setUint32(14, trackData.serialNumber, true);
|
|
this.pageView.setUint32(18, trackData.pagesWritten, true);
|
|
this.pageView.setUint32(22, 0, true);
|
|
this.pageView.setUint8(26, trackData.currentLacingValues.length);
|
|
this.pageBytes.set(trackData.currentLacingValues, 27);
|
|
let pos = 27 + trackData.currentLacingValues.length;
|
|
for (const data of trackData.currentPageData) {
|
|
this.pageBytes.set(data, pos);
|
|
pos += data.length;
|
|
}
|
|
const slice = this.pageBytes.subarray(0, pos);
|
|
const crc = computeOggPageCrc(slice);
|
|
this.pageView.setUint32(22, crc, true);
|
|
trackData.pagesWritten++;
|
|
trackData.currentLacingValues.length = 0;
|
|
trackData.currentPageData.length = 0;
|
|
trackData.currentPageSize = 27;
|
|
trackData.currentPageStartsWithFreshPacket = true;
|
|
trackData.currentPageStartTimestampInSamples = trackData.currentGranulePosition;
|
|
if (this.format._options.onPage) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
this.writer.write(slice);
|
|
if (this.format._options.onPage) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onPage(data, start, trackData.track.source);
|
|
}
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
async onTrackClose() {
|
|
const release = await this.mutex.acquire();
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
await this.interleavePages();
|
|
release();
|
|
}
|
|
async finalize() {
|
|
const release = await this.mutex.acquire();
|
|
this.allTracksKnown.resolve();
|
|
await this.interleavePages(true);
|
|
for (const trackData of this.trackDatas) {
|
|
if (trackData.currentLacingValues.length > 0) {
|
|
this.writePage(trackData, true);
|
|
}
|
|
}
|
|
release();
|
|
}
|
|
};
|
|
|
|
// src/mpeg-ts/mpeg-ts-muxer.ts
|
|
var PAT_PID = 0;
|
|
var PMT_PID = 4096;
|
|
var FIRST_TRACK_PID = 256;
|
|
var VIDEO_STREAM_ID_BASE = 224;
|
|
var AUDIO_STREAM_ID_BASE = 192;
|
|
var AVC_AUD_NAL = new Uint8Array([9, 240]);
|
|
var HEVC_AUD_NAL = new Uint8Array([70, 1]);
|
|
var MpegTsMuxer = class extends Muxer {
|
|
constructor(output, format) {
|
|
super(output);
|
|
this.trackDatas = [];
|
|
this.tablesWritten = false;
|
|
this.continuityCounters = /* @__PURE__ */ new Map();
|
|
this.packetBuffer = new Uint8Array(TS_PACKET_SIZE);
|
|
this.packetView = toDataView(this.packetBuffer);
|
|
this.allTracksKnown = promiseWithResolvers();
|
|
this.videoTrackIndex = 0;
|
|
this.audioTrackIndex = 0;
|
|
this.pesHeaderBuffer = new Uint8Array(14);
|
|
this.pesHeaderView = toDataView(this.pesHeaderBuffer);
|
|
this.ptsBitstream = new Bitstream(this.pesHeaderBuffer.subarray(9, 14));
|
|
this.adaptationFieldBuffer = new Uint8Array(184);
|
|
this.payloadBuffer = new Uint8Array(184);
|
|
this.format = format;
|
|
this.writer = output._writer;
|
|
this.writer.ensureMonotonicity = true;
|
|
}
|
|
async start() {
|
|
}
|
|
async getMimeType() {
|
|
await this.allTracksKnown.promise;
|
|
return buildMpegTsMimeType(this.trackDatas.map((x) => x.codecString));
|
|
}
|
|
getVideoTrackData(track, meta) {
|
|
const existingTrackData = this.trackDatas.find((x) => x.track === track);
|
|
if (existingTrackData) {
|
|
return existingTrackData;
|
|
}
|
|
validateVideoChunkMetadata(meta);
|
|
assert(meta?.decoderConfig);
|
|
const codec = track.source._codec;
|
|
assert(codec === "avc" || codec === "hevc");
|
|
const streamType = codec === "avc" ? 27 /* AVC */ : 36 /* HEVC */;
|
|
const pid = FIRST_TRACK_PID + this.trackDatas.length;
|
|
const streamId = VIDEO_STREAM_ID_BASE + this.videoTrackIndex++;
|
|
const newTrackData = {
|
|
track,
|
|
pid,
|
|
streamType,
|
|
streamId,
|
|
codecString: meta.decoderConfig.codec,
|
|
packetQueue: [],
|
|
inputIsAnnexB: null,
|
|
inputIsAdts: null,
|
|
avcDecoderConfig: null,
|
|
hevcDecoderConfig: null,
|
|
adtsHeader: null,
|
|
adtsHeaderBitstream: null,
|
|
firstPacketWritten: false
|
|
};
|
|
this.trackDatas.push(newTrackData);
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
return newTrackData;
|
|
}
|
|
getAudioTrackData(track, meta) {
|
|
const existingTrackData = this.trackDatas.find((x) => x.track === track);
|
|
if (existingTrackData) {
|
|
return existingTrackData;
|
|
}
|
|
validateAudioChunkMetadata(meta);
|
|
assert(meta?.decoderConfig);
|
|
const codec = track.source._codec;
|
|
assert(codec === "aac" || codec === "mp3" || codec === "ac3" || codec === "eac3");
|
|
let streamType;
|
|
let streamId;
|
|
switch (codec) {
|
|
case "aac":
|
|
{
|
|
streamType = 15 /* AAC */;
|
|
streamId = AUDIO_STREAM_ID_BASE + this.audioTrackIndex++;
|
|
}
|
|
;
|
|
break;
|
|
case "mp3":
|
|
{
|
|
streamType = 3 /* MP3_MPEG1 */;
|
|
streamId = AUDIO_STREAM_ID_BASE + this.audioTrackIndex++;
|
|
}
|
|
;
|
|
break;
|
|
case "ac3":
|
|
{
|
|
streamType = 129 /* AC3_SYSTEM_A */;
|
|
streamId = 189;
|
|
}
|
|
;
|
|
break;
|
|
case "eac3":
|
|
{
|
|
streamType = 135 /* EAC3_SYSTEM_A */;
|
|
streamId = 189;
|
|
}
|
|
;
|
|
break;
|
|
}
|
|
const pid = FIRST_TRACK_PID + this.trackDatas.length;
|
|
const newTrackData = {
|
|
track,
|
|
pid,
|
|
streamType,
|
|
streamId,
|
|
codecString: meta.decoderConfig.codec,
|
|
packetQueue: [],
|
|
inputIsAnnexB: null,
|
|
inputIsAdts: null,
|
|
avcDecoderConfig: null,
|
|
hevcDecoderConfig: null,
|
|
adtsHeader: null,
|
|
adtsHeaderBitstream: null,
|
|
firstPacketWritten: false
|
|
};
|
|
this.trackDatas.push(newTrackData);
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
return newTrackData;
|
|
}
|
|
async addEncodedVideoPacket(track, packet, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getVideoTrackData(track, meta);
|
|
const timestamp = this.validateAndNormalizeTimestamp(
|
|
trackData.track,
|
|
packet.timestamp,
|
|
packet.type === "key"
|
|
);
|
|
const preparedData = this.prepareVideoPacket(trackData, packet, meta);
|
|
trackData.packetQueue.push({
|
|
data: preparedData,
|
|
timestamp,
|
|
isKeyframe: packet.type === "key"
|
|
});
|
|
await this.interleavePackets();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async addEncodedAudioPacket(track, packet, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getAudioTrackData(track, meta);
|
|
const timestamp = this.validateAndNormalizeTimestamp(
|
|
trackData.track,
|
|
packet.timestamp,
|
|
packet.type === "key"
|
|
);
|
|
const preparedData = this.prepareAudioPacket(trackData, packet, meta);
|
|
trackData.packetQueue.push({
|
|
data: preparedData,
|
|
timestamp,
|
|
isKeyframe: packet.type === "key"
|
|
});
|
|
await this.interleavePackets();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async addSubtitleCue() {
|
|
throw new Error("MPEG-TS does not support subtitles.");
|
|
}
|
|
prepareVideoPacket(trackData, packet, meta) {
|
|
const codec = trackData.track.source._codec;
|
|
if (trackData.inputIsAnnexB === null) {
|
|
const description = meta?.decoderConfig?.description;
|
|
trackData.inputIsAnnexB = !description;
|
|
if (!trackData.inputIsAnnexB) {
|
|
const bytes2 = toUint8Array(description);
|
|
if (codec === "avc") {
|
|
trackData.avcDecoderConfig = deserializeAvcDecoderConfigurationRecord(bytes2);
|
|
} else {
|
|
trackData.hevcDecoderConfig = deserializeHevcDecoderConfigurationRecord(bytes2);
|
|
}
|
|
}
|
|
}
|
|
if (trackData.inputIsAnnexB) {
|
|
return this.prepareAnnexBVideoPacket(packet.data, codec);
|
|
} else {
|
|
return this.prepareLengthPrefixedVideoPacket(trackData, packet, codec);
|
|
}
|
|
}
|
|
prepareAnnexBVideoPacket(data, codec) {
|
|
const nalUnits = [];
|
|
for (const loc of iterateNalUnitsInAnnexB(data)) {
|
|
const nalUnit = data.subarray(loc.offset, loc.offset + loc.length);
|
|
const isAud = codec === "avc" ? extractNalUnitTypeForAvc(nalUnit[0]) === 9 /* AUD */ : extractNalUnitTypeForHevc(nalUnit[0]) === 35 /* AUD_NUT */;
|
|
if (!isAud) {
|
|
nalUnits.push(nalUnit);
|
|
}
|
|
}
|
|
const aud = codec === "avc" ? AVC_AUD_NAL : HEVC_AUD_NAL;
|
|
nalUnits.unshift(aud);
|
|
return concatNalUnitsInAnnexB(nalUnits);
|
|
}
|
|
prepareLengthPrefixedVideoPacket(trackData, packet, codec) {
|
|
const data = packet.data;
|
|
const lengthSize = codec === "avc" ? trackData.avcDecoderConfig.lengthSizeMinusOne + 1 : trackData.hevcDecoderConfig.lengthSizeMinusOne + 1;
|
|
const nalUnits = [];
|
|
for (const loc of iterateNalUnitsInLengthPrefixed(data, lengthSize)) {
|
|
const nalUnit = data.subarray(loc.offset, loc.offset + loc.length);
|
|
const isAud = codec === "avc" ? extractNalUnitTypeForAvc(nalUnit[0]) === 9 /* AUD */ : extractNalUnitTypeForHevc(nalUnit[0]) === 35 /* AUD_NUT */;
|
|
if (!isAud) {
|
|
nalUnits.push(nalUnit);
|
|
}
|
|
}
|
|
if (packet.type === "key") {
|
|
if (codec === "avc") {
|
|
const config = trackData.avcDecoderConfig;
|
|
for (const pps of config.pictureParameterSets) {
|
|
nalUnits.unshift(pps);
|
|
}
|
|
for (const sps of config.sequenceParameterSets) {
|
|
nalUnits.unshift(sps);
|
|
}
|
|
} else {
|
|
const config = trackData.hevcDecoderConfig;
|
|
for (const arr of config.arrays) {
|
|
if (arr.nalUnitType === 34 /* PPS_NUT */) {
|
|
for (const nal of arr.nalUnits) {
|
|
nalUnits.unshift(nal);
|
|
}
|
|
}
|
|
}
|
|
for (const arr of config.arrays) {
|
|
if (arr.nalUnitType === 33 /* SPS_NUT */) {
|
|
for (const nal of arr.nalUnits) {
|
|
nalUnits.unshift(nal);
|
|
}
|
|
}
|
|
}
|
|
for (const arr of config.arrays) {
|
|
if (arr.nalUnitType === 32 /* VPS_NUT */) {
|
|
for (const nal of arr.nalUnits) {
|
|
nalUnits.unshift(nal);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const aud = codec === "avc" ? AVC_AUD_NAL : HEVC_AUD_NAL;
|
|
nalUnits.unshift(aud);
|
|
return concatNalUnitsInAnnexB(nalUnits);
|
|
}
|
|
prepareAudioPacket(trackData, packet, meta) {
|
|
const codec = trackData.track.source._codec;
|
|
if (codec === "mp3" || codec === "ac3" || codec === "eac3") {
|
|
return packet.data;
|
|
}
|
|
if (trackData.inputIsAdts === null) {
|
|
const description = meta?.decoderConfig?.description;
|
|
trackData.inputIsAdts = !description;
|
|
if (!trackData.inputIsAdts) {
|
|
const config = parseAacAudioSpecificConfig(toUint8Array(description));
|
|
const template = buildAdtsHeaderTemplate(config);
|
|
trackData.adtsHeader = template.header;
|
|
trackData.adtsHeaderBitstream = template.bitstream;
|
|
}
|
|
}
|
|
if (trackData.inputIsAdts) {
|
|
return packet.data;
|
|
}
|
|
assert(trackData.adtsHeader);
|
|
assert(trackData.adtsHeaderBitstream);
|
|
const header = trackData.adtsHeader;
|
|
const frameLength = packet.data.byteLength + header.byteLength;
|
|
writeAdtsFrameLength(trackData.adtsHeaderBitstream, frameLength);
|
|
const result = new Uint8Array(frameLength);
|
|
result.set(header, 0);
|
|
result.set(packet.data, header.byteLength);
|
|
return result;
|
|
}
|
|
allTracksAreKnown() {
|
|
for (const track of this.output._tracks) {
|
|
if (!track.source._closed && !this.trackDatas.some((x) => x.track === track)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
async interleavePackets(isFinalCall = false) {
|
|
if (!this.tablesWritten) {
|
|
if (!this.allTracksAreKnown() && !isFinalCall) {
|
|
return;
|
|
}
|
|
this.writeTables();
|
|
}
|
|
outer:
|
|
while (true) {
|
|
let trackWithMinTimestamp = null;
|
|
let minTimestamp = Infinity;
|
|
for (const trackData of this.trackDatas) {
|
|
if (!isFinalCall && trackData.packetQueue.length === 0 && !trackData.track.source._closed) {
|
|
break outer;
|
|
}
|
|
if (trackData.packetQueue.length > 0 && trackData.packetQueue[0].timestamp < minTimestamp) {
|
|
trackWithMinTimestamp = trackData;
|
|
minTimestamp = trackData.packetQueue[0].timestamp;
|
|
}
|
|
}
|
|
if (!trackWithMinTimestamp) {
|
|
break;
|
|
}
|
|
const queuedPacket = trackWithMinTimestamp.packetQueue.shift();
|
|
this.writePesPacket(trackWithMinTimestamp, queuedPacket);
|
|
}
|
|
if (!isFinalCall) {
|
|
await this.writer.flush();
|
|
}
|
|
}
|
|
writeTables() {
|
|
assert(!this.tablesWritten);
|
|
this.writePsiSection(PAT_PID, PAT_SECTION);
|
|
this.writePsiSection(PMT_PID, buildPmt(this.trackDatas));
|
|
this.tablesWritten = true;
|
|
}
|
|
writePsiSection(pid, section) {
|
|
let offset = 0;
|
|
let isFirst = true;
|
|
while (offset < section.length) {
|
|
const pointerFieldSize = isFirst ? 1 : 0;
|
|
const availablePayload = 184 - pointerFieldSize;
|
|
const remainingData = section.length - offset;
|
|
const chunkSize = Math.min(availablePayload, remainingData);
|
|
let payload;
|
|
if (isFirst) {
|
|
payload = this.payloadBuffer.subarray(0, 1 + chunkSize);
|
|
payload[0] = 0;
|
|
payload.set(section.subarray(offset, offset + chunkSize), 1);
|
|
} else {
|
|
payload = section.subarray(offset, offset + chunkSize);
|
|
}
|
|
this.writeTsPacket(pid, isFirst, null, payload);
|
|
offset += chunkSize;
|
|
isFirst = false;
|
|
}
|
|
}
|
|
writePesPacket(trackData, queuedPacket) {
|
|
const pesView = this.pesHeaderView;
|
|
setUint24(pesView, 0, 1, false);
|
|
this.pesHeaderBuffer[3] = trackData.streamId;
|
|
const pesPacketLength = trackData.track.type === "video" ? 0 : Math.min(8 + queuedPacket.data.length, 65535);
|
|
pesView.setUint16(4, pesPacketLength, false);
|
|
pesView.setUint8(6, 132);
|
|
pesView.setUint8(7, 128);
|
|
pesView.setUint8(8, 5);
|
|
const pts = Math.round(queuedPacket.timestamp * TIMESCALE);
|
|
this.ptsBitstream.pos = 0;
|
|
this.ptsBitstream.writeBits(4, 2);
|
|
this.ptsBitstream.writeBits(3, pts >>> 30 & 7);
|
|
this.ptsBitstream.writeBits(1, 1);
|
|
this.ptsBitstream.writeBits(15, pts >>> 15 & 32767);
|
|
this.ptsBitstream.writeBits(1, 1);
|
|
this.ptsBitstream.writeBits(15, pts & 32767);
|
|
this.ptsBitstream.writeBits(1, 1);
|
|
const totalLength = this.pesHeaderBuffer.length + queuedPacket.data.length;
|
|
let offset = 0;
|
|
let isFirstTsPacket = true;
|
|
while (offset < totalLength) {
|
|
const pusi = isFirstTsPacket;
|
|
const remainingData = totalLength - offset;
|
|
const randomAccessIndicator = isFirstTsPacket && queuedPacket.isKeyframe;
|
|
const discontinuityIndicator = isFirstTsPacket && !trackData.firstPacketWritten;
|
|
const basePaddingNeeded = Math.max(0, 184 - remainingData);
|
|
let adaptationFieldSize;
|
|
if (randomAccessIndicator || discontinuityIndicator) {
|
|
adaptationFieldSize = Math.max(2, basePaddingNeeded);
|
|
} else {
|
|
adaptationFieldSize = basePaddingNeeded;
|
|
}
|
|
let adaptationField = null;
|
|
if (adaptationFieldSize > 0) {
|
|
const buf = this.adaptationFieldBuffer;
|
|
if (adaptationFieldSize === 1) {
|
|
buf[0] = 0;
|
|
} else {
|
|
buf[0] = adaptationFieldSize - 1;
|
|
buf[1] = Number(discontinuityIndicator) << 7 | Number(randomAccessIndicator) << 6;
|
|
buf.fill(255, 2, adaptationFieldSize);
|
|
}
|
|
adaptationField = buf.subarray(0, adaptationFieldSize);
|
|
}
|
|
const payloadSize = Math.min(184 - adaptationFieldSize, remainingData);
|
|
const payload = this.payloadBuffer.subarray(0, payloadSize);
|
|
let payloadOffset = 0;
|
|
if (offset < this.pesHeaderBuffer.length) {
|
|
const headerBytes = Math.min(this.pesHeaderBuffer.length - offset, payloadSize);
|
|
payload.set(this.pesHeaderBuffer.subarray(offset, offset + headerBytes), 0);
|
|
payloadOffset = headerBytes;
|
|
}
|
|
const dataStart = Math.max(0, offset - this.pesHeaderBuffer.length);
|
|
const dataEnd = dataStart + (payloadSize - payloadOffset);
|
|
if (payloadOffset < payloadSize) {
|
|
payload.set(queuedPacket.data.subarray(dataStart, dataEnd), payloadOffset);
|
|
}
|
|
this.writeTsPacket(trackData.pid, pusi, adaptationField, payload);
|
|
offset += payloadSize;
|
|
isFirstTsPacket = false;
|
|
}
|
|
trackData.firstPacketWritten = true;
|
|
}
|
|
writeTsPacket(pid, pusi, adaptationField, payload) {
|
|
const cc = this.continuityCounters.get(pid) ?? 0;
|
|
const hasPayload = payload.length > 0;
|
|
const adaptCtrl = adaptationField ? hasPayload ? 3 : 2 : hasPayload ? 1 : 0;
|
|
this.packetBuffer[0] = 71;
|
|
this.packetView.setUint16(1, (pusi ? 16384 : 0) | pid & 8191, false);
|
|
this.packetBuffer[3] = adaptCtrl << 4 | cc & 15;
|
|
if (hasPayload) {
|
|
this.continuityCounters.set(pid, cc + 1 & 15);
|
|
}
|
|
let offset = 4;
|
|
if (adaptationField) {
|
|
this.packetBuffer.set(adaptationField, offset);
|
|
offset += adaptationField.length;
|
|
}
|
|
this.packetBuffer.set(payload, offset);
|
|
offset += payload.length;
|
|
if (offset < TS_PACKET_SIZE) {
|
|
this.packetBuffer.fill(255, offset);
|
|
}
|
|
const startPos = this.writer.getPos();
|
|
this.writer.write(this.packetBuffer);
|
|
if (this.format._options.onPacket) {
|
|
this.format._options.onPacket(this.packetBuffer.slice(), startPos);
|
|
}
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
async onTrackClose() {
|
|
const release = await this.mutex.acquire();
|
|
if (this.allTracksAreKnown()) {
|
|
this.allTracksKnown.resolve();
|
|
}
|
|
await this.interleavePackets();
|
|
release();
|
|
}
|
|
async finalize() {
|
|
const release = await this.mutex.acquire();
|
|
this.allTracksKnown.resolve();
|
|
await this.interleavePackets(true);
|
|
release();
|
|
}
|
|
};
|
|
var MPEG_TS_CRC_POLYNOMIAL = 79764919;
|
|
var MPEG_TS_CRC_TABLE = new Uint32Array(256);
|
|
for (let n = 0; n < 256; n++) {
|
|
let crc = n << 24;
|
|
for (let k = 0; k < 8; k++) {
|
|
crc = crc & 2147483648 ? crc << 1 ^ MPEG_TS_CRC_POLYNOMIAL : crc << 1;
|
|
}
|
|
MPEG_TS_CRC_TABLE[n] = crc >>> 0 & 4294967295;
|
|
}
|
|
var computeMpegTsCrc32 = (data) => {
|
|
let crc = 4294967295;
|
|
for (let i = 0; i < data.length; i++) {
|
|
const byte = data[i];
|
|
crc = (crc << 8 ^ MPEG_TS_CRC_TABLE[crc >>> 24 ^ byte]) >>> 0;
|
|
}
|
|
return crc;
|
|
};
|
|
var PAT_SECTION = new Uint8Array(16);
|
|
{
|
|
const view2 = toDataView(PAT_SECTION);
|
|
PAT_SECTION[0] = 0;
|
|
view2.setUint16(1, 45069, false);
|
|
view2.setUint16(3, 1, false);
|
|
PAT_SECTION[5] = 193;
|
|
PAT_SECTION[6] = 0;
|
|
PAT_SECTION[7] = 0;
|
|
view2.setUint16(8, 1, false);
|
|
view2.setUint16(10, 57344 | PMT_PID & 8191, false);
|
|
view2.setUint32(12, computeMpegTsCrc32(PAT_SECTION.subarray(0, 12)), false);
|
|
}
|
|
var buildPmt = (trackDatas) => {
|
|
let totalEsBytes = 0;
|
|
for (const trackData of trackDatas) {
|
|
totalEsBytes += 5;
|
|
if (trackData.streamType === 129 /* AC3_SYSTEM_A */) {
|
|
totalEsBytes += AC3_REGISTRATION_DESCRIPTOR.length;
|
|
} else if (trackData.streamType === 135 /* EAC3_SYSTEM_A */) {
|
|
totalEsBytes += EAC3_REGISTRATION_DESCRIPTOR.length;
|
|
}
|
|
}
|
|
const sectionLength = 9 + totalEsBytes + 4;
|
|
const section = new Uint8Array(3 + sectionLength - 4);
|
|
const view2 = toDataView(section);
|
|
section[0] = 2;
|
|
view2.setUint16(1, 45056 | sectionLength & 4095, false);
|
|
view2.setUint16(3, 1, false);
|
|
section[5] = 193;
|
|
section[6] = 0;
|
|
section[7] = 0;
|
|
view2.setUint16(8, 57344 | 8191, false);
|
|
view2.setUint16(10, 61440, false);
|
|
let offset = 12;
|
|
for (const trackData of trackDatas) {
|
|
section[offset++] = trackData.streamType;
|
|
view2.setUint16(offset, 57344 | trackData.pid & 8191, false);
|
|
offset += 2;
|
|
if (trackData.streamType === 129 /* AC3_SYSTEM_A */) {
|
|
view2.setUint16(offset, 61440 | AC3_REGISTRATION_DESCRIPTOR.length, false);
|
|
offset += 2;
|
|
section.set(AC3_REGISTRATION_DESCRIPTOR, offset);
|
|
offset += AC3_REGISTRATION_DESCRIPTOR.length;
|
|
} else if (trackData.streamType === 135 /* EAC3_SYSTEM_A */) {
|
|
view2.setUint16(offset, 61440 | EAC3_REGISTRATION_DESCRIPTOR.length, false);
|
|
offset += 2;
|
|
section.set(EAC3_REGISTRATION_DESCRIPTOR, offset);
|
|
offset += EAC3_REGISTRATION_DESCRIPTOR.length;
|
|
} else {
|
|
view2.setUint16(offset, 61440, false);
|
|
offset += 2;
|
|
}
|
|
}
|
|
const crc = computeMpegTsCrc32(section);
|
|
const result = new Uint8Array(section.length + 4);
|
|
result.set(section, 0);
|
|
toDataView(result).setUint32(section.length, crc, false);
|
|
return result;
|
|
};
|
|
|
|
// src/wave/riff-writer.ts
|
|
var RiffWriter = class {
|
|
constructor(writer) {
|
|
this.writer = writer;
|
|
this.helper = new Uint8Array(8);
|
|
this.helperView = new DataView(this.helper.buffer);
|
|
}
|
|
writeU16(value) {
|
|
this.helperView.setUint16(0, value, true);
|
|
this.writer.write(this.helper.subarray(0, 2));
|
|
}
|
|
writeU32(value) {
|
|
this.helperView.setUint32(0, value, true);
|
|
this.writer.write(this.helper.subarray(0, 4));
|
|
}
|
|
writeU64(value) {
|
|
this.helperView.setUint32(0, value, true);
|
|
this.helperView.setUint32(4, Math.floor(value / 2 ** 32), true);
|
|
this.writer.write(this.helper);
|
|
}
|
|
writeAscii(text) {
|
|
this.writer.write(new TextEncoder().encode(text));
|
|
}
|
|
};
|
|
|
|
// src/wave/wave-muxer.ts
|
|
var WaveMuxer = class extends Muxer {
|
|
constructor(output, format) {
|
|
super(output);
|
|
this.headerWritten = false;
|
|
this.dataSize = 0;
|
|
this.sampleRate = null;
|
|
this.sampleCount = 0;
|
|
this.riffSizePos = null;
|
|
this.dataSizePos = null;
|
|
this.ds64RiffSizePos = null;
|
|
this.ds64DataSizePos = null;
|
|
this.ds64SampleCountPos = null;
|
|
this.format = format;
|
|
this.writer = output._writer;
|
|
this.riffWriter = new RiffWriter(output._writer);
|
|
this.isRf64 = !!format._options.large;
|
|
}
|
|
async start() {
|
|
}
|
|
async getMimeType() {
|
|
return "audio/wav";
|
|
}
|
|
async addEncodedVideoPacket() {
|
|
throw new Error("WAVE does not support video.");
|
|
}
|
|
async addEncodedAudioPacket(track, packet, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
if (!this.headerWritten) {
|
|
validateAudioChunkMetadata(meta);
|
|
assert(meta);
|
|
assert(meta.decoderConfig);
|
|
this.writeHeader(track, meta.decoderConfig);
|
|
this.sampleRate = meta.decoderConfig.sampleRate;
|
|
this.headerWritten = true;
|
|
}
|
|
this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === "key");
|
|
if (!this.isRf64 && this.writer.getPos() + packet.data.byteLength >= 2 ** 32) {
|
|
throw new Error(
|
|
"Adding more audio data would exceed the maximum RIFF size of 4 GiB. To write larger files, use RF64 by setting `large: true` in the WavOutputFormatOptions."
|
|
);
|
|
}
|
|
this.writer.write(packet.data);
|
|
this.dataSize += packet.data.byteLength;
|
|
this.sampleCount += Math.round(packet.duration * this.sampleRate);
|
|
await this.writer.flush();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async addSubtitleCue() {
|
|
throw new Error("WAVE does not support subtitles.");
|
|
}
|
|
writeHeader(track, config) {
|
|
if (this.format._options.onHeader) {
|
|
this.writer.startTrackingWrites();
|
|
}
|
|
let format;
|
|
const codec = track.source._codec;
|
|
const pcmInfo = parsePcmCodec(codec);
|
|
if (pcmInfo.dataType === "ulaw") {
|
|
format = 7 /* MULAW */;
|
|
} else if (pcmInfo.dataType === "alaw") {
|
|
format = 6 /* ALAW */;
|
|
} else if (pcmInfo.dataType === "float") {
|
|
format = 3 /* IEEE_FLOAT */;
|
|
} else {
|
|
format = 1 /* PCM */;
|
|
}
|
|
const channels = config.numberOfChannels;
|
|
const sampleRate = config.sampleRate;
|
|
const blockSize = pcmInfo.sampleSize * channels;
|
|
this.riffWriter.writeAscii(this.isRf64 ? "RF64" : "RIFF");
|
|
if (this.isRf64) {
|
|
this.riffWriter.writeU32(4294967295);
|
|
} else {
|
|
this.riffSizePos = this.writer.getPos();
|
|
this.riffWriter.writeU32(0);
|
|
}
|
|
this.riffWriter.writeAscii("WAVE");
|
|
if (this.isRf64) {
|
|
this.riffWriter.writeAscii("ds64");
|
|
this.riffWriter.writeU32(28);
|
|
this.ds64RiffSizePos = this.writer.getPos();
|
|
this.riffWriter.writeU64(0);
|
|
this.ds64DataSizePos = this.writer.getPos();
|
|
this.riffWriter.writeU64(0);
|
|
this.ds64SampleCountPos = this.writer.getPos();
|
|
this.riffWriter.writeU64(0);
|
|
this.riffWriter.writeU32(0);
|
|
}
|
|
this.riffWriter.writeAscii("fmt ");
|
|
this.riffWriter.writeU32(16);
|
|
this.riffWriter.writeU16(format);
|
|
this.riffWriter.writeU16(channels);
|
|
this.riffWriter.writeU32(sampleRate);
|
|
this.riffWriter.writeU32(sampleRate * blockSize);
|
|
this.riffWriter.writeU16(blockSize);
|
|
this.riffWriter.writeU16(8 * pcmInfo.sampleSize);
|
|
if (!metadataTagsAreEmpty(this.output._metadataTags)) {
|
|
const metadataFormat = this.format._options.metadataFormat ?? "info";
|
|
if (metadataFormat === "info") {
|
|
this.writeInfoChunk(this.output._metadataTags);
|
|
} else if (metadataFormat === "id3") {
|
|
this.writeId3Chunk(this.output._metadataTags);
|
|
} else {
|
|
assertNever(metadataFormat);
|
|
}
|
|
}
|
|
this.riffWriter.writeAscii("data");
|
|
if (this.isRf64) {
|
|
this.riffWriter.writeU32(4294967295);
|
|
} else {
|
|
this.dataSizePos = this.writer.getPos();
|
|
this.riffWriter.writeU32(0);
|
|
}
|
|
if (this.format._options.onHeader) {
|
|
const { data, start } = this.writer.stopTrackingWrites();
|
|
this.format._options.onHeader(data, start);
|
|
}
|
|
}
|
|
writeInfoChunk(metadata) {
|
|
const startPos = this.writer.getPos();
|
|
this.riffWriter.writeAscii("LIST");
|
|
this.riffWriter.writeU32(0);
|
|
this.riffWriter.writeAscii("INFO");
|
|
const writtenTags = /* @__PURE__ */ new Set();
|
|
const writeInfoTag = (tag, value) => {
|
|
if (!isIso88591Compatible(value)) {
|
|
console.warn(`Didn't write tag '${tag}' because '${value}' is not ISO 8859-1-compatible.`);
|
|
return;
|
|
}
|
|
const size = value.length + 1;
|
|
const bytes2 = new Uint8Array(size);
|
|
for (let i = 0; i < value.length; i++) {
|
|
bytes2[i] = value.charCodeAt(i);
|
|
}
|
|
this.riffWriter.writeAscii(tag);
|
|
this.riffWriter.writeU32(size);
|
|
this.writer.write(bytes2);
|
|
if (size & 1) {
|
|
this.writer.write(new Uint8Array(1));
|
|
}
|
|
writtenTags.add(tag);
|
|
};
|
|
for (const { key, value } of keyValueIterator(metadata)) {
|
|
switch (key) {
|
|
case "title":
|
|
{
|
|
writeInfoTag("INAM", value);
|
|
writtenTags.add("INAM");
|
|
}
|
|
;
|
|
break;
|
|
case "artist":
|
|
{
|
|
writeInfoTag("IART", value);
|
|
writtenTags.add("IART");
|
|
}
|
|
;
|
|
break;
|
|
case "album":
|
|
{
|
|
writeInfoTag("IPRD", value);
|
|
writtenTags.add("IPRD");
|
|
}
|
|
;
|
|
break;
|
|
case "trackNumber":
|
|
{
|
|
const string = metadata.tracksTotal !== void 0 ? `${value}/${metadata.tracksTotal}` : value.toString();
|
|
writeInfoTag("ITRK", string);
|
|
writtenTags.add("ITRK");
|
|
}
|
|
;
|
|
break;
|
|
case "genre":
|
|
{
|
|
writeInfoTag("IGNR", value);
|
|
writtenTags.add("IGNR");
|
|
}
|
|
;
|
|
break;
|
|
case "date":
|
|
{
|
|
writeInfoTag("ICRD", value.toISOString().slice(0, 10));
|
|
writtenTags.add("ICRD");
|
|
}
|
|
;
|
|
break;
|
|
case "comment":
|
|
{
|
|
writeInfoTag("ICMT", value);
|
|
writtenTags.add("ICMT");
|
|
}
|
|
;
|
|
break;
|
|
case "albumArtist":
|
|
case "discNumber":
|
|
case "tracksTotal":
|
|
case "discsTotal":
|
|
case "description":
|
|
case "lyrics":
|
|
case "images":
|
|
{
|
|
}
|
|
;
|
|
break;
|
|
case "raw":
|
|
{
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
assertNever(key);
|
|
}
|
|
}
|
|
if (metadata.raw) {
|
|
for (const key in metadata.raw) {
|
|
const value = metadata.raw[key];
|
|
if (value == null || key.length !== 4 || writtenTags.has(key)) {
|
|
continue;
|
|
}
|
|
if (typeof value === "string") {
|
|
writeInfoTag(key, value);
|
|
}
|
|
}
|
|
}
|
|
const endPos = this.writer.getPos();
|
|
const chunkSize = endPos - startPos - 8;
|
|
this.writer.seek(startPos + 4);
|
|
this.riffWriter.writeU32(chunkSize);
|
|
this.writer.seek(endPos);
|
|
if (chunkSize & 1) {
|
|
this.writer.write(new Uint8Array(1));
|
|
}
|
|
}
|
|
writeId3Chunk(metadata) {
|
|
const startPos = this.writer.getPos();
|
|
this.riffWriter.writeAscii("ID3 ");
|
|
this.riffWriter.writeU32(0);
|
|
const id3Writer = new Id3V2Writer(this.writer);
|
|
const id3TagSize = id3Writer.writeId3V2Tag(metadata);
|
|
const endPos = this.writer.getPos();
|
|
this.writer.seek(startPos + 4);
|
|
this.riffWriter.writeU32(id3TagSize);
|
|
this.writer.seek(endPos);
|
|
if (id3TagSize & 1) {
|
|
this.writer.write(new Uint8Array(1));
|
|
}
|
|
}
|
|
async finalize() {
|
|
const release = await this.mutex.acquire();
|
|
const endPos = this.writer.getPos();
|
|
if (this.isRf64) {
|
|
assert(this.ds64RiffSizePos !== null);
|
|
this.writer.seek(this.ds64RiffSizePos);
|
|
this.riffWriter.writeU64(endPos - 8);
|
|
assert(this.ds64DataSizePos !== null);
|
|
this.writer.seek(this.ds64DataSizePos);
|
|
this.riffWriter.writeU64(this.dataSize);
|
|
assert(this.ds64SampleCountPos !== null);
|
|
this.writer.seek(this.ds64SampleCountPos);
|
|
this.riffWriter.writeU64(this.sampleCount);
|
|
} else {
|
|
assert(this.riffSizePos !== null);
|
|
this.writer.seek(this.riffSizePos);
|
|
this.riffWriter.writeU32(endPos - 8);
|
|
assert(this.dataSizePos !== null);
|
|
this.writer.seek(this.dataSizePos);
|
|
this.riffWriter.writeU32(this.dataSize);
|
|
}
|
|
this.writer.seek(endPos);
|
|
release();
|
|
}
|
|
};
|
|
|
|
// src/output-format.ts
|
|
var OutputFormat = class {
|
|
/** Returns a list of video codecs that this output format can contain. */
|
|
getSupportedVideoCodecs() {
|
|
return this.getSupportedCodecs().filter((codec) => VIDEO_CODECS.includes(codec));
|
|
}
|
|
/** Returns a list of audio codecs that this output format can contain. */
|
|
getSupportedAudioCodecs() {
|
|
return this.getSupportedCodecs().filter((codec) => AUDIO_CODECS.includes(codec));
|
|
}
|
|
/** Returns a list of subtitle codecs that this output format can contain. */
|
|
getSupportedSubtitleCodecs() {
|
|
return this.getSupportedCodecs().filter((codec) => SUBTITLE_CODECS.includes(codec));
|
|
}
|
|
/** @internal */
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
_codecUnsupportedHint(codec) {
|
|
return "";
|
|
}
|
|
};
|
|
var IsobmffOutputFormat2 = class extends OutputFormat {
|
|
/** Internal constructor. */
|
|
constructor(options = {}) {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.fastStart !== void 0 && ![false, "in-memory", "reserve", "fragmented"].includes(options.fastStart)) {
|
|
throw new TypeError(
|
|
"options.fastStart, when provided, must be false, 'in-memory', 'reserve', or 'fragmented'."
|
|
);
|
|
}
|
|
if (options.minimumFragmentDuration !== void 0 && (!Number.isFinite(options.minimumFragmentDuration) || options.minimumFragmentDuration < 0)) {
|
|
throw new TypeError("options.minimumFragmentDuration, when provided, must be a non-negative number.");
|
|
}
|
|
if (options.onFtyp !== void 0 && typeof options.onFtyp !== "function") {
|
|
throw new TypeError("options.onFtyp, when provided, must be a function.");
|
|
}
|
|
if (options.onMoov !== void 0 && typeof options.onMoov !== "function") {
|
|
throw new TypeError("options.onMoov, when provided, must be a function.");
|
|
}
|
|
if (options.onMdat !== void 0 && typeof options.onMdat !== "function") {
|
|
throw new TypeError("options.onMdat, when provided, must be a function.");
|
|
}
|
|
if (options.onMoof !== void 0 && typeof options.onMoof !== "function") {
|
|
throw new TypeError("options.onMoof, when provided, must be a function.");
|
|
}
|
|
if (options.metadataFormat !== void 0 && !["mdir", "mdta", "udta", "auto"].includes(options.metadataFormat)) {
|
|
throw new TypeError(
|
|
"options.metadataFormat, when provided, must be either 'auto', 'mdir', 'mdta', or 'udta'."
|
|
);
|
|
}
|
|
super();
|
|
this._options = options;
|
|
}
|
|
getSupportedTrackCounts() {
|
|
const max = 2 ** 32 - 1;
|
|
return {
|
|
video: { min: 0, max },
|
|
audio: { min: 0, max },
|
|
subtitle: { min: 0, max },
|
|
total: { min: 1, max }
|
|
};
|
|
}
|
|
get supportsVideoRotationMetadata() {
|
|
return true;
|
|
}
|
|
get supportsTimestampedMediaData() {
|
|
return true;
|
|
}
|
|
/** @internal */
|
|
_createMuxer(output) {
|
|
return new IsobmffMuxer2(output, this);
|
|
}
|
|
};
|
|
var Mp4OutputFormat = class extends IsobmffOutputFormat2 {
|
|
/** Creates a new {@link Mp4OutputFormat} configured with the specified `options`. */
|
|
constructor(options) {
|
|
super(options);
|
|
}
|
|
/** @internal */
|
|
get _name() {
|
|
return "MP4";
|
|
}
|
|
get fileExtension() {
|
|
return ".mp4";
|
|
}
|
|
get mimeType() {
|
|
return "video/mp4";
|
|
}
|
|
getSupportedCodecs() {
|
|
return [
|
|
...VIDEO_CODECS,
|
|
...NON_PCM_AUDIO_CODECS,
|
|
// These are supported via ISO/IEC 23003-5:
|
|
"pcm-s16",
|
|
"pcm-s16be",
|
|
"pcm-s24",
|
|
"pcm-s24be",
|
|
"pcm-s32",
|
|
"pcm-s32be",
|
|
"pcm-f32",
|
|
"pcm-f32be",
|
|
"pcm-f64",
|
|
"pcm-f64be",
|
|
...SUBTITLE_CODECS
|
|
];
|
|
}
|
|
/** @internal */
|
|
_codecUnsupportedHint(codec) {
|
|
if (new MovOutputFormat().getSupportedCodecs().includes(codec)) {
|
|
return " Switching to MOV will grant support for this codec.";
|
|
}
|
|
return "";
|
|
}
|
|
};
|
|
var MovOutputFormat = class extends IsobmffOutputFormat2 {
|
|
/** Creates a new {@link MovOutputFormat} configured with the specified `options`. */
|
|
constructor(options) {
|
|
super(options);
|
|
}
|
|
/** @internal */
|
|
get _name() {
|
|
return "MOV";
|
|
}
|
|
get fileExtension() {
|
|
return ".mov";
|
|
}
|
|
get mimeType() {
|
|
return "video/quicktime";
|
|
}
|
|
getSupportedCodecs() {
|
|
return [
|
|
...VIDEO_CODECS,
|
|
...AUDIO_CODECS
|
|
];
|
|
}
|
|
/** @internal */
|
|
_codecUnsupportedHint(codec) {
|
|
if (new Mp4OutputFormat().getSupportedCodecs().includes(codec)) {
|
|
return " Switching to MP4 will grant support for this codec.";
|
|
}
|
|
return "";
|
|
}
|
|
};
|
|
var MkvOutputFormat2 = class extends OutputFormat {
|
|
/** Creates a new {@link MkvOutputFormat} configured with the specified `options`. */
|
|
constructor(options = {}) {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.appendOnly !== void 0 && typeof options.appendOnly !== "boolean") {
|
|
throw new TypeError("options.appendOnly, when provided, must be a boolean.");
|
|
}
|
|
if (options.minimumClusterDuration !== void 0 && (!Number.isFinite(options.minimumClusterDuration) || options.minimumClusterDuration < 0)) {
|
|
throw new TypeError("options.minimumClusterDuration, when provided, must be a non-negative number.");
|
|
}
|
|
if (options.onEbmlHeader !== void 0 && typeof options.onEbmlHeader !== "function") {
|
|
throw new TypeError("options.onEbmlHeader, when provided, must be a function.");
|
|
}
|
|
if (options.onSegmentHeader !== void 0 && typeof options.onSegmentHeader !== "function") {
|
|
throw new TypeError("options.onHeader, when provided, must be a function.");
|
|
}
|
|
if (options.onCluster !== void 0 && typeof options.onCluster !== "function") {
|
|
throw new TypeError("options.onCluster, when provided, must be a function.");
|
|
}
|
|
super();
|
|
this._options = options;
|
|
}
|
|
/** @internal */
|
|
_createMuxer(output) {
|
|
return new MatroskaMuxer(output, this);
|
|
}
|
|
/** @internal */
|
|
get _name() {
|
|
return "Matroska";
|
|
}
|
|
getSupportedTrackCounts() {
|
|
const max = 127;
|
|
return {
|
|
video: { min: 0, max },
|
|
audio: { min: 0, max },
|
|
subtitle: { min: 0, max },
|
|
total: { min: 1, max }
|
|
};
|
|
}
|
|
get fileExtension() {
|
|
return ".mkv";
|
|
}
|
|
get mimeType() {
|
|
return "video/x-matroska";
|
|
}
|
|
getSupportedCodecs() {
|
|
return [
|
|
...VIDEO_CODECS,
|
|
...NON_PCM_AUDIO_CODECS,
|
|
...PCM_AUDIO_CODECS.filter((codec) => !["pcm-s8", "pcm-f32be", "pcm-f64be", "ulaw", "alaw"].includes(codec)),
|
|
...SUBTITLE_CODECS
|
|
];
|
|
}
|
|
get supportsVideoRotationMetadata() {
|
|
return false;
|
|
}
|
|
get supportsTimestampedMediaData() {
|
|
return true;
|
|
}
|
|
};
|
|
var WebMOutputFormat = class extends MkvOutputFormat2 {
|
|
/** Creates a new {@link WebMOutputFormat} configured with the specified `options`. */
|
|
constructor(options) {
|
|
super(options);
|
|
}
|
|
getSupportedCodecs() {
|
|
return [
|
|
...VIDEO_CODECS.filter((codec) => ["vp8", "vp9", "av1"].includes(codec)),
|
|
...AUDIO_CODECS.filter((codec) => ["opus", "vorbis"].includes(codec)),
|
|
...SUBTITLE_CODECS
|
|
];
|
|
}
|
|
/** @internal */
|
|
get _name() {
|
|
return "WebM";
|
|
}
|
|
get fileExtension() {
|
|
return ".webm";
|
|
}
|
|
get mimeType() {
|
|
return "video/webm";
|
|
}
|
|
/** @internal */
|
|
_codecUnsupportedHint(codec) {
|
|
if (new MkvOutputFormat2().getSupportedCodecs().includes(codec)) {
|
|
return " Switching to MKV will grant support for this codec.";
|
|
}
|
|
return "";
|
|
}
|
|
};
|
|
var Mp3OutputFormat = class extends OutputFormat {
|
|
/** Creates a new {@link Mp3OutputFormat} configured with the specified `options`. */
|
|
constructor(options = {}) {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.xingHeader !== void 0 && typeof options.xingHeader !== "boolean") {
|
|
throw new TypeError("options.xingHeader, when provided, must be a boolean.");
|
|
}
|
|
if (options.onXingFrame !== void 0 && typeof options.onXingFrame !== "function") {
|
|
throw new TypeError("options.onXingFrame, when provided, must be a function.");
|
|
}
|
|
super();
|
|
this._options = options;
|
|
}
|
|
/** @internal */
|
|
_createMuxer(output) {
|
|
return new Mp3Muxer(output, this);
|
|
}
|
|
/** @internal */
|
|
get _name() {
|
|
return "MP3";
|
|
}
|
|
getSupportedTrackCounts() {
|
|
return {
|
|
video: { min: 0, max: 0 },
|
|
audio: { min: 1, max: 1 },
|
|
subtitle: { min: 0, max: 0 },
|
|
total: { min: 1, max: 1 }
|
|
};
|
|
}
|
|
get fileExtension() {
|
|
return ".mp3";
|
|
}
|
|
get mimeType() {
|
|
return "audio/mpeg";
|
|
}
|
|
getSupportedCodecs() {
|
|
return ["mp3"];
|
|
}
|
|
get supportsVideoRotationMetadata() {
|
|
return false;
|
|
}
|
|
get supportsTimestampedMediaData() {
|
|
return false;
|
|
}
|
|
};
|
|
var WavOutputFormat = class extends OutputFormat {
|
|
/** Creates a new {@link WavOutputFormat} configured with the specified `options`. */
|
|
constructor(options = {}) {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.large !== void 0 && typeof options.large !== "boolean") {
|
|
throw new TypeError("options.large, when provided, must be a boolean.");
|
|
}
|
|
if (options.metadataFormat !== void 0 && !["info", "id3"].includes(options.metadataFormat)) {
|
|
throw new TypeError("options.metadataFormat, when provided, must be either 'info' or 'id3'.");
|
|
}
|
|
if (options.onHeader !== void 0 && typeof options.onHeader !== "function") {
|
|
throw new TypeError("options.onHeader, when provided, must be a function.");
|
|
}
|
|
super();
|
|
this._options = options;
|
|
}
|
|
/** @internal */
|
|
_createMuxer(output) {
|
|
return new WaveMuxer(output, this);
|
|
}
|
|
/** @internal */
|
|
get _name() {
|
|
return "WAVE";
|
|
}
|
|
getSupportedTrackCounts() {
|
|
return {
|
|
video: { min: 0, max: 0 },
|
|
audio: { min: 1, max: 1 },
|
|
subtitle: { min: 0, max: 0 },
|
|
total: { min: 1, max: 1 }
|
|
};
|
|
}
|
|
get fileExtension() {
|
|
return ".wav";
|
|
}
|
|
get mimeType() {
|
|
return "audio/wav";
|
|
}
|
|
getSupportedCodecs() {
|
|
return [
|
|
...PCM_AUDIO_CODECS.filter(
|
|
(codec) => ["pcm-s16", "pcm-s24", "pcm-s32", "pcm-f32", "pcm-u8", "ulaw", "alaw"].includes(codec)
|
|
)
|
|
];
|
|
}
|
|
get supportsVideoRotationMetadata() {
|
|
return false;
|
|
}
|
|
get supportsTimestampedMediaData() {
|
|
return false;
|
|
}
|
|
};
|
|
var OggOutputFormat = class extends OutputFormat {
|
|
/** Creates a new {@link OggOutputFormat} configured with the specified `options`. */
|
|
constructor(options = {}) {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.maximumPageDuration !== void 0 && (!Number.isFinite(options.maximumPageDuration) || options.maximumPageDuration <= 0)) {
|
|
throw new TypeError("options.maximumPageDuration, when provided, must be a positive number.");
|
|
}
|
|
if (options.onPage !== void 0 && typeof options.onPage !== "function") {
|
|
throw new TypeError("options.onPage, when provided, must be a function.");
|
|
}
|
|
super();
|
|
this._options = options;
|
|
}
|
|
/** @internal */
|
|
_createMuxer(output) {
|
|
return new OggMuxer(output, this);
|
|
}
|
|
/** @internal */
|
|
get _name() {
|
|
return "Ogg";
|
|
}
|
|
getSupportedTrackCounts() {
|
|
const max = 2 ** 32;
|
|
return {
|
|
video: { min: 0, max: 0 },
|
|
audio: { min: 0, max },
|
|
subtitle: { min: 0, max: 0 },
|
|
total: { min: 1, max }
|
|
};
|
|
}
|
|
get fileExtension() {
|
|
return ".ogg";
|
|
}
|
|
get mimeType() {
|
|
return "application/ogg";
|
|
}
|
|
getSupportedCodecs() {
|
|
return [
|
|
...AUDIO_CODECS.filter((codec) => ["vorbis", "opus"].includes(codec))
|
|
];
|
|
}
|
|
get supportsVideoRotationMetadata() {
|
|
return false;
|
|
}
|
|
get supportsTimestampedMediaData() {
|
|
return false;
|
|
}
|
|
};
|
|
var AdtsOutputFormat = class extends OutputFormat {
|
|
/** Creates a new {@link AdtsOutputFormat} configured with the specified `options`. */
|
|
constructor(options = {}) {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.onFrame !== void 0 && typeof options.onFrame !== "function") {
|
|
throw new TypeError("options.onFrame, when provided, must be a function.");
|
|
}
|
|
super();
|
|
this._options = options;
|
|
}
|
|
/** @internal */
|
|
_createMuxer(output) {
|
|
return new AdtsMuxer(output, this);
|
|
}
|
|
/** @internal */
|
|
get _name() {
|
|
return "ADTS";
|
|
}
|
|
getSupportedTrackCounts() {
|
|
return {
|
|
video: { min: 0, max: 0 },
|
|
audio: { min: 1, max: 1 },
|
|
subtitle: { min: 0, max: 0 },
|
|
total: { min: 1, max: 1 }
|
|
};
|
|
}
|
|
get fileExtension() {
|
|
return ".aac";
|
|
}
|
|
get mimeType() {
|
|
return "audio/aac";
|
|
}
|
|
getSupportedCodecs() {
|
|
return ["aac"];
|
|
}
|
|
get supportsVideoRotationMetadata() {
|
|
return false;
|
|
}
|
|
get supportsTimestampedMediaData() {
|
|
return false;
|
|
}
|
|
};
|
|
var FlacOutputFormat = class extends OutputFormat {
|
|
/** Creates a new {@link FlacOutputFormat} configured with the specified `options`. */
|
|
constructor(options = {}) {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
super();
|
|
this._options = options;
|
|
}
|
|
/** @internal */
|
|
_createMuxer(output) {
|
|
return new FlacMuxer(output, this);
|
|
}
|
|
/** @internal */
|
|
get _name() {
|
|
return "FLAC";
|
|
}
|
|
getSupportedTrackCounts() {
|
|
return {
|
|
video: { min: 0, max: 0 },
|
|
audio: { min: 1, max: 1 },
|
|
subtitle: { min: 0, max: 0 },
|
|
total: { min: 1, max: 1 }
|
|
};
|
|
}
|
|
get fileExtension() {
|
|
return ".flac";
|
|
}
|
|
get mimeType() {
|
|
return "audio/flac";
|
|
}
|
|
getSupportedCodecs() {
|
|
return ["flac"];
|
|
}
|
|
get supportsVideoRotationMetadata() {
|
|
return false;
|
|
}
|
|
get supportsTimestampedMediaData() {
|
|
return false;
|
|
}
|
|
};
|
|
var MpegTsOutputFormat = class extends OutputFormat {
|
|
/** Creates a new {@link MpegTsOutputFormat} configured with the specified `options`. */
|
|
constructor(options = {}) {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.onPacket !== void 0 && typeof options.onPacket !== "function") {
|
|
throw new TypeError("options.onPacket, when provided, must be a function.");
|
|
}
|
|
super();
|
|
this._options = options;
|
|
}
|
|
/** @internal */
|
|
_createMuxer(output) {
|
|
return new MpegTsMuxer(output, this);
|
|
}
|
|
/** @internal */
|
|
get _name() {
|
|
return "MPEG-TS";
|
|
}
|
|
getSupportedTrackCounts() {
|
|
const maxVideo = 16;
|
|
const maxAudio = 32;
|
|
const maxTotal = maxVideo + maxAudio;
|
|
return {
|
|
video: { min: 0, max: maxVideo },
|
|
audio: { min: 0, max: maxAudio },
|
|
subtitle: { min: 0, max: 0 },
|
|
total: { min: 1, max: maxTotal }
|
|
};
|
|
}
|
|
get fileExtension() {
|
|
return ".ts";
|
|
}
|
|
get mimeType() {
|
|
return "video/MP2T";
|
|
}
|
|
getSupportedCodecs() {
|
|
return [
|
|
...VIDEO_CODECS.filter((codec) => ["avc", "hevc"].includes(codec)),
|
|
...AUDIO_CODECS.filter((codec) => ["aac", "mp3", "ac3", "eac3"].includes(codec))
|
|
];
|
|
}
|
|
get supportsVideoRotationMetadata() {
|
|
return false;
|
|
}
|
|
get supportsTimestampedMediaData() {
|
|
return true;
|
|
}
|
|
};
|
|
|
|
// src/encode.ts
|
|
var validateVideoEncodingConfig = (config) => {
|
|
if (!config || typeof config !== "object") {
|
|
throw new TypeError("Encoding config must be an object.");
|
|
}
|
|
if (!VIDEO_CODECS.includes(config.codec)) {
|
|
throw new TypeError(`Invalid video codec '${config.codec}'. Must be one of: ${VIDEO_CODECS.join(", ")}.`);
|
|
}
|
|
if (!(config.bitrate instanceof Quality) && (!Number.isInteger(config.bitrate) || config.bitrate <= 0)) {
|
|
throw new TypeError("config.bitrate must be a positive integer or a quality.");
|
|
}
|
|
if (config.keyFrameInterval !== void 0 && (!Number.isFinite(config.keyFrameInterval) || config.keyFrameInterval < 0)) {
|
|
throw new TypeError("config.keyFrameInterval, when provided, must be a non-negative number.");
|
|
}
|
|
if (config.sizeChangeBehavior !== void 0 && !["deny", "passThrough", "fill", "contain", "cover"].includes(config.sizeChangeBehavior)) {
|
|
throw new TypeError(
|
|
"config.sizeChangeBehavior, when provided, must be 'deny', 'passThrough', 'fill', 'contain' or 'cover'."
|
|
);
|
|
}
|
|
if (config.onEncodedPacket !== void 0 && typeof config.onEncodedPacket !== "function") {
|
|
throw new TypeError("config.onEncodedChunk, when provided, must be a function.");
|
|
}
|
|
if (config.onEncoderConfig !== void 0 && typeof config.onEncoderConfig !== "function") {
|
|
throw new TypeError("config.onEncoderConfig, when provided, must be a function.");
|
|
}
|
|
validateVideoEncodingAdditionalOptions(config.codec, config);
|
|
};
|
|
var validateVideoEncodingAdditionalOptions = (codec, options) => {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("Encoding options must be an object.");
|
|
}
|
|
if (options.alpha !== void 0 && !["discard", "keep"].includes(options.alpha)) {
|
|
throw new TypeError("options.alpha, when provided, must be 'discard' or 'keep'.");
|
|
}
|
|
if (options.bitrateMode !== void 0 && !["constant", "variable"].includes(options.bitrateMode)) {
|
|
throw new TypeError("bitrateMode, when provided, must be 'constant' or 'variable'.");
|
|
}
|
|
if (options.latencyMode !== void 0 && !["quality", "realtime"].includes(options.latencyMode)) {
|
|
throw new TypeError("latencyMode, when provided, must be 'quality' or 'realtime'.");
|
|
}
|
|
if (options.fullCodecString !== void 0 && typeof options.fullCodecString !== "string") {
|
|
throw new TypeError("fullCodecString, when provided, must be a string.");
|
|
}
|
|
if (options.fullCodecString !== void 0 && inferCodecFromCodecString(options.fullCodecString) !== codec) {
|
|
throw new TypeError(
|
|
`fullCodecString, when provided, must be a string that matches the specified codec (${codec}).`
|
|
);
|
|
}
|
|
if (options.hardwareAcceleration !== void 0 && !["no-preference", "prefer-hardware", "prefer-software"].includes(options.hardwareAcceleration)) {
|
|
throw new TypeError(
|
|
"hardwareAcceleration, when provided, must be 'no-preference', 'prefer-hardware' or 'prefer-software'."
|
|
);
|
|
}
|
|
if (options.scalabilityMode !== void 0 && typeof options.scalabilityMode !== "string") {
|
|
throw new TypeError("scalabilityMode, when provided, must be a string.");
|
|
}
|
|
if (options.contentHint !== void 0 && typeof options.contentHint !== "string") {
|
|
throw new TypeError("contentHint, when provided, must be a string.");
|
|
}
|
|
};
|
|
var buildVideoEncoderConfig = (options) => {
|
|
const resolvedBitrate = options.bitrate instanceof Quality ? options.bitrate._toVideoBitrate(options.codec, options.width, options.height) : options.bitrate;
|
|
return {
|
|
codec: options.fullCodecString ?? buildVideoCodecString(
|
|
options.codec,
|
|
options.width,
|
|
options.height,
|
|
resolvedBitrate
|
|
),
|
|
width: options.width,
|
|
height: options.height,
|
|
bitrate: resolvedBitrate,
|
|
bitrateMode: options.bitrateMode,
|
|
alpha: options.alpha ?? "discard",
|
|
framerate: options.framerate,
|
|
latencyMode: options.latencyMode,
|
|
hardwareAcceleration: options.hardwareAcceleration,
|
|
scalabilityMode: options.scalabilityMode,
|
|
contentHint: options.contentHint,
|
|
...getVideoEncoderConfigExtension(options.codec)
|
|
};
|
|
};
|
|
var validateAudioEncodingConfig = (config) => {
|
|
if (!config || typeof config !== "object") {
|
|
throw new TypeError("Encoding config must be an object.");
|
|
}
|
|
if (!AUDIO_CODECS.includes(config.codec)) {
|
|
throw new TypeError(`Invalid audio codec '${config.codec}'. Must be one of: ${AUDIO_CODECS.join(", ")}.`);
|
|
}
|
|
if (config.bitrate === void 0 && (!PCM_AUDIO_CODECS.includes(config.codec) || config.codec === "flac")) {
|
|
throw new TypeError("config.bitrate must be provided for compressed audio codecs.");
|
|
}
|
|
if (config.bitrate !== void 0 && !(config.bitrate instanceof Quality) && (!Number.isInteger(config.bitrate) || config.bitrate <= 0)) {
|
|
throw new TypeError("config.bitrate, when provided, must be a positive integer or a quality.");
|
|
}
|
|
if (config.onEncodedPacket !== void 0 && typeof config.onEncodedPacket !== "function") {
|
|
throw new TypeError("config.onEncodedChunk, when provided, must be a function.");
|
|
}
|
|
if (config.onEncoderConfig !== void 0 && typeof config.onEncoderConfig !== "function") {
|
|
throw new TypeError("config.onEncoderConfig, when provided, must be a function.");
|
|
}
|
|
validateAudioEncodingAdditionalOptions(config.codec, config);
|
|
};
|
|
var validateAudioEncodingAdditionalOptions = (codec, options) => {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("Encoding options must be an object.");
|
|
}
|
|
if (options.bitrateMode !== void 0 && !["constant", "variable"].includes(options.bitrateMode)) {
|
|
throw new TypeError("bitrateMode, when provided, must be 'constant' or 'variable'.");
|
|
}
|
|
if (options.fullCodecString !== void 0 && typeof options.fullCodecString !== "string") {
|
|
throw new TypeError("fullCodecString, when provided, must be a string.");
|
|
}
|
|
if (options.fullCodecString !== void 0 && inferCodecFromCodecString(options.fullCodecString) !== codec) {
|
|
throw new TypeError(
|
|
`fullCodecString, when provided, must be a string that matches the specified codec (${codec}).`
|
|
);
|
|
}
|
|
};
|
|
var buildAudioEncoderConfig = (options) => {
|
|
const resolvedBitrate = options.bitrate instanceof Quality ? options.bitrate._toAudioBitrate(options.codec) : options.bitrate;
|
|
return {
|
|
codec: options.fullCodecString ?? buildAudioCodecString(
|
|
options.codec,
|
|
options.numberOfChannels,
|
|
options.sampleRate
|
|
),
|
|
numberOfChannels: options.numberOfChannels,
|
|
sampleRate: options.sampleRate,
|
|
bitrate: resolvedBitrate,
|
|
bitrateMode: options.bitrateMode,
|
|
...getAudioEncoderConfigExtension(options.codec)
|
|
};
|
|
};
|
|
var Quality = class {
|
|
/** @internal */
|
|
constructor(factor) {
|
|
this._factor = factor;
|
|
}
|
|
/** @internal */
|
|
_toVideoBitrate(codec, width, height) {
|
|
const pixels = width * height;
|
|
const codecEfficiencyFactors = {
|
|
avc: 1,
|
|
// H.264/AVC (baseline)
|
|
hevc: 0.6,
|
|
// H.265/HEVC (~40% more efficient than AVC)
|
|
vp9: 0.6,
|
|
// Similar to HEVC
|
|
av1: 0.4,
|
|
// ~60% more efficient than AVC
|
|
vp8: 1.2
|
|
// Slightly less efficient than AVC
|
|
};
|
|
const referencePixels = 1920 * 1080;
|
|
const referenceBitrate = 3e6;
|
|
const scaleFactor = Math.pow(pixels / referencePixels, 0.95);
|
|
const baseBitrate = referenceBitrate * scaleFactor;
|
|
const codecAdjustedBitrate = baseBitrate * codecEfficiencyFactors[codec];
|
|
const finalBitrate = codecAdjustedBitrate * this._factor;
|
|
return Math.ceil(finalBitrate / 1e3) * 1e3;
|
|
}
|
|
/** @internal */
|
|
_toAudioBitrate(codec) {
|
|
if (PCM_AUDIO_CODECS.includes(codec) || codec === "flac") {
|
|
return void 0;
|
|
}
|
|
const baseRates = {
|
|
aac: 128e3,
|
|
// 128kbps base for AAC
|
|
opus: 64e3,
|
|
// 64kbps base for Opus
|
|
mp3: 16e4,
|
|
// 160kbps base for MP3
|
|
vorbis: 64e3,
|
|
// 64kbps base for Vorbis
|
|
ac3: 384e3,
|
|
// 384kbps base for AC-3
|
|
eac3: 192e3
|
|
// 192kbps base for E-AC-3
|
|
};
|
|
const baseBitrate = baseRates[codec];
|
|
if (!baseBitrate) {
|
|
throw new Error(`Unhandled codec: ${codec}`);
|
|
}
|
|
let finalBitrate = baseBitrate * this._factor;
|
|
if (codec === "aac") {
|
|
const validRates = [96e3, 128e3, 16e4, 192e3];
|
|
finalBitrate = validRates.reduce(
|
|
(prev, curr) => Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev
|
|
);
|
|
} else if (codec === "opus" || codec === "vorbis") {
|
|
finalBitrate = Math.max(6e3, finalBitrate);
|
|
} else if (codec === "mp3") {
|
|
const validRates = [
|
|
8e3,
|
|
16e3,
|
|
24e3,
|
|
32e3,
|
|
4e4,
|
|
48e3,
|
|
64e3,
|
|
8e4,
|
|
96e3,
|
|
112e3,
|
|
128e3,
|
|
16e4,
|
|
192e3,
|
|
224e3,
|
|
256e3,
|
|
32e4
|
|
];
|
|
finalBitrate = validRates.reduce(
|
|
(prev, curr) => Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev
|
|
);
|
|
}
|
|
return Math.round(finalBitrate / 1e3) * 1e3;
|
|
}
|
|
};
|
|
var QUALITY_VERY_LOW = /* @__PURE__ */ new Quality(0.3);
|
|
var QUALITY_LOW = /* @__PURE__ */ new Quality(0.6);
|
|
var QUALITY_MEDIUM = /* @__PURE__ */ new Quality(1);
|
|
var QUALITY_HIGH = /* @__PURE__ */ new Quality(2);
|
|
var QUALITY_VERY_HIGH = /* @__PURE__ */ new Quality(4);
|
|
var canEncode = (codec) => {
|
|
if (VIDEO_CODECS.includes(codec)) {
|
|
return canEncodeVideo(codec);
|
|
} else if (AUDIO_CODECS.includes(codec)) {
|
|
return canEncodeAudio(codec);
|
|
} else if (SUBTITLE_CODECS.includes(codec)) {
|
|
return canEncodeSubtitles(codec);
|
|
}
|
|
throw new TypeError(`Unknown codec '${codec}'.`);
|
|
};
|
|
var canEncodeVideo = async (codec, options = {}) => {
|
|
const {
|
|
width = 1280,
|
|
height = 720,
|
|
bitrate = 1e6,
|
|
...restOptions
|
|
} = options;
|
|
if (!VIDEO_CODECS.includes(codec)) {
|
|
return false;
|
|
}
|
|
if (!Number.isInteger(width) || width <= 0) {
|
|
throw new TypeError("width must be a positive integer.");
|
|
}
|
|
if (!Number.isInteger(height) || height <= 0) {
|
|
throw new TypeError("height must be a positive integer.");
|
|
}
|
|
if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
|
|
throw new TypeError("bitrate must be a positive integer or a quality.");
|
|
}
|
|
validateVideoEncodingAdditionalOptions(codec, restOptions);
|
|
let encoderConfig = null;
|
|
if (customVideoEncoders.length > 0) {
|
|
encoderConfig ??= buildVideoEncoderConfig({
|
|
codec,
|
|
width,
|
|
height,
|
|
bitrate,
|
|
framerate: void 0,
|
|
...restOptions
|
|
});
|
|
if (customVideoEncoders.some((x) => x.supports(codec, encoderConfig))) {
|
|
return true;
|
|
}
|
|
}
|
|
if (typeof VideoEncoder === "undefined") {
|
|
return false;
|
|
}
|
|
const hasOddDimension = width % 2 === 1 || height % 2 === 1;
|
|
if (hasOddDimension && (codec === "avc" || codec === "hevc")) {
|
|
return false;
|
|
}
|
|
encoderConfig ??= buildVideoEncoderConfig({
|
|
codec,
|
|
width,
|
|
height,
|
|
bitrate,
|
|
framerate: void 0,
|
|
...restOptions,
|
|
alpha: "discard"
|
|
// Since we handle alpha ourselves
|
|
});
|
|
const support = await VideoEncoder.isConfigSupported(encoderConfig);
|
|
if (!support.supported) {
|
|
return false;
|
|
}
|
|
if (isFirefox()) {
|
|
return new Promise(async (resolve) => {
|
|
try {
|
|
const encoder = new VideoEncoder({
|
|
output: () => {
|
|
},
|
|
error: () => resolve(false)
|
|
});
|
|
encoder.configure(encoderConfig);
|
|
const frameData = new Uint8Array(width * height * 4);
|
|
const frame = new VideoFrame(frameData, {
|
|
format: "RGBA",
|
|
codedWidth: width,
|
|
codedHeight: height,
|
|
timestamp: 0
|
|
});
|
|
encoder.encode(frame);
|
|
frame.close();
|
|
await encoder.flush();
|
|
resolve(true);
|
|
} catch {
|
|
resolve(false);
|
|
}
|
|
});
|
|
} else {
|
|
return true;
|
|
}
|
|
};
|
|
var canEncodeAudio = async (codec, options = {}) => {
|
|
const {
|
|
numberOfChannels = 2,
|
|
sampleRate = 48e3,
|
|
bitrate = 128e3,
|
|
...restOptions
|
|
} = options;
|
|
if (!AUDIO_CODECS.includes(codec)) {
|
|
return false;
|
|
}
|
|
if (!Number.isInteger(numberOfChannels) || numberOfChannels <= 0) {
|
|
throw new TypeError("numberOfChannels must be a positive integer.");
|
|
}
|
|
if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
|
|
throw new TypeError("sampleRate must be a positive integer.");
|
|
}
|
|
if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
|
|
throw new TypeError("bitrate must be a positive integer.");
|
|
}
|
|
validateAudioEncodingAdditionalOptions(codec, restOptions);
|
|
let encoderConfig = null;
|
|
if (customAudioEncoders.length > 0) {
|
|
encoderConfig ??= buildAudioEncoderConfig({
|
|
codec,
|
|
numberOfChannels,
|
|
sampleRate,
|
|
bitrate,
|
|
...restOptions
|
|
});
|
|
if (customAudioEncoders.some((x) => x.supports(codec, encoderConfig))) {
|
|
return true;
|
|
}
|
|
}
|
|
if (PCM_AUDIO_CODECS.includes(codec)) {
|
|
return true;
|
|
}
|
|
if (typeof AudioEncoder === "undefined") {
|
|
return false;
|
|
}
|
|
encoderConfig ??= buildAudioEncoderConfig({
|
|
codec,
|
|
numberOfChannels,
|
|
sampleRate,
|
|
bitrate,
|
|
...restOptions
|
|
});
|
|
const support = await AudioEncoder.isConfigSupported(encoderConfig);
|
|
return support.supported === true;
|
|
};
|
|
var canEncodeSubtitles = async (codec) => {
|
|
if (!SUBTITLE_CODECS.includes(codec)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
var getEncodableCodecs = async () => {
|
|
const [videoCodecs, audioCodecs, subtitleCodecs] = await Promise.all([
|
|
getEncodableVideoCodecs(),
|
|
getEncodableAudioCodecs(),
|
|
getEncodableSubtitleCodecs()
|
|
]);
|
|
return [...videoCodecs, ...audioCodecs, ...subtitleCodecs];
|
|
};
|
|
var getEncodableVideoCodecs = async (checkedCodecs = VIDEO_CODECS, options) => {
|
|
const bools = await Promise.all(checkedCodecs.map((codec) => canEncodeVideo(codec, options)));
|
|
return checkedCodecs.filter((_, i) => bools[i]);
|
|
};
|
|
var getEncodableAudioCodecs = async (checkedCodecs = AUDIO_CODECS, options) => {
|
|
const bools = await Promise.all(checkedCodecs.map((codec) => canEncodeAudio(codec, options)));
|
|
return checkedCodecs.filter((_, i) => bools[i]);
|
|
};
|
|
var getEncodableSubtitleCodecs = async (checkedCodecs = SUBTITLE_CODECS) => {
|
|
const bools = await Promise.all(checkedCodecs.map(canEncodeSubtitles));
|
|
return checkedCodecs.filter((_, i) => bools[i]);
|
|
};
|
|
var getFirstEncodableVideoCodec = async (checkedCodecs, options) => {
|
|
for (const codec of checkedCodecs) {
|
|
if (await canEncodeVideo(codec, options)) {
|
|
return codec;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
var getFirstEncodableAudioCodec = async (checkedCodecs, options) => {
|
|
for (const codec of checkedCodecs) {
|
|
if (await canEncodeAudio(codec, options)) {
|
|
return codec;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
var getFirstEncodableSubtitleCodec = async (checkedCodecs) => {
|
|
for (const codec of checkedCodecs) {
|
|
if (await canEncodeSubtitles(codec)) {
|
|
return codec;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// src/media-source.ts
|
|
var MediaSource = class {
|
|
constructor() {
|
|
/** @internal */
|
|
this._connectedTrack = null;
|
|
/** @internal */
|
|
this._closingPromise = null;
|
|
/** @internal */
|
|
this._closed = false;
|
|
/**
|
|
* @internal
|
|
* A time offset in seconds that is added to all timestamps generated by this source.
|
|
*/
|
|
this._timestampOffset = 0;
|
|
}
|
|
/** @internal */
|
|
_ensureValidAdd() {
|
|
if (!this._connectedTrack) {
|
|
throw new Error("Source is not connected to an output track.");
|
|
}
|
|
if (this._connectedTrack.output.state === "canceled") {
|
|
throw new Error("Output has been canceled.");
|
|
}
|
|
if (this._connectedTrack.output.state === "finalizing" || this._connectedTrack.output.state === "finalized") {
|
|
throw new Error("Output has been finalized.");
|
|
}
|
|
if (this._connectedTrack.output.state === "pending") {
|
|
throw new Error("Output has not started.");
|
|
}
|
|
if (this._closed) {
|
|
throw new Error("Source is closed.");
|
|
}
|
|
}
|
|
/** @internal */
|
|
async _start() {
|
|
}
|
|
/** @internal */
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
async _flushAndClose(forceClose) {
|
|
}
|
|
/**
|
|
* Closes this source. This prevents future samples from being added and signals to the output file that no further
|
|
* samples will come in for this track. Calling `.close()` is optional but recommended after adding the
|
|
* last sample - for improved performance and reduced memory usage.
|
|
*/
|
|
close() {
|
|
if (this._closingPromise) {
|
|
return;
|
|
}
|
|
const connectedTrack = this._connectedTrack;
|
|
if (!connectedTrack) {
|
|
throw new Error("Cannot call close without connecting the source to an output track.");
|
|
}
|
|
if (connectedTrack.output.state === "pending") {
|
|
throw new Error("Cannot call close before output has been started.");
|
|
}
|
|
this._closingPromise = (async () => {
|
|
await this._flushAndClose(false);
|
|
this._closed = true;
|
|
if (connectedTrack.output.state === "finalizing" || connectedTrack.output.state === "finalized") {
|
|
return;
|
|
}
|
|
connectedTrack.output._muxer.onTrackClose(connectedTrack);
|
|
})();
|
|
}
|
|
/** @internal */
|
|
async _flushOrWaitForOngoingClose(forceClose) {
|
|
return this._closingPromise ??= (async () => {
|
|
await this._flushAndClose(forceClose);
|
|
this._closed = true;
|
|
})();
|
|
}
|
|
};
|
|
var VideoSource = class extends MediaSource {
|
|
/** Internal constructor. */
|
|
constructor(codec) {
|
|
super();
|
|
/** @internal */
|
|
this._connectedTrack = null;
|
|
if (!VIDEO_CODECS.includes(codec)) {
|
|
throw new TypeError(`Invalid video codec '${codec}'. Must be one of: ${VIDEO_CODECS.join(", ")}.`);
|
|
}
|
|
this._codec = codec;
|
|
}
|
|
};
|
|
var EncodedVideoPacketSource = class extends VideoSource {
|
|
/** Creates a new {@link EncodedVideoPacketSource} whose packets are encoded using `codec`. */
|
|
constructor(codec) {
|
|
super(codec);
|
|
}
|
|
/**
|
|
* Adds an encoded packet to the output video track. Packets must be added in *decode order*, while a packet's
|
|
* timestamp must be its *presentation timestamp*. B-frames are handled automatically.
|
|
*
|
|
* @param meta - Additional metadata from the encoder. You should pass this for the first call, including a valid
|
|
* decoder config.
|
|
*
|
|
* @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise
|
|
* to respect writer and encoder backpressure.
|
|
*/
|
|
add(packet, meta) {
|
|
if (!(packet instanceof EncodedPacket)) {
|
|
throw new TypeError("packet must be an EncodedPacket.");
|
|
}
|
|
if (packet.isMetadataOnly) {
|
|
throw new TypeError("Metadata-only packets cannot be added.");
|
|
}
|
|
if (meta !== void 0 && (!meta || typeof meta !== "object")) {
|
|
throw new TypeError("meta, when provided, must be an object.");
|
|
}
|
|
this._ensureValidAdd();
|
|
return this._connectedTrack.output._muxer.addEncodedVideoPacket(this._connectedTrack, packet, meta);
|
|
}
|
|
};
|
|
var VideoEncoderWrapper = class {
|
|
constructor(source, encodingConfig) {
|
|
this.source = source;
|
|
this.encodingConfig = encodingConfig;
|
|
this.ensureEncoderPromise = null;
|
|
this.encoderInitialized = false;
|
|
this.encoder = null;
|
|
this.muxer = null;
|
|
this.lastMultipleOfKeyFrameInterval = -1;
|
|
this.codedWidth = null;
|
|
this.codedHeight = null;
|
|
this.resizeCanvas = null;
|
|
this.customEncoder = null;
|
|
this.customEncoderCallSerializer = new CallSerializer();
|
|
this.customEncoderQueueSize = 0;
|
|
// Alpha stuff
|
|
this.alphaEncoder = null;
|
|
this.splitter = null;
|
|
this.splitterCreationFailed = false;
|
|
this.alphaFrameQueue = [];
|
|
/**
|
|
* Encoders typically throw their errors "out of band", meaning asynchronously in some other execution context.
|
|
* However, we want to surface these errors to the user within the normal control flow, so they don't go uncaught.
|
|
* So, we keep track of the encoder error and throw it as soon as we get the chance.
|
|
*/
|
|
this.error = null;
|
|
}
|
|
async add(videoSample, shouldClose, encodeOptions) {
|
|
try {
|
|
this.checkForEncoderError();
|
|
this.source._ensureValidAdd();
|
|
if (this.codedWidth !== null && this.codedHeight !== null) {
|
|
if (videoSample.codedWidth !== this.codedWidth || videoSample.codedHeight !== this.codedHeight) {
|
|
const sizeChangeBehavior = this.encodingConfig.sizeChangeBehavior ?? "deny";
|
|
if (sizeChangeBehavior === "passThrough") {
|
|
} else if (sizeChangeBehavior === "deny") {
|
|
throw new Error(
|
|
`Video sample size must remain constant. Expected ${this.codedWidth}x${this.codedHeight}, got ${videoSample.codedWidth}x${videoSample.codedHeight}. To allow the sample size to change over time, set \`sizeChangeBehavior\` to a value other than 'strict' in the encoding options.`
|
|
);
|
|
} else {
|
|
let canvasIsNew = false;
|
|
if (!this.resizeCanvas) {
|
|
if (typeof document !== "undefined") {
|
|
this.resizeCanvas = document.createElement("canvas");
|
|
this.resizeCanvas.width = this.codedWidth;
|
|
this.resizeCanvas.height = this.codedHeight;
|
|
} else {
|
|
this.resizeCanvas = new OffscreenCanvas(this.codedWidth, this.codedHeight);
|
|
}
|
|
canvasIsNew = true;
|
|
}
|
|
const context = this.resizeCanvas.getContext("2d", {
|
|
alpha: isFirefox()
|
|
// Firefox has VideoFrame glitches with opaque canvases
|
|
});
|
|
assert(context);
|
|
if (!canvasIsNew) {
|
|
if (isFirefox()) {
|
|
context.fillStyle = "black";
|
|
context.fillRect(0, 0, this.codedWidth, this.codedHeight);
|
|
} else {
|
|
context.clearRect(0, 0, this.codedWidth, this.codedHeight);
|
|
}
|
|
}
|
|
videoSample.drawWithFit(context, { fit: sizeChangeBehavior });
|
|
if (shouldClose) {
|
|
videoSample.close();
|
|
}
|
|
videoSample = new VideoSample(this.resizeCanvas, {
|
|
timestamp: videoSample.timestamp,
|
|
duration: videoSample.duration,
|
|
rotation: videoSample.rotation
|
|
});
|
|
shouldClose = true;
|
|
}
|
|
}
|
|
} else {
|
|
this.codedWidth = videoSample.codedWidth;
|
|
this.codedHeight = videoSample.codedHeight;
|
|
}
|
|
if (!this.encoderInitialized) {
|
|
if (!this.ensureEncoderPromise) {
|
|
this.ensureEncoder(videoSample);
|
|
}
|
|
if (!this.encoderInitialized) {
|
|
await this.ensureEncoderPromise;
|
|
}
|
|
}
|
|
assert(this.encoderInitialized);
|
|
const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 5;
|
|
const multipleOfKeyFrameInterval = Math.floor(videoSample.timestamp / keyFrameInterval);
|
|
const finalEncodeOptions = {
|
|
...encodeOptions,
|
|
keyFrame: encodeOptions?.keyFrame || keyFrameInterval === 0 || multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval
|
|
};
|
|
this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval;
|
|
if (this.customEncoder) {
|
|
this.customEncoderQueueSize++;
|
|
const clonedSample = videoSample.clone();
|
|
const promise = this.customEncoderCallSerializer.call(() => this.customEncoder.encode(clonedSample, finalEncodeOptions)).then(() => this.customEncoderQueueSize--).catch((error) => this.error ??= error).finally(() => {
|
|
clonedSample.close();
|
|
});
|
|
if (this.customEncoderQueueSize >= 4) {
|
|
await promise;
|
|
}
|
|
} else {
|
|
assert(this.encoder);
|
|
const videoFrame = videoSample.toVideoFrame();
|
|
if (!this.alphaEncoder) {
|
|
this.encoder.encode(videoFrame, finalEncodeOptions);
|
|
videoFrame.close();
|
|
} else {
|
|
const frameDefinitelyHasNoAlpha = !!videoFrame.format && !videoFrame.format.includes("A");
|
|
if (frameDefinitelyHasNoAlpha || this.splitterCreationFailed) {
|
|
this.alphaFrameQueue.push(null);
|
|
this.encoder.encode(videoFrame, finalEncodeOptions);
|
|
videoFrame.close();
|
|
} else {
|
|
const width = videoFrame.displayWidth;
|
|
const height = videoFrame.displayHeight;
|
|
if (!this.splitter) {
|
|
try {
|
|
this.splitter = new ColorAlphaSplitter(width, height);
|
|
} catch (error) {
|
|
console.error("Due to an error, only color data will be encoded.", error);
|
|
this.splitterCreationFailed = true;
|
|
this.alphaFrameQueue.push(null);
|
|
this.encoder.encode(videoFrame, finalEncodeOptions);
|
|
videoFrame.close();
|
|
}
|
|
}
|
|
if (this.splitter) {
|
|
const colorFrame = this.splitter.extractColor(videoFrame);
|
|
const alphaFrame = this.splitter.extractAlpha(videoFrame);
|
|
this.alphaFrameQueue.push(alphaFrame);
|
|
this.encoder.encode(colorFrame, finalEncodeOptions);
|
|
colorFrame.close();
|
|
videoFrame.close();
|
|
}
|
|
}
|
|
}
|
|
if (shouldClose) {
|
|
videoSample.close();
|
|
}
|
|
if (this.encoder.encodeQueueSize >= 4) {
|
|
await new Promise((resolve) => this.encoder.addEventListener("dequeue", resolve, { once: true }));
|
|
}
|
|
}
|
|
await this.muxer.mutex.currentPromise;
|
|
} finally {
|
|
if (shouldClose) {
|
|
videoSample.close();
|
|
}
|
|
}
|
|
}
|
|
ensureEncoder(videoSample) {
|
|
this.ensureEncoderPromise = (async () => {
|
|
const encoderConfig = buildVideoEncoderConfig({
|
|
width: videoSample.codedWidth,
|
|
height: videoSample.codedHeight,
|
|
...this.encodingConfig,
|
|
framerate: this.source._connectedTrack?.metadata.frameRate
|
|
});
|
|
this.encodingConfig.onEncoderConfig?.(encoderConfig);
|
|
const MatchingCustomEncoder = customVideoEncoders.find((x) => x.supports(
|
|
this.encodingConfig.codec,
|
|
encoderConfig
|
|
));
|
|
if (MatchingCustomEncoder) {
|
|
this.customEncoder = new MatchingCustomEncoder();
|
|
this.customEncoder.codec = this.encodingConfig.codec;
|
|
this.customEncoder.config = encoderConfig;
|
|
this.customEncoder.onPacket = (packet, meta) => {
|
|
if (!(packet instanceof EncodedPacket)) {
|
|
throw new TypeError("The first argument passed to onPacket must be an EncodedPacket.");
|
|
}
|
|
if (meta !== void 0 && (!meta || typeof meta !== "object")) {
|
|
throw new TypeError("The second argument passed to onPacket must be an object or undefined.");
|
|
}
|
|
this.encodingConfig.onEncodedPacket?.(packet, meta);
|
|
void this.muxer.addEncodedVideoPacket(this.source._connectedTrack, packet, meta).catch((error) => {
|
|
this.error ??= error;
|
|
});
|
|
};
|
|
await this.customEncoder.init();
|
|
} else {
|
|
if (typeof VideoEncoder === "undefined") {
|
|
throw new Error("VideoEncoder is not supported by this browser.");
|
|
}
|
|
encoderConfig.alpha = "discard";
|
|
if (this.encodingConfig.alpha === "keep") {
|
|
encoderConfig.latencyMode = "quality";
|
|
}
|
|
const hasOddDimension = encoderConfig.width % 2 === 1 || encoderConfig.height % 2 === 1;
|
|
if (hasOddDimension && (this.encodingConfig.codec === "avc" || this.encodingConfig.codec === "hevc")) {
|
|
throw new Error(
|
|
`The dimensions ${encoderConfig.width}x${encoderConfig.height} are not supported for codec '${this.encodingConfig.codec}'; both width and height must be even numbers. Make sure to round your dimensions to the nearest even number.`
|
|
);
|
|
}
|
|
const support = await VideoEncoder.isConfigSupported(encoderConfig);
|
|
if (!support.supported) {
|
|
throw new Error(
|
|
`This specific encoder configuration (${encoderConfig.codec}, ${encoderConfig.bitrate} bps, ${encoderConfig.width}x${encoderConfig.height}, hardware acceleration: ${encoderConfig.hardwareAcceleration ?? "no-preference"}) is not supported by this browser. Consider using another codec or changing your video parameters.`
|
|
);
|
|
}
|
|
const colorChunkQueue = [];
|
|
const nullAlphaChunkQueue = [];
|
|
let encodedAlphaChunkCount = 0;
|
|
let alphaEncoderQueue = 0;
|
|
const addPacket = (colorChunk, alphaChunk, meta) => {
|
|
const sideData = {};
|
|
if (alphaChunk) {
|
|
const alphaData = new Uint8Array(alphaChunk.byteLength);
|
|
alphaChunk.copyTo(alphaData);
|
|
sideData.alpha = alphaData;
|
|
}
|
|
const packet = EncodedPacket.fromEncodedChunk(colorChunk, sideData);
|
|
this.encodingConfig.onEncodedPacket?.(packet, meta);
|
|
void this.muxer.addEncodedVideoPacket(this.source._connectedTrack, packet, meta).catch((error) => {
|
|
this.error ??= error;
|
|
});
|
|
};
|
|
const stack = new Error("Encoding error").stack;
|
|
this.encoder = new VideoEncoder({
|
|
output: (chunk, meta) => {
|
|
if (!this.alphaEncoder) {
|
|
addPacket(chunk, null, meta);
|
|
return;
|
|
}
|
|
const alphaFrame = this.alphaFrameQueue.shift();
|
|
assert(alphaFrame !== void 0);
|
|
if (alphaFrame) {
|
|
this.alphaEncoder.encode(alphaFrame, {
|
|
// Crucial: The alpha frame is forced to be a key frame whenever the color frame
|
|
// also is. Without this, playback can glitch and even crash in some browsers.
|
|
// This is the reason why the two encoders are wired in series and not in parallel.
|
|
keyFrame: chunk.type === "key"
|
|
});
|
|
alphaEncoderQueue++;
|
|
alphaFrame.close();
|
|
colorChunkQueue.push({ chunk, meta });
|
|
} else {
|
|
if (alphaEncoderQueue === 0) {
|
|
addPacket(chunk, null, meta);
|
|
} else {
|
|
nullAlphaChunkQueue.push(encodedAlphaChunkCount + alphaEncoderQueue);
|
|
colorChunkQueue.push({ chunk, meta });
|
|
}
|
|
}
|
|
},
|
|
error: (error) => {
|
|
error.stack = stack;
|
|
this.error ??= error;
|
|
}
|
|
});
|
|
this.encoder.configure(encoderConfig);
|
|
if (this.encodingConfig.alpha === "keep") {
|
|
const stack2 = new Error("Encoding error").stack;
|
|
this.alphaEncoder = new VideoEncoder({
|
|
// We ignore the alpha chunk's metadata
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
output: (chunk, meta) => {
|
|
alphaEncoderQueue--;
|
|
const colorChunk = colorChunkQueue.shift();
|
|
assert(colorChunk !== void 0);
|
|
addPacket(colorChunk.chunk, chunk, colorChunk.meta);
|
|
encodedAlphaChunkCount++;
|
|
while (nullAlphaChunkQueue.length > 0 && nullAlphaChunkQueue[0] === encodedAlphaChunkCount) {
|
|
nullAlphaChunkQueue.shift();
|
|
const colorChunk2 = colorChunkQueue.shift();
|
|
assert(colorChunk2 !== void 0);
|
|
addPacket(colorChunk2.chunk, null, colorChunk2.meta);
|
|
}
|
|
},
|
|
error: (error) => {
|
|
error.stack = stack2;
|
|
this.error ??= error;
|
|
}
|
|
});
|
|
this.alphaEncoder.configure(encoderConfig);
|
|
}
|
|
}
|
|
assert(this.source._connectedTrack);
|
|
this.muxer = this.source._connectedTrack.output._muxer;
|
|
this.encoderInitialized = true;
|
|
})();
|
|
}
|
|
async flushAndClose(forceClose) {
|
|
if (!forceClose) this.checkForEncoderError();
|
|
if (this.customEncoder) {
|
|
if (!forceClose) {
|
|
void this.customEncoderCallSerializer.call(() => this.customEncoder.flush());
|
|
}
|
|
await this.customEncoderCallSerializer.call(() => this.customEncoder.close());
|
|
} else if (this.encoder) {
|
|
if (!forceClose) {
|
|
await this.encoder.flush();
|
|
await this.alphaEncoder?.flush();
|
|
}
|
|
if (this.encoder.state !== "closed") {
|
|
this.encoder.close();
|
|
}
|
|
if (this.alphaEncoder && this.alphaEncoder.state !== "closed") {
|
|
this.alphaEncoder.close();
|
|
}
|
|
this.alphaFrameQueue.forEach((x) => x?.close());
|
|
this.splitter?.close();
|
|
}
|
|
if (!forceClose) this.checkForEncoderError();
|
|
}
|
|
getQueueSize() {
|
|
if (this.customEncoder) {
|
|
return this.customEncoderQueueSize;
|
|
} else {
|
|
return this.encoder?.encodeQueueSize ?? 0;
|
|
}
|
|
}
|
|
checkForEncoderError() {
|
|
if (this.error) {
|
|
throw this.error;
|
|
}
|
|
}
|
|
};
|
|
var ColorAlphaSplitter = class {
|
|
constructor(initialWidth, initialHeight) {
|
|
this.lastFrame = null;
|
|
if (typeof OffscreenCanvas !== "undefined") {
|
|
this.canvas = new OffscreenCanvas(initialWidth, initialHeight);
|
|
} else {
|
|
this.canvas = document.createElement("canvas");
|
|
this.canvas.width = initialWidth;
|
|
this.canvas.height = initialHeight;
|
|
}
|
|
const gl = this.canvas.getContext("webgl2", {
|
|
alpha: true
|
|
// Needed due to the YUV thing we do for alpha
|
|
});
|
|
if (!gl) {
|
|
throw new Error("Couldn't acquire WebGL 2 context.");
|
|
}
|
|
this.gl = gl;
|
|
this.colorProgram = this.createColorProgram();
|
|
this.alphaProgram = this.createAlphaProgram();
|
|
this.vao = this.createVAO();
|
|
this.sourceTexture = this.createTexture();
|
|
this.alphaResolutionLocation = this.gl.getUniformLocation(this.alphaProgram, "u_resolution");
|
|
this.gl.useProgram(this.colorProgram);
|
|
this.gl.uniform1i(this.gl.getUniformLocation(this.colorProgram, "u_sourceTexture"), 0);
|
|
this.gl.useProgram(this.alphaProgram);
|
|
this.gl.uniform1i(this.gl.getUniformLocation(this.alphaProgram, "u_sourceTexture"), 0);
|
|
}
|
|
createVertexShader() {
|
|
return this.createShader(this.gl.VERTEX_SHADER, `#version 300 es
|
|
in vec2 a_position;
|
|
in vec2 a_texCoord;
|
|
out vec2 v_texCoord;
|
|
|
|
void main() {
|
|
gl_Position = vec4(a_position, 0.0, 1.0);
|
|
v_texCoord = a_texCoord;
|
|
}
|
|
`);
|
|
}
|
|
createColorProgram() {
|
|
const vertexShader = this.createVertexShader();
|
|
const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, `#version 300 es
|
|
precision highp float;
|
|
|
|
uniform sampler2D u_sourceTexture;
|
|
in vec2 v_texCoord;
|
|
out vec4 fragColor;
|
|
|
|
void main() {
|
|
vec4 source = texture(u_sourceTexture, v_texCoord);
|
|
fragColor = vec4(source.rgb, 1.0);
|
|
}
|
|
`);
|
|
const program = this.gl.createProgram();
|
|
this.gl.attachShader(program, vertexShader);
|
|
this.gl.attachShader(program, fragmentShader);
|
|
this.gl.linkProgram(program);
|
|
return program;
|
|
}
|
|
createAlphaProgram() {
|
|
const vertexShader = this.createVertexShader();
|
|
const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, `#version 300 es
|
|
precision highp float;
|
|
|
|
uniform sampler2D u_sourceTexture;
|
|
uniform vec2 u_resolution; // The width and height of the canvas
|
|
in vec2 v_texCoord;
|
|
out vec4 fragColor;
|
|
|
|
// This function determines the value for a single byte in the YUV stream
|
|
float getByteValue(float byteOffset) {
|
|
float width = u_resolution.x;
|
|
float height = u_resolution.y;
|
|
|
|
float yPlaneSize = width * height;
|
|
|
|
if (byteOffset < yPlaneSize) {
|
|
// This byte is in the luma plane. Find the corresponding pixel coordinates to sample from
|
|
float y = floor(byteOffset / width);
|
|
float x = mod(byteOffset, width);
|
|
|
|
// Add 0.5 to sample the center of the texel
|
|
vec2 sampleCoord = (vec2(x, y) + 0.5) / u_resolution;
|
|
|
|
// The luma value is the alpha from the source texture
|
|
return texture(u_sourceTexture, sampleCoord).a;
|
|
} else {
|
|
// Write a fixed value for chroma and beyond
|
|
return 128.0 / 255.0;
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
// Each fragment writes 4 bytes (R, G, B, A)
|
|
float pixelIndex = floor(gl_FragCoord.y) * u_resolution.x + floor(gl_FragCoord.x);
|
|
float baseByteOffset = pixelIndex * 4.0;
|
|
|
|
vec4 result;
|
|
for (int i = 0; i < 4; i++) {
|
|
float currentByteOffset = baseByteOffset + float(i);
|
|
result[i] = getByteValue(currentByteOffset);
|
|
}
|
|
|
|
fragColor = result;
|
|
}
|
|
`);
|
|
const program = this.gl.createProgram();
|
|
this.gl.attachShader(program, vertexShader);
|
|
this.gl.attachShader(program, fragmentShader);
|
|
this.gl.linkProgram(program);
|
|
return program;
|
|
}
|
|
createShader(type, source) {
|
|
const shader = this.gl.createShader(type);
|
|
this.gl.shaderSource(shader, source);
|
|
this.gl.compileShader(shader);
|
|
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
|
|
console.error("Shader compile error:", this.gl.getShaderInfoLog(shader));
|
|
}
|
|
return shader;
|
|
}
|
|
createVAO() {
|
|
const vao = this.gl.createVertexArray();
|
|
this.gl.bindVertexArray(vao);
|
|
const vertices = new Float32Array([
|
|
-1,
|
|
-1,
|
|
0,
|
|
1,
|
|
1,
|
|
-1,
|
|
1,
|
|
1,
|
|
-1,
|
|
1,
|
|
0,
|
|
0,
|
|
1,
|
|
1,
|
|
1,
|
|
0
|
|
]);
|
|
const buffer = this.gl.createBuffer();
|
|
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer);
|
|
this.gl.bufferData(this.gl.ARRAY_BUFFER, vertices, this.gl.STATIC_DRAW);
|
|
const positionLocation = this.gl.getAttribLocation(this.colorProgram, "a_position");
|
|
const texCoordLocation = this.gl.getAttribLocation(this.colorProgram, "a_texCoord");
|
|
this.gl.enableVertexAttribArray(positionLocation);
|
|
this.gl.vertexAttribPointer(positionLocation, 2, this.gl.FLOAT, false, 16, 0);
|
|
this.gl.enableVertexAttribArray(texCoordLocation);
|
|
this.gl.vertexAttribPointer(texCoordLocation, 2, this.gl.FLOAT, false, 16, 8);
|
|
return vao;
|
|
}
|
|
createTexture() {
|
|
const texture = this.gl.createTexture();
|
|
this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
|
|
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
|
|
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
|
|
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
|
|
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
|
|
return texture;
|
|
}
|
|
updateTexture(sourceFrame) {
|
|
if (this.lastFrame === sourceFrame) {
|
|
return;
|
|
}
|
|
if (sourceFrame.displayWidth !== this.canvas.width || sourceFrame.displayHeight !== this.canvas.height) {
|
|
this.canvas.width = sourceFrame.displayWidth;
|
|
this.canvas.height = sourceFrame.displayHeight;
|
|
}
|
|
this.gl.activeTexture(this.gl.TEXTURE0);
|
|
this.gl.bindTexture(this.gl.TEXTURE_2D, this.sourceTexture);
|
|
this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, sourceFrame);
|
|
this.lastFrame = sourceFrame;
|
|
}
|
|
extractColor(sourceFrame) {
|
|
this.updateTexture(sourceFrame);
|
|
this.gl.useProgram(this.colorProgram);
|
|
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
|
this.gl.bindVertexArray(this.vao);
|
|
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
|
return new VideoFrame(this.canvas, {
|
|
timestamp: sourceFrame.timestamp,
|
|
duration: sourceFrame.duration ?? void 0,
|
|
alpha: "discard"
|
|
});
|
|
}
|
|
extractAlpha(sourceFrame) {
|
|
this.updateTexture(sourceFrame);
|
|
this.gl.useProgram(this.alphaProgram);
|
|
this.gl.uniform2f(this.alphaResolutionLocation, this.canvas.width, this.canvas.height);
|
|
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
|
this.gl.bindVertexArray(this.vao);
|
|
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
|
const { width, height } = this.canvas;
|
|
const chromaSamples = Math.ceil(width / 2) * Math.ceil(height / 2);
|
|
const yuvSize = width * height + chromaSamples * 2;
|
|
const requiredHeight = Math.ceil(yuvSize / (width * 4));
|
|
let yuv = new Uint8Array(4 * width * requiredHeight);
|
|
this.gl.readPixels(0, 0, width, requiredHeight, this.gl.RGBA, this.gl.UNSIGNED_BYTE, yuv);
|
|
yuv = yuv.subarray(0, yuvSize);
|
|
assert(yuv[width * height] === 128);
|
|
assert(yuv[yuv.length - 1] === 128);
|
|
const init = {
|
|
format: "I420",
|
|
codedWidth: width,
|
|
codedHeight: height,
|
|
timestamp: sourceFrame.timestamp,
|
|
duration: sourceFrame.duration ?? void 0,
|
|
transfer: [yuv.buffer]
|
|
};
|
|
return new VideoFrame(yuv, init);
|
|
}
|
|
close() {
|
|
this.gl.getExtension("WEBGL_lose_context")?.loseContext();
|
|
this.gl = null;
|
|
}
|
|
};
|
|
var VideoSampleSource = class extends VideoSource {
|
|
/**
|
|
* Creates a new {@link VideoSampleSource} whose samples are encoded according to the specified
|
|
* {@link VideoEncodingConfig}.
|
|
*/
|
|
constructor(encodingConfig) {
|
|
validateVideoEncodingConfig(encodingConfig);
|
|
super(encodingConfig.codec);
|
|
this._encoder = new VideoEncoderWrapper(this, encodingConfig);
|
|
}
|
|
/**
|
|
* Encodes a video sample (frame) and then adds it to the output.
|
|
*
|
|
* @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise
|
|
* to respect writer and encoder backpressure.
|
|
*/
|
|
add(videoSample, encodeOptions) {
|
|
if (!(videoSample instanceof VideoSample)) {
|
|
throw new TypeError("videoSample must be a VideoSample.");
|
|
}
|
|
return this._encoder.add(videoSample, false, encodeOptions);
|
|
}
|
|
/** @internal */
|
|
_flushAndClose(forceClose) {
|
|
return this._encoder.flushAndClose(forceClose);
|
|
}
|
|
};
|
|
var CanvasSource = class extends VideoSource {
|
|
/**
|
|
* Creates a new {@link CanvasSource} from a canvas element or `OffscreenCanvas` whose samples are encoded
|
|
* according to the specified {@link VideoEncodingConfig}.
|
|
*/
|
|
constructor(canvas, encodingConfig) {
|
|
if (!(typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) && !(typeof OffscreenCanvas !== "undefined" && canvas instanceof OffscreenCanvas)) {
|
|
throw new TypeError("canvas must be an HTMLCanvasElement or OffscreenCanvas.");
|
|
}
|
|
validateVideoEncodingConfig(encodingConfig);
|
|
super(encodingConfig.codec);
|
|
this._encoder = new VideoEncoderWrapper(this, encodingConfig);
|
|
this._canvas = canvas;
|
|
}
|
|
/**
|
|
* Captures the current canvas state as a video sample (frame), encodes it and adds it to the output.
|
|
*
|
|
* @param timestamp - The timestamp of the sample, in seconds.
|
|
* @param duration - The duration of the sample, in seconds.
|
|
*
|
|
* @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise
|
|
* to respect writer and encoder backpressure.
|
|
*/
|
|
add(timestamp, duration = 0, encodeOptions) {
|
|
if (!Number.isFinite(timestamp) || timestamp < 0) {
|
|
throw new TypeError("timestamp must be a non-negative number.");
|
|
}
|
|
if (!Number.isFinite(duration) || duration < 0) {
|
|
throw new TypeError("duration must be a non-negative number.");
|
|
}
|
|
const sample = new VideoSample(this._canvas, { timestamp, duration });
|
|
return this._encoder.add(sample, true, encodeOptions);
|
|
}
|
|
/** @internal */
|
|
_flushAndClose(forceClose) {
|
|
return this._encoder.flushAndClose(forceClose);
|
|
}
|
|
};
|
|
var MediaStreamVideoTrackSource = class extends VideoSource {
|
|
/**
|
|
* Creates a new {@link MediaStreamVideoTrackSource} from a
|
|
* [`MediaStreamVideoTrack`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack), which will pull
|
|
* video samples from the stream in real time and encode them according to {@link VideoEncodingConfig}.
|
|
*/
|
|
constructor(track, encodingConfig) {
|
|
if (!(track instanceof MediaStreamTrack) || track.kind !== "video") {
|
|
throw new TypeError("track must be a video MediaStreamTrack.");
|
|
}
|
|
validateVideoEncodingConfig(encodingConfig);
|
|
encodingConfig = {
|
|
...encodingConfig,
|
|
latencyMode: "realtime"
|
|
};
|
|
super(encodingConfig.codec);
|
|
/** @internal */
|
|
this._abortController = null;
|
|
/** @internal */
|
|
this._workerTrackId = null;
|
|
/** @internal */
|
|
this._workerListener = null;
|
|
/** @internal */
|
|
this._promiseWithResolvers = promiseWithResolvers();
|
|
/** @internal */
|
|
this._errorPromiseAccessed = false;
|
|
/** @internal */
|
|
this._paused = false;
|
|
/** @internal */
|
|
this._lastSampleTimestamp = null;
|
|
/** @internal */
|
|
this._pauseOffset = 0;
|
|
this._encoder = new VideoEncoderWrapper(this, encodingConfig);
|
|
this._track = track;
|
|
}
|
|
/** A promise that rejects upon any error within this source. This promise never resolves. */
|
|
get errorPromise() {
|
|
this._errorPromiseAccessed = true;
|
|
return this._promiseWithResolvers.promise;
|
|
}
|
|
/** Whether this source is currently paused as a result of calling `.pause()`. */
|
|
get paused() {
|
|
return this._paused;
|
|
}
|
|
/** @internal */
|
|
async _start() {
|
|
if (!this._errorPromiseAccessed) {
|
|
console.warn(
|
|
"Make sure not to ignore the `errorPromise` field on MediaStreamVideoTrackSource, so that any internal errors get bubbled up properly."
|
|
);
|
|
}
|
|
this._abortController = new AbortController();
|
|
let firstVideoFrameTimestamp = null;
|
|
let errored = false;
|
|
const onVideoFrame = (videoFrame) => {
|
|
if (errored) {
|
|
videoFrame.close();
|
|
return;
|
|
}
|
|
const currentTimestamp = videoFrame.timestamp / 1e6;
|
|
if (this._paused) {
|
|
const frameSeen = firstVideoFrameTimestamp !== null;
|
|
if (frameSeen) {
|
|
if (this._lastSampleTimestamp !== null) {
|
|
const timeDelta = currentTimestamp - this._lastSampleTimestamp;
|
|
this._pauseOffset -= timeDelta;
|
|
}
|
|
this._lastSampleTimestamp = currentTimestamp;
|
|
}
|
|
videoFrame.close();
|
|
return;
|
|
}
|
|
if (firstVideoFrameTimestamp === null) {
|
|
firstVideoFrameTimestamp = currentTimestamp;
|
|
const muxer = this._connectedTrack.output._muxer;
|
|
if (muxer.firstMediaStreamTimestamp === null) {
|
|
muxer.firstMediaStreamTimestamp = performance.now() / 1e3;
|
|
this._timestampOffset = -firstVideoFrameTimestamp;
|
|
} else {
|
|
this._timestampOffset = performance.now() / 1e3 - muxer.firstMediaStreamTimestamp - firstVideoFrameTimestamp;
|
|
}
|
|
}
|
|
this._lastSampleTimestamp = currentTimestamp;
|
|
if (this._encoder.getQueueSize() >= 4) {
|
|
videoFrame.close();
|
|
return;
|
|
}
|
|
const sample = new VideoSample(videoFrame, {
|
|
timestamp: currentTimestamp + this._pauseOffset
|
|
});
|
|
void this._encoder.add(sample, true).catch((error) => {
|
|
errored = true;
|
|
this._abortController?.abort();
|
|
this._promiseWithResolvers.reject(error);
|
|
if (this._workerTrackId !== null) {
|
|
sendMessageToMediaStreamTrackProcessorWorker({
|
|
type: "stopTrack",
|
|
trackId: this._workerTrackId
|
|
});
|
|
}
|
|
});
|
|
};
|
|
if (typeof MediaStreamTrackProcessor !== "undefined") {
|
|
const processor = new MediaStreamTrackProcessor({ track: this._track });
|
|
const consumer = new WritableStream({ write: onVideoFrame });
|
|
processor.readable.pipeTo(consumer, {
|
|
signal: this._abortController.signal
|
|
}).catch((error) => {
|
|
if (error instanceof DOMException && error.name === "AbortError") return;
|
|
this._promiseWithResolvers.reject(error);
|
|
});
|
|
} else {
|
|
const supportedInWorker = await mediaStreamTrackProcessorIsSupportedInWorker();
|
|
if (supportedInWorker) {
|
|
this._workerTrackId = nextMediaStreamTrackProcessorWorkerId++;
|
|
sendMessageToMediaStreamTrackProcessorWorker({
|
|
type: "videoTrack",
|
|
trackId: this._workerTrackId,
|
|
track: this._track
|
|
});
|
|
this._workerListener = (event) => {
|
|
const message = event.data;
|
|
if (message.type === "videoFrame" && message.trackId === this._workerTrackId) {
|
|
onVideoFrame(message.videoFrame);
|
|
} else if (message.type === "error" && message.trackId === this._workerTrackId) {
|
|
this._promiseWithResolvers.reject(message.error);
|
|
}
|
|
};
|
|
mediaStreamTrackProcessorWorker.addEventListener("message", this._workerListener);
|
|
} else {
|
|
throw new Error("MediaStreamTrackProcessor is required but not supported by this browser.");
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Pauses the capture of video frames - any video frames emitted by the underlying media stream will be ignored
|
|
* while paused. This does *not* close the underlying `MediaStreamVideoTrack`, it just ignores its output.
|
|
*/
|
|
pause() {
|
|
this._paused = true;
|
|
}
|
|
/** Resumes the capture of video frames after being paused. */
|
|
resume() {
|
|
this._paused = false;
|
|
}
|
|
/** @internal */
|
|
async _flushAndClose(forceClose) {
|
|
if (this._abortController) {
|
|
this._abortController.abort();
|
|
this._abortController = null;
|
|
}
|
|
if (this._workerTrackId !== null) {
|
|
assert(this._workerListener);
|
|
sendMessageToMediaStreamTrackProcessorWorker({
|
|
type: "stopTrack",
|
|
trackId: this._workerTrackId
|
|
});
|
|
await new Promise((resolve) => {
|
|
const listener = (event) => {
|
|
const message = event.data;
|
|
if (message.type === "trackStopped" && message.trackId === this._workerTrackId) {
|
|
assert(this._workerListener);
|
|
mediaStreamTrackProcessorWorker.removeEventListener("message", this._workerListener);
|
|
mediaStreamTrackProcessorWorker.removeEventListener("message", listener);
|
|
resolve();
|
|
}
|
|
};
|
|
mediaStreamTrackProcessorWorker.addEventListener("message", listener);
|
|
});
|
|
}
|
|
await this._encoder.flushAndClose(forceClose);
|
|
}
|
|
};
|
|
var AudioSource = class extends MediaSource {
|
|
/** Internal constructor. */
|
|
constructor(codec) {
|
|
super();
|
|
/** @internal */
|
|
this._connectedTrack = null;
|
|
if (!AUDIO_CODECS.includes(codec)) {
|
|
throw new TypeError(`Invalid audio codec '${codec}'. Must be one of: ${AUDIO_CODECS.join(", ")}.`);
|
|
}
|
|
this._codec = codec;
|
|
}
|
|
};
|
|
var EncodedAudioPacketSource = class extends AudioSource {
|
|
/** Creates a new {@link EncodedAudioPacketSource} whose packets are encoded using `codec`. */
|
|
constructor(codec) {
|
|
super(codec);
|
|
}
|
|
/**
|
|
* Adds an encoded packet to the output audio track. Packets must be added in *decode order*.
|
|
*
|
|
* @param meta - Additional metadata from the encoder. You should pass this for the first call, including a valid
|
|
* decoder config.
|
|
*
|
|
* @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise
|
|
* to respect writer and encoder backpressure.
|
|
*/
|
|
add(packet, meta) {
|
|
if (!(packet instanceof EncodedPacket)) {
|
|
throw new TypeError("packet must be an EncodedPacket.");
|
|
}
|
|
if (packet.isMetadataOnly) {
|
|
throw new TypeError("Metadata-only packets cannot be added.");
|
|
}
|
|
if (meta !== void 0 && (!meta || typeof meta !== "object")) {
|
|
throw new TypeError("meta, when provided, must be an object.");
|
|
}
|
|
this._ensureValidAdd();
|
|
return this._connectedTrack.output._muxer.addEncodedAudioPacket(this._connectedTrack, packet, meta);
|
|
}
|
|
};
|
|
var AudioEncoderWrapper = class {
|
|
constructor(source, encodingConfig) {
|
|
this.source = source;
|
|
this.encodingConfig = encodingConfig;
|
|
this.ensureEncoderPromise = null;
|
|
this.encoderInitialized = false;
|
|
this.encoder = null;
|
|
this.muxer = null;
|
|
this.lastNumberOfChannels = null;
|
|
this.lastSampleRate = null;
|
|
this.isPcmEncoder = false;
|
|
this.outputSampleSize = null;
|
|
this.writeOutputValue = null;
|
|
this.customEncoder = null;
|
|
this.customEncoderCallSerializer = new CallSerializer();
|
|
this.customEncoderQueueSize = 0;
|
|
this.lastEndSampleIndex = null;
|
|
/**
|
|
* Encoders typically throw their errors "out of band", meaning asynchronously in some other execution context.
|
|
* However, we want to surface these errors to the user within the normal control flow, so they don't go uncaught.
|
|
* So, we keep track of the encoder error and throw it as soon as we get the chance.
|
|
*/
|
|
this.error = null;
|
|
}
|
|
async add(audioSample, shouldClose) {
|
|
try {
|
|
this.checkForEncoderError();
|
|
this.source._ensureValidAdd();
|
|
if (this.lastNumberOfChannels !== null && this.lastSampleRate !== null) {
|
|
if (audioSample.numberOfChannels !== this.lastNumberOfChannels || audioSample.sampleRate !== this.lastSampleRate) {
|
|
throw new Error(
|
|
`Audio parameters must remain constant. Expected ${this.lastNumberOfChannels} channels at ${this.lastSampleRate} Hz, got ${audioSample.numberOfChannels} channels at ${audioSample.sampleRate} Hz.`
|
|
);
|
|
}
|
|
} else {
|
|
this.lastNumberOfChannels = audioSample.numberOfChannels;
|
|
this.lastSampleRate = audioSample.sampleRate;
|
|
}
|
|
if (!this.encoderInitialized) {
|
|
if (!this.ensureEncoderPromise) {
|
|
this.ensureEncoder(audioSample);
|
|
}
|
|
if (!this.encoderInitialized) {
|
|
await this.ensureEncoderPromise;
|
|
}
|
|
}
|
|
assert(this.encoderInitialized);
|
|
{
|
|
const startSampleIndex = Math.round(
|
|
audioSample.timestamp * audioSample.sampleRate
|
|
);
|
|
const endSampleIndex = Math.round(
|
|
(audioSample.timestamp + audioSample.duration) * audioSample.sampleRate
|
|
);
|
|
if (this.lastEndSampleIndex === null) {
|
|
this.lastEndSampleIndex = endSampleIndex;
|
|
} else {
|
|
const sampleDiff = startSampleIndex - this.lastEndSampleIndex;
|
|
if (sampleDiff >= 64) {
|
|
const fillSample = new AudioSample({
|
|
data: new Float32Array(sampleDiff * audioSample.numberOfChannels),
|
|
format: "f32-planar",
|
|
sampleRate: audioSample.sampleRate,
|
|
numberOfChannels: audioSample.numberOfChannels,
|
|
numberOfFrames: sampleDiff,
|
|
timestamp: this.lastEndSampleIndex / audioSample.sampleRate
|
|
});
|
|
await this.add(fillSample, true);
|
|
}
|
|
this.lastEndSampleIndex += audioSample.numberOfFrames;
|
|
}
|
|
}
|
|
if (this.customEncoder) {
|
|
this.customEncoderQueueSize++;
|
|
const clonedSample = audioSample.clone();
|
|
const promise = this.customEncoderCallSerializer.call(() => this.customEncoder.encode(clonedSample)).then(() => this.customEncoderQueueSize--).catch((error) => this.error ??= error).finally(() => {
|
|
clonedSample.close();
|
|
});
|
|
if (this.customEncoderQueueSize >= 4) {
|
|
await promise;
|
|
}
|
|
await this.muxer.mutex.currentPromise;
|
|
} else if (this.isPcmEncoder) {
|
|
await this.doPcmEncoding(audioSample, shouldClose);
|
|
} else {
|
|
assert(this.encoder);
|
|
const audioData = audioSample.toAudioData();
|
|
this.encoder.encode(audioData);
|
|
audioData.close();
|
|
if (shouldClose) {
|
|
audioSample.close();
|
|
}
|
|
if (this.encoder.encodeQueueSize >= 4) {
|
|
await new Promise((resolve) => this.encoder.addEventListener("dequeue", resolve, { once: true }));
|
|
}
|
|
await this.muxer.mutex.currentPromise;
|
|
}
|
|
} finally {
|
|
if (shouldClose) {
|
|
audioSample.close();
|
|
}
|
|
}
|
|
}
|
|
async doPcmEncoding(audioSample, shouldClose) {
|
|
assert(this.outputSampleSize);
|
|
assert(this.writeOutputValue);
|
|
const { numberOfChannels, numberOfFrames, sampleRate, timestamp } = audioSample;
|
|
const CHUNK_SIZE = 2048;
|
|
const outputs = [];
|
|
for (let frame = 0; frame < numberOfFrames; frame += CHUNK_SIZE) {
|
|
const frameCount = Math.min(CHUNK_SIZE, audioSample.numberOfFrames - frame);
|
|
const outputSize = frameCount * numberOfChannels * this.outputSampleSize;
|
|
const outputBuffer = new ArrayBuffer(outputSize);
|
|
const outputView = new DataView(outputBuffer);
|
|
outputs.push({ frameCount, view: outputView });
|
|
}
|
|
const allocationSize = audioSample.allocationSize({ planeIndex: 0, format: "f32-planar" });
|
|
const floats = new Float32Array(allocationSize / Float32Array.BYTES_PER_ELEMENT);
|
|
for (let i = 0; i < numberOfChannels; i++) {
|
|
audioSample.copyTo(floats, { planeIndex: i, format: "f32-planar" });
|
|
for (let j = 0; j < outputs.length; j++) {
|
|
const { frameCount, view: view2 } = outputs[j];
|
|
for (let k = 0; k < frameCount; k++) {
|
|
this.writeOutputValue(
|
|
view2,
|
|
(k * numberOfChannels + i) * this.outputSampleSize,
|
|
floats[j * CHUNK_SIZE + k]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (shouldClose) {
|
|
audioSample.close();
|
|
}
|
|
const meta = {
|
|
decoderConfig: {
|
|
codec: this.encodingConfig.codec,
|
|
numberOfChannels,
|
|
sampleRate
|
|
}
|
|
};
|
|
for (let i = 0; i < outputs.length; i++) {
|
|
const { frameCount, view: view2 } = outputs[i];
|
|
const outputBuffer = view2.buffer;
|
|
const startFrame = i * CHUNK_SIZE;
|
|
const packet = new EncodedPacket(
|
|
new Uint8Array(outputBuffer),
|
|
"key",
|
|
timestamp + startFrame / sampleRate,
|
|
frameCount / sampleRate
|
|
);
|
|
this.encodingConfig.onEncodedPacket?.(packet, meta);
|
|
await this.muxer.addEncodedAudioPacket(this.source._connectedTrack, packet, meta);
|
|
}
|
|
}
|
|
ensureEncoder(audioSample) {
|
|
this.ensureEncoderPromise = (async () => {
|
|
const { numberOfChannels, sampleRate } = audioSample;
|
|
const encoderConfig = buildAudioEncoderConfig({
|
|
numberOfChannels,
|
|
sampleRate,
|
|
...this.encodingConfig
|
|
});
|
|
this.encodingConfig.onEncoderConfig?.(encoderConfig);
|
|
const MatchingCustomEncoder = customAudioEncoders.find((x) => x.supports(
|
|
this.encodingConfig.codec,
|
|
encoderConfig
|
|
));
|
|
if (MatchingCustomEncoder) {
|
|
this.customEncoder = new MatchingCustomEncoder();
|
|
this.customEncoder.codec = this.encodingConfig.codec;
|
|
this.customEncoder.config = encoderConfig;
|
|
this.customEncoder.onPacket = (packet, meta) => {
|
|
if (!(packet instanceof EncodedPacket)) {
|
|
throw new TypeError("The first argument passed to onPacket must be an EncodedPacket.");
|
|
}
|
|
if (meta !== void 0 && (!meta || typeof meta !== "object")) {
|
|
throw new TypeError("The second argument passed to onPacket must be an object or undefined.");
|
|
}
|
|
this.encodingConfig.onEncodedPacket?.(packet, meta);
|
|
void this.muxer.addEncodedAudioPacket(this.source._connectedTrack, packet, meta).catch((error) => {
|
|
this.error ??= error;
|
|
});
|
|
};
|
|
await this.customEncoder.init();
|
|
} else if (PCM_AUDIO_CODECS.includes(this.encodingConfig.codec)) {
|
|
this.initPcmEncoder();
|
|
} else {
|
|
if (typeof AudioEncoder === "undefined") {
|
|
throw new Error("AudioEncoder is not supported by this browser.");
|
|
}
|
|
const support = await AudioEncoder.isConfigSupported(encoderConfig);
|
|
if (!support.supported) {
|
|
throw new Error(
|
|
`This specific encoder configuration (${encoderConfig.codec}, ${encoderConfig.bitrate} bps, ${encoderConfig.numberOfChannels} channels, ${encoderConfig.sampleRate} Hz) is not supported by this browser. Consider using another codec or changing your audio parameters.`
|
|
);
|
|
}
|
|
const stack = new Error("Encoding error").stack;
|
|
this.encoder = new AudioEncoder({
|
|
output: (chunk, meta) => {
|
|
if (this.encodingConfig.codec === "aac" && meta?.decoderConfig) {
|
|
let needsDescriptionOverwrite = false;
|
|
if (!meta.decoderConfig.description || meta.decoderConfig.description.byteLength < 2) {
|
|
needsDescriptionOverwrite = true;
|
|
} else {
|
|
const audioSpecificConfig = parseAacAudioSpecificConfig(
|
|
toUint8Array(meta.decoderConfig.description)
|
|
);
|
|
needsDescriptionOverwrite = audioSpecificConfig.objectType === 0;
|
|
}
|
|
if (needsDescriptionOverwrite) {
|
|
const objectType = Number(last(encoderConfig.codec.split(".")));
|
|
meta.decoderConfig.description = buildAacAudioSpecificConfig({
|
|
objectType,
|
|
numberOfChannels: meta.decoderConfig.numberOfChannels,
|
|
sampleRate: meta.decoderConfig.sampleRate
|
|
});
|
|
}
|
|
}
|
|
const packet = EncodedPacket.fromEncodedChunk(chunk);
|
|
this.encodingConfig.onEncodedPacket?.(packet, meta);
|
|
void this.muxer.addEncodedAudioPacket(this.source._connectedTrack, packet, meta).catch((error) => {
|
|
this.error ??= error;
|
|
});
|
|
},
|
|
error: (error) => {
|
|
error.stack = stack;
|
|
this.error ??= error;
|
|
}
|
|
});
|
|
this.encoder.configure(encoderConfig);
|
|
}
|
|
assert(this.source._connectedTrack);
|
|
this.muxer = this.source._connectedTrack.output._muxer;
|
|
this.encoderInitialized = true;
|
|
})();
|
|
}
|
|
initPcmEncoder() {
|
|
this.isPcmEncoder = true;
|
|
const codec = this.encodingConfig.codec;
|
|
const { dataType, sampleSize, littleEndian } = parsePcmCodec(codec);
|
|
this.outputSampleSize = sampleSize;
|
|
switch (sampleSize) {
|
|
case 1:
|
|
{
|
|
if (dataType === "unsigned") {
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setUint8(byteOffset, clamp((value + 1) * 127.5, 0, 255));
|
|
} else if (dataType === "signed") {
|
|
this.writeOutputValue = (view2, byteOffset, value) => {
|
|
view2.setInt8(byteOffset, clamp(Math.round(value * 128), -128, 127));
|
|
};
|
|
} else if (dataType === "ulaw") {
|
|
this.writeOutputValue = (view2, byteOffset, value) => {
|
|
const int16 = clamp(Math.floor(value * 32767), -32768, 32767);
|
|
view2.setUint8(byteOffset, toUlaw(int16));
|
|
};
|
|
} else if (dataType === "alaw") {
|
|
this.writeOutputValue = (view2, byteOffset, value) => {
|
|
const int16 = clamp(Math.floor(value * 32767), -32768, 32767);
|
|
view2.setUint8(byteOffset, toAlaw(int16));
|
|
};
|
|
} else {
|
|
assert(false);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 2:
|
|
{
|
|
if (dataType === "unsigned") {
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setUint16(byteOffset, clamp((value + 1) * 32767.5, 0, 65535), littleEndian);
|
|
} else if (dataType === "signed") {
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setInt16(byteOffset, clamp(Math.round(value * 32767), -32768, 32767), littleEndian);
|
|
} else {
|
|
assert(false);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 3:
|
|
{
|
|
if (dataType === "unsigned") {
|
|
this.writeOutputValue = (view2, byteOffset, value) => setUint24(view2, byteOffset, clamp((value + 1) * 83886075e-1, 0, 16777215), littleEndian);
|
|
} else if (dataType === "signed") {
|
|
this.writeOutputValue = (view2, byteOffset, value) => setInt24(
|
|
view2,
|
|
byteOffset,
|
|
clamp(Math.round(value * 8388607), -8388608, 8388607),
|
|
littleEndian
|
|
);
|
|
} else {
|
|
assert(false);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 4:
|
|
{
|
|
if (dataType === "unsigned") {
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setUint32(byteOffset, clamp((value + 1) * 21474836475e-1, 0, 4294967295), littleEndian);
|
|
} else if (dataType === "signed") {
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setInt32(
|
|
byteOffset,
|
|
clamp(Math.round(value * 2147483647), -2147483648, 2147483647),
|
|
littleEndian
|
|
);
|
|
} else if (dataType === "float") {
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setFloat32(byteOffset, value, littleEndian);
|
|
} else {
|
|
assert(false);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case 8:
|
|
{
|
|
if (dataType === "float") {
|
|
this.writeOutputValue = (view2, byteOffset, value) => view2.setFloat64(byteOffset, value, littleEndian);
|
|
} else {
|
|
assert(false);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
{
|
|
assertNever(sampleSize);
|
|
assert(false);
|
|
}
|
|
;
|
|
}
|
|
}
|
|
async flushAndClose(forceClose) {
|
|
if (!forceClose) this.checkForEncoderError();
|
|
if (this.customEncoder) {
|
|
if (!forceClose) {
|
|
void this.customEncoderCallSerializer.call(() => this.customEncoder.flush());
|
|
}
|
|
await this.customEncoderCallSerializer.call(() => this.customEncoder.close());
|
|
} else if (this.encoder) {
|
|
if (!forceClose) {
|
|
await this.encoder.flush();
|
|
}
|
|
if (this.encoder.state !== "closed") {
|
|
this.encoder.close();
|
|
}
|
|
}
|
|
if (!forceClose) this.checkForEncoderError();
|
|
}
|
|
getQueueSize() {
|
|
if (this.customEncoder) {
|
|
return this.customEncoderQueueSize;
|
|
} else if (this.isPcmEncoder) {
|
|
return 0;
|
|
} else {
|
|
return this.encoder?.encodeQueueSize ?? 0;
|
|
}
|
|
}
|
|
checkForEncoderError() {
|
|
if (this.error) {
|
|
throw this.error;
|
|
}
|
|
}
|
|
};
|
|
var AudioSampleSource = class extends AudioSource {
|
|
/**
|
|
* Creates a new {@link AudioSampleSource} whose samples are encoded according to the specified
|
|
* {@link AudioEncodingConfig}.
|
|
*/
|
|
constructor(encodingConfig) {
|
|
validateAudioEncodingConfig(encodingConfig);
|
|
super(encodingConfig.codec);
|
|
this._encoder = new AudioEncoderWrapper(this, encodingConfig);
|
|
}
|
|
/**
|
|
* Encodes an audio sample and then adds it to the output.
|
|
*
|
|
* @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise
|
|
* to respect writer and encoder backpressure.
|
|
*/
|
|
add(audioSample) {
|
|
if (!(audioSample instanceof AudioSample)) {
|
|
throw new TypeError("audioSample must be an AudioSample.");
|
|
}
|
|
return this._encoder.add(audioSample, false);
|
|
}
|
|
/** @internal */
|
|
_flushAndClose(forceClose) {
|
|
return this._encoder.flushAndClose(forceClose);
|
|
}
|
|
};
|
|
var AudioBufferSource = class extends AudioSource {
|
|
/**
|
|
* Creates a new {@link AudioBufferSource} whose `AudioBuffer` instances are encoded according to the specified
|
|
* {@link AudioEncodingConfig}.
|
|
*/
|
|
constructor(encodingConfig) {
|
|
validateAudioEncodingConfig(encodingConfig);
|
|
super(encodingConfig.codec);
|
|
/** @internal */
|
|
this._accumulatedTime = 0;
|
|
this._encoder = new AudioEncoderWrapper(this, encodingConfig);
|
|
}
|
|
/**
|
|
* Converts an AudioBuffer to audio samples, encodes them and adds them to the output. The first AudioBuffer will
|
|
* be played at timestamp 0, and any subsequent AudioBuffer will have a timestamp equal to the total duration of
|
|
* all previous AudioBuffers.
|
|
*
|
|
* @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise
|
|
* to respect writer and encoder backpressure.
|
|
*/
|
|
async add(audioBuffer) {
|
|
if (!(audioBuffer instanceof AudioBuffer)) {
|
|
throw new TypeError("audioBuffer must be an AudioBuffer.");
|
|
}
|
|
const iterator = AudioSample._fromAudioBuffer(audioBuffer, this._accumulatedTime);
|
|
this._accumulatedTime += audioBuffer.duration;
|
|
for (const audioSample of iterator) {
|
|
await this._encoder.add(audioSample, true);
|
|
}
|
|
}
|
|
/** @internal */
|
|
_flushAndClose(forceClose) {
|
|
return this._encoder.flushAndClose(forceClose);
|
|
}
|
|
};
|
|
var MediaStreamAudioTrackSource = class extends AudioSource {
|
|
/**
|
|
* Creates a new {@link MediaStreamAudioTrackSource} from a `MediaStreamAudioTrack`, which will pull audio samples
|
|
* from the stream in real time and encode them according to {@link AudioEncodingConfig}.
|
|
*/
|
|
constructor(track, encodingConfig) {
|
|
if (!(track instanceof MediaStreamTrack) || track.kind !== "audio") {
|
|
throw new TypeError("track must be an audio MediaStreamTrack.");
|
|
}
|
|
validateAudioEncodingConfig(encodingConfig);
|
|
super(encodingConfig.codec);
|
|
/** @internal */
|
|
this._abortController = null;
|
|
/** @internal */
|
|
this._audioContext = null;
|
|
/** @internal */
|
|
this._scriptProcessorNode = null;
|
|
// Deprecated but goated
|
|
/** @internal */
|
|
this._promiseWithResolvers = promiseWithResolvers();
|
|
/** @internal */
|
|
this._errorPromiseAccessed = false;
|
|
/** @internal */
|
|
this._paused = false;
|
|
/** @internal */
|
|
this._lastSampleTimestamp = null;
|
|
/** @internal */
|
|
this._pauseOffset = 0;
|
|
this._encoder = new AudioEncoderWrapper(this, encodingConfig);
|
|
this._track = track;
|
|
}
|
|
/** A promise that rejects upon any error within this source. This promise never resolves. */
|
|
get errorPromise() {
|
|
this._errorPromiseAccessed = true;
|
|
return this._promiseWithResolvers.promise;
|
|
}
|
|
/** Whether this source is currently paused as a result of calling `.pause()`. */
|
|
get paused() {
|
|
return this._paused;
|
|
}
|
|
/** @internal */
|
|
async _start() {
|
|
if (!this._errorPromiseAccessed) {
|
|
console.warn(
|
|
"Make sure not to ignore the `errorPromise` field on MediaStreamVideoTrackSource, so that any internal errors get bubbled up properly."
|
|
);
|
|
}
|
|
this._abortController = new AbortController();
|
|
let firstAudioDataTimestamp = null;
|
|
let errored = false;
|
|
const onAudioSample = (audioSample) => {
|
|
if (errored) {
|
|
audioSample.close();
|
|
return;
|
|
}
|
|
const currentTimestamp = audioSample.timestamp;
|
|
if (this._paused) {
|
|
const dataSeen = firstAudioDataTimestamp !== null;
|
|
if (dataSeen) {
|
|
if (this._lastSampleTimestamp !== null) {
|
|
const timeDelta = currentTimestamp - this._lastSampleTimestamp;
|
|
this._pauseOffset -= timeDelta;
|
|
}
|
|
this._lastSampleTimestamp = currentTimestamp;
|
|
}
|
|
audioSample.close();
|
|
return;
|
|
}
|
|
if (firstAudioDataTimestamp === null) {
|
|
firstAudioDataTimestamp = audioSample.timestamp;
|
|
const muxer = this._connectedTrack.output._muxer;
|
|
if (muxer.firstMediaStreamTimestamp === null) {
|
|
muxer.firstMediaStreamTimestamp = performance.now() / 1e3;
|
|
this._timestampOffset = -firstAudioDataTimestamp;
|
|
} else {
|
|
this._timestampOffset = performance.now() / 1e3 - muxer.firstMediaStreamTimestamp - firstAudioDataTimestamp;
|
|
}
|
|
}
|
|
this._lastSampleTimestamp = currentTimestamp;
|
|
if (this._encoder.getQueueSize() >= 4) {
|
|
audioSample.close();
|
|
return;
|
|
}
|
|
audioSample.setTimestamp(currentTimestamp + this._pauseOffset);
|
|
void this._encoder.add(audioSample, true).catch((error) => {
|
|
errored = true;
|
|
this._abortController?.abort();
|
|
this._promiseWithResolvers.reject(error);
|
|
void this._audioContext?.suspend();
|
|
});
|
|
};
|
|
if (typeof MediaStreamTrackProcessor !== "undefined") {
|
|
const processor = new MediaStreamTrackProcessor({ track: this._track });
|
|
const consumer = new WritableStream({
|
|
write: (audioData) => onAudioSample(new AudioSample(audioData))
|
|
});
|
|
processor.readable.pipeTo(consumer, {
|
|
signal: this._abortController.signal
|
|
}).catch((error) => {
|
|
if (error instanceof DOMException && error.name === "AbortError") return;
|
|
this._promiseWithResolvers.reject(error);
|
|
});
|
|
} else {
|
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
this._audioContext = new AudioContext({ sampleRate: this._track.getSettings().sampleRate });
|
|
const sourceNode = this._audioContext.createMediaStreamSource(new MediaStream([this._track]));
|
|
this._scriptProcessorNode = this._audioContext.createScriptProcessor(4096);
|
|
if (this._audioContext.state === "suspended") {
|
|
await this._audioContext.resume();
|
|
}
|
|
sourceNode.connect(this._scriptProcessorNode);
|
|
this._scriptProcessorNode.connect(this._audioContext.destination);
|
|
let totalDuration = 0;
|
|
this._scriptProcessorNode.onaudioprocess = (event) => {
|
|
const iterator = AudioSample._fromAudioBuffer(event.inputBuffer, totalDuration);
|
|
totalDuration += event.inputBuffer.duration;
|
|
for (const audioSample of iterator) {
|
|
onAudioSample(audioSample);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
/**
|
|
* Pauses the capture of audio data - any audio data emitted by the underlying media stream will be ignored
|
|
* while paused. This does *not* close the underlying `MediaStreamAudioTrack`, it just ignores its output.
|
|
*/
|
|
pause() {
|
|
this._paused = true;
|
|
}
|
|
/** Resumes the capture of audio data after being paused. */
|
|
resume() {
|
|
this._paused = false;
|
|
}
|
|
/** @internal */
|
|
async _flushAndClose(forceClose) {
|
|
if (this._abortController) {
|
|
this._abortController.abort();
|
|
this._abortController = null;
|
|
}
|
|
if (this._audioContext) {
|
|
assert(this._scriptProcessorNode);
|
|
this._scriptProcessorNode.disconnect();
|
|
await this._audioContext.suspend();
|
|
}
|
|
await this._encoder.flushAndClose(forceClose);
|
|
}
|
|
};
|
|
var mediaStreamTrackProcessorWorkerCode = () => {
|
|
const sendMessage = (message, transfer) => {
|
|
if (transfer) {
|
|
self.postMessage(message, { transfer });
|
|
} else {
|
|
self.postMessage(message);
|
|
}
|
|
};
|
|
sendMessage({
|
|
type: "support",
|
|
supported: typeof MediaStreamTrackProcessor !== "undefined"
|
|
});
|
|
const abortControllers = /* @__PURE__ */ new Map();
|
|
const activeTracks = /* @__PURE__ */ new Map();
|
|
self.addEventListener("message", (event) => {
|
|
const message = event.data;
|
|
switch (message.type) {
|
|
case "videoTrack":
|
|
{
|
|
activeTracks.set(message.trackId, message.track);
|
|
const processor = new MediaStreamTrackProcessor({ track: message.track });
|
|
const consumer = new WritableStream({
|
|
write: (videoFrame) => {
|
|
if (!activeTracks.has(message.trackId)) {
|
|
videoFrame.close();
|
|
return;
|
|
}
|
|
sendMessage({
|
|
type: "videoFrame",
|
|
trackId: message.trackId,
|
|
videoFrame
|
|
}, [videoFrame]);
|
|
}
|
|
});
|
|
const abortController = new AbortController();
|
|
abortControllers.set(message.trackId, abortController);
|
|
processor.readable.pipeTo(consumer, {
|
|
signal: abortController.signal
|
|
}).catch((error) => {
|
|
if (error instanceof DOMException && error.name === "AbortError") return;
|
|
sendMessage({
|
|
type: "error",
|
|
trackId: message.trackId,
|
|
error
|
|
});
|
|
});
|
|
}
|
|
;
|
|
break;
|
|
case "stopTrack":
|
|
{
|
|
const abortController = abortControllers.get(message.trackId);
|
|
if (abortController) {
|
|
abortController.abort();
|
|
abortControllers.delete(message.trackId);
|
|
}
|
|
const track = activeTracks.get(message.trackId);
|
|
track?.stop();
|
|
activeTracks.delete(message.trackId);
|
|
sendMessage({
|
|
type: "trackStopped",
|
|
trackId: message.trackId
|
|
});
|
|
}
|
|
;
|
|
break;
|
|
default:
|
|
assertNever(message);
|
|
}
|
|
});
|
|
};
|
|
var nextMediaStreamTrackProcessorWorkerId = 0;
|
|
var mediaStreamTrackProcessorWorker = null;
|
|
var initMediaStreamTrackProcessorWorker = () => {
|
|
const blob = new Blob(
|
|
[`(${mediaStreamTrackProcessorWorkerCode.toString()})()`],
|
|
{ type: "application/javascript" }
|
|
);
|
|
const url2 = URL.createObjectURL(blob);
|
|
mediaStreamTrackProcessorWorker = new Worker(url2);
|
|
};
|
|
var mediaStreamTrackProcessorIsSupportedInWorkerCache = null;
|
|
var mediaStreamTrackProcessorIsSupportedInWorker = async () => {
|
|
if (mediaStreamTrackProcessorIsSupportedInWorkerCache !== null) {
|
|
return mediaStreamTrackProcessorIsSupportedInWorkerCache;
|
|
}
|
|
if (!mediaStreamTrackProcessorWorker) {
|
|
initMediaStreamTrackProcessorWorker();
|
|
}
|
|
return new Promise((resolve) => {
|
|
assert(mediaStreamTrackProcessorWorker);
|
|
const listener = (event) => {
|
|
const message = event.data;
|
|
if (message.type === "support") {
|
|
mediaStreamTrackProcessorIsSupportedInWorkerCache = message.supported;
|
|
mediaStreamTrackProcessorWorker.removeEventListener("message", listener);
|
|
resolve(message.supported);
|
|
}
|
|
};
|
|
mediaStreamTrackProcessorWorker.addEventListener("message", listener);
|
|
});
|
|
};
|
|
var sendMessageToMediaStreamTrackProcessorWorker = (message, transfer) => {
|
|
assert(mediaStreamTrackProcessorWorker);
|
|
if (transfer) {
|
|
mediaStreamTrackProcessorWorker.postMessage(message, transfer);
|
|
} else {
|
|
mediaStreamTrackProcessorWorker.postMessage(message);
|
|
}
|
|
};
|
|
var SubtitleSource = class extends MediaSource {
|
|
/** Internal constructor. */
|
|
constructor(codec) {
|
|
super();
|
|
/** @internal */
|
|
this._connectedTrack = null;
|
|
if (!SUBTITLE_CODECS.includes(codec)) {
|
|
throw new TypeError(`Invalid subtitle codec '${codec}'. Must be one of: ${SUBTITLE_CODECS.join(", ")}.`);
|
|
}
|
|
this._codec = codec;
|
|
}
|
|
};
|
|
var TextSubtitleSource = class extends SubtitleSource {
|
|
/** Creates a new {@link TextSubtitleSource} where added text chunks are in the specified `codec`. */
|
|
constructor(codec) {
|
|
super(codec);
|
|
/** @internal */
|
|
this._error = null;
|
|
this._parser = new SubtitleParser({
|
|
codec,
|
|
output: (cue, metadata) => {
|
|
void this._connectedTrack?.output._muxer.addSubtitleCue(this._connectedTrack, cue, metadata).catch((error) => {
|
|
this._error ??= error;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Parses the subtitle text according to the specified codec and adds it to the output track. You don't have to
|
|
* add the entire subtitle file at once here; you can provide it in chunks.
|
|
*
|
|
* @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise
|
|
* to respect writer and encoder backpressure.
|
|
*/
|
|
add(text) {
|
|
if (typeof text !== "string") {
|
|
throw new TypeError("text must be a string.");
|
|
}
|
|
this._checkForError();
|
|
this._ensureValidAdd();
|
|
this._parser.parse(text);
|
|
return this._connectedTrack.output._muxer.mutex.currentPromise;
|
|
}
|
|
/** @internal */
|
|
_checkForError() {
|
|
if (this._error) {
|
|
throw this._error;
|
|
}
|
|
}
|
|
/** @internal */
|
|
async _flushAndClose(forceClose) {
|
|
if (!forceClose) {
|
|
this._checkForError();
|
|
}
|
|
}
|
|
};
|
|
|
|
// src/output.ts
|
|
var ALL_TRACK_TYPES = ["video", "audio", "subtitle"];
|
|
var validateBaseTrackMetadata = (metadata) => {
|
|
if (!metadata || typeof metadata !== "object") {
|
|
throw new TypeError("metadata must be an object.");
|
|
}
|
|
if (metadata.languageCode !== void 0 && !isIso639Dash2LanguageCode(metadata.languageCode)) {
|
|
throw new TypeError("metadata.languageCode, when provided, must be a three-letter, ISO 639-2/T language code.");
|
|
}
|
|
if (metadata.name !== void 0 && typeof metadata.name !== "string") {
|
|
throw new TypeError("metadata.name, when provided, must be a string.");
|
|
}
|
|
if (metadata.disposition !== void 0) {
|
|
validateTrackDisposition(metadata.disposition);
|
|
}
|
|
if (metadata.maximumPacketCount !== void 0 && (!Number.isInteger(metadata.maximumPacketCount) || metadata.maximumPacketCount < 0)) {
|
|
throw new TypeError("metadata.maximumPacketCount, when provided, must be a non-negative integer.");
|
|
}
|
|
};
|
|
var Output = class {
|
|
/**
|
|
* Creates a new instance of {@link Output} which can then be used to create a new media file according to the
|
|
* specified {@link OutputOptions}.
|
|
*/
|
|
constructor(options) {
|
|
/** The current state of the output. */
|
|
this.state = "pending";
|
|
/** @internal */
|
|
this._tracks = [];
|
|
/** @internal */
|
|
this._startPromise = null;
|
|
/** @internal */
|
|
this._cancelPromise = null;
|
|
/** @internal */
|
|
this._finalizePromise = null;
|
|
/** @internal */
|
|
this._mutex = new AsyncMutex();
|
|
/** @internal */
|
|
this._metadataTags = {};
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (!(options.format instanceof OutputFormat)) {
|
|
throw new TypeError("options.format must be an OutputFormat.");
|
|
}
|
|
if (!(options.target instanceof Target)) {
|
|
throw new TypeError("options.target must be a Target.");
|
|
}
|
|
if (options.target._output) {
|
|
throw new Error("Target is already used for another output.");
|
|
}
|
|
options.target._output = this;
|
|
this.format = options.format;
|
|
this.target = options.target;
|
|
this._writer = options.target._createWriter();
|
|
this._muxer = options.format._createMuxer(this);
|
|
}
|
|
/** Adds a video track to the output with the given source. Can only be called before the output is started. */
|
|
addVideoTrack(source, metadata = {}) {
|
|
if (!(source instanceof VideoSource)) {
|
|
throw new TypeError("source must be a VideoSource.");
|
|
}
|
|
validateBaseTrackMetadata(metadata);
|
|
if (metadata.rotation !== void 0 && ![0, 90, 180, 270].includes(metadata.rotation)) {
|
|
throw new TypeError(`Invalid video rotation: ${metadata.rotation}. Has to be 0, 90, 180 or 270.`);
|
|
}
|
|
if (!this.format.supportsVideoRotationMetadata && metadata.rotation) {
|
|
throw new Error(`${this.format._name} does not support video rotation metadata.`);
|
|
}
|
|
if (metadata.frameRate !== void 0 && (!Number.isFinite(metadata.frameRate) || metadata.frameRate <= 0)) {
|
|
throw new TypeError(
|
|
`Invalid video frame rate: ${metadata.frameRate}. Must be a positive number.`
|
|
);
|
|
}
|
|
this._addTrack("video", source, metadata);
|
|
}
|
|
/** Adds an audio track to the output with the given source. Can only be called before the output is started. */
|
|
addAudioTrack(source, metadata = {}) {
|
|
if (!(source instanceof AudioSource)) {
|
|
throw new TypeError("source must be an AudioSource.");
|
|
}
|
|
validateBaseTrackMetadata(metadata);
|
|
this._addTrack("audio", source, metadata);
|
|
}
|
|
/** Adds a subtitle track to the output with the given source. Can only be called before the output is started. */
|
|
addSubtitleTrack(source, metadata = {}) {
|
|
if (!(source instanceof SubtitleSource)) {
|
|
throw new TypeError("source must be a SubtitleSource.");
|
|
}
|
|
validateBaseTrackMetadata(metadata);
|
|
this._addTrack("subtitle", source, metadata);
|
|
}
|
|
/**
|
|
* Sets descriptive metadata tags about the media file, such as title, author, date, or cover art. When called
|
|
* multiple times, only the metadata from the last call will be used.
|
|
*
|
|
* Can only be called before the output is started.
|
|
*/
|
|
setMetadataTags(tags) {
|
|
validateMetadataTags(tags);
|
|
if (this.state !== "pending") {
|
|
throw new Error("Cannot set metadata tags after output has been started or canceled.");
|
|
}
|
|
this._metadataTags = tags;
|
|
}
|
|
/** @internal */
|
|
_addTrack(type, source, metadata) {
|
|
if (this.state !== "pending") {
|
|
throw new Error("Cannot add track after output has been started or canceled.");
|
|
}
|
|
if (source._connectedTrack) {
|
|
throw new Error("Source is already used for a track.");
|
|
}
|
|
const supportedTrackCounts = this.format.getSupportedTrackCounts();
|
|
const presentTracksOfThisType = this._tracks.reduce(
|
|
(count, track2) => count + (track2.type === type ? 1 : 0),
|
|
0
|
|
);
|
|
const maxCount = supportedTrackCounts[type].max;
|
|
if (presentTracksOfThisType === maxCount) {
|
|
throw new Error(
|
|
maxCount === 0 ? `${this.format._name} does not support ${type} tracks.` : `${this.format._name} does not support more than ${maxCount} ${type} track${maxCount === 1 ? "" : "s"}.`
|
|
);
|
|
}
|
|
const maxTotalCount = supportedTrackCounts.total.max;
|
|
if (this._tracks.length === maxTotalCount) {
|
|
throw new Error(
|
|
`${this.format._name} does not support more than ${maxTotalCount} tracks${maxTotalCount === 1 ? "" : "s"} in total.`
|
|
);
|
|
}
|
|
const track = {
|
|
id: this._tracks.length + 1,
|
|
output: this,
|
|
type,
|
|
source,
|
|
metadata
|
|
};
|
|
if (track.type === "video") {
|
|
const supportedVideoCodecs = this.format.getSupportedVideoCodecs();
|
|
if (supportedVideoCodecs.length === 0) {
|
|
throw new Error(
|
|
`${this.format._name} does not support video tracks.` + this.format._codecUnsupportedHint(track.source._codec)
|
|
);
|
|
} else if (!supportedVideoCodecs.includes(track.source._codec)) {
|
|
throw new Error(
|
|
`Codec '${track.source._codec}' cannot be contained within ${this.format._name}. Supported video codecs are: ${supportedVideoCodecs.map((codec) => `'${codec}'`).join(", ")}.` + this.format._codecUnsupportedHint(track.source._codec)
|
|
);
|
|
}
|
|
} else if (track.type === "audio") {
|
|
const supportedAudioCodecs = this.format.getSupportedAudioCodecs();
|
|
if (supportedAudioCodecs.length === 0) {
|
|
throw new Error(
|
|
`${this.format._name} does not support audio tracks.` + this.format._codecUnsupportedHint(track.source._codec)
|
|
);
|
|
} else if (!supportedAudioCodecs.includes(track.source._codec)) {
|
|
throw new Error(
|
|
`Codec '${track.source._codec}' cannot be contained within ${this.format._name}. Supported audio codecs are: ${supportedAudioCodecs.map((codec) => `'${codec}'`).join(", ")}.` + this.format._codecUnsupportedHint(track.source._codec)
|
|
);
|
|
}
|
|
} else if (track.type === "subtitle") {
|
|
const supportedSubtitleCodecs = this.format.getSupportedSubtitleCodecs();
|
|
if (supportedSubtitleCodecs.length === 0) {
|
|
throw new Error(
|
|
`${this.format._name} does not support subtitle tracks.` + this.format._codecUnsupportedHint(track.source._codec)
|
|
);
|
|
} else if (!supportedSubtitleCodecs.includes(track.source._codec)) {
|
|
throw new Error(
|
|
`Codec '${track.source._codec}' cannot be contained within ${this.format._name}. Supported subtitle codecs are: ${supportedSubtitleCodecs.map((codec) => `'${codec}'`).join(", ")}.` + this.format._codecUnsupportedHint(track.source._codec)
|
|
);
|
|
}
|
|
}
|
|
this._tracks.push(track);
|
|
source._connectedTrack = track;
|
|
}
|
|
/**
|
|
* Starts the creation of the output file. This method should be called after all tracks have been added. Only after
|
|
* the output has started can media samples be added to the tracks.
|
|
*
|
|
* @returns A promise that resolves when the output has successfully started and is ready to receive media samples.
|
|
*/
|
|
async start() {
|
|
const supportedTrackCounts = this.format.getSupportedTrackCounts();
|
|
for (const trackType of ALL_TRACK_TYPES) {
|
|
const presentTracksOfThisType = this._tracks.reduce(
|
|
(count, track) => count + (track.type === trackType ? 1 : 0),
|
|
0
|
|
);
|
|
const minCount = supportedTrackCounts[trackType].min;
|
|
if (presentTracksOfThisType < minCount) {
|
|
throw new Error(
|
|
minCount === supportedTrackCounts[trackType].max ? `${this.format._name} requires exactly ${minCount} ${trackType} track${minCount === 1 ? "" : "s"}.` : `${this.format._name} requires at least ${minCount} ${trackType} track${minCount === 1 ? "" : "s"}.`
|
|
);
|
|
}
|
|
}
|
|
const totalMinCount = supportedTrackCounts.total.min;
|
|
if (this._tracks.length < totalMinCount) {
|
|
throw new Error(
|
|
totalMinCount === supportedTrackCounts.total.max ? `${this.format._name} requires exactly ${totalMinCount} track${totalMinCount === 1 ? "" : "s"}.` : `${this.format._name} requires at least ${totalMinCount} track${totalMinCount === 1 ? "" : "s"}.`
|
|
);
|
|
}
|
|
if (this.state === "canceled") {
|
|
throw new Error("Output has been canceled.");
|
|
}
|
|
if (this._startPromise) {
|
|
console.warn("Output has already been started.");
|
|
return this._startPromise;
|
|
}
|
|
return this._startPromise = (async () => {
|
|
this.state = "started";
|
|
this._writer.start();
|
|
const release = await this._mutex.acquire();
|
|
await this._muxer.start();
|
|
const promises = this._tracks.map((track) => track.source._start());
|
|
await Promise.all(promises);
|
|
release();
|
|
})();
|
|
}
|
|
/**
|
|
* Resolves with the full MIME type of the output file, including track codecs.
|
|
*
|
|
* The returned promise will resolve only once the precise codec strings of all tracks are known.
|
|
*/
|
|
getMimeType() {
|
|
return this._muxer.getMimeType();
|
|
}
|
|
/**
|
|
* Cancels the creation of the output file, releasing internal resources like encoders and preventing further
|
|
* samples from being added.
|
|
*
|
|
* @returns A promise that resolves once all internal resources have been released.
|
|
*/
|
|
async cancel() {
|
|
if (this._cancelPromise) {
|
|
console.warn("Output has already been canceled.");
|
|
return this._cancelPromise;
|
|
} else if (this.state === "finalizing" || this.state === "finalized") {
|
|
console.warn("Output has already been finalized.");
|
|
return;
|
|
}
|
|
return this._cancelPromise = (async () => {
|
|
this.state = "canceled";
|
|
const release = await this._mutex.acquire();
|
|
const promises = this._tracks.map((x) => x.source._flushOrWaitForOngoingClose(true));
|
|
await Promise.all(promises);
|
|
await this._writer.close();
|
|
release();
|
|
})();
|
|
}
|
|
/**
|
|
* Finalizes the output file. This method must be called after all media samples across all tracks have been added.
|
|
* Once the Promise returned by this method completes, the output file is ready.
|
|
*/
|
|
async finalize() {
|
|
if (this.state === "pending") {
|
|
throw new Error("Cannot finalize before starting.");
|
|
}
|
|
if (this.state === "canceled") {
|
|
throw new Error("Cannot finalize after canceling.");
|
|
}
|
|
if (this._finalizePromise) {
|
|
console.warn("Output has already been finalized.");
|
|
return this._finalizePromise;
|
|
}
|
|
return this._finalizePromise = (async () => {
|
|
this.state = "finalizing";
|
|
const release = await this._mutex.acquire();
|
|
const promises = this._tracks.map((x) => x.source._flushOrWaitForOngoingClose(false));
|
|
await Promise.all(promises);
|
|
await this._muxer.finalize();
|
|
await this._writer.flush();
|
|
await this._writer.finalize();
|
|
this.state = "finalized";
|
|
release();
|
|
})();
|
|
}
|
|
};
|
|
|
|
// src/conversion.ts
|
|
var validateVideoOptions = (videoOptions) => {
|
|
if (videoOptions !== void 0 && (!videoOptions || typeof videoOptions !== "object")) {
|
|
throw new TypeError("options.video, when provided, must be an object.");
|
|
}
|
|
if (videoOptions?.discard !== void 0 && typeof videoOptions.discard !== "boolean") {
|
|
throw new TypeError("options.video.discard, when provided, must be a boolean.");
|
|
}
|
|
if (videoOptions?.forceTranscode !== void 0 && typeof videoOptions.forceTranscode !== "boolean") {
|
|
throw new TypeError("options.video.forceTranscode, when provided, must be a boolean.");
|
|
}
|
|
if (videoOptions?.codec !== void 0 && !VIDEO_CODECS.includes(videoOptions.codec)) {
|
|
throw new TypeError(
|
|
`options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(", ")}.`
|
|
);
|
|
}
|
|
if (videoOptions?.bitrate !== void 0 && !(videoOptions.bitrate instanceof Quality) && (!Number.isInteger(videoOptions.bitrate) || videoOptions.bitrate <= 0)) {
|
|
throw new TypeError("options.video.bitrate, when provided, must be a positive integer or a quality.");
|
|
}
|
|
if (videoOptions?.width !== void 0 && (!Number.isInteger(videoOptions.width) || videoOptions.width <= 0)) {
|
|
throw new TypeError("options.video.width, when provided, must be a positive integer.");
|
|
}
|
|
if (videoOptions?.height !== void 0 && (!Number.isInteger(videoOptions.height) || videoOptions.height <= 0)) {
|
|
throw new TypeError("options.video.height, when provided, must be a positive integer.");
|
|
}
|
|
if (videoOptions?.fit !== void 0 && !["fill", "contain", "cover"].includes(videoOptions.fit)) {
|
|
throw new TypeError("options.video.fit, when provided, must be one of 'fill', 'contain', or 'cover'.");
|
|
}
|
|
if (videoOptions?.width !== void 0 && videoOptions.height !== void 0 && videoOptions.fit === void 0) {
|
|
throw new TypeError(
|
|
"When both options.video.width and options.video.height are provided, options.video.fit must also be provided."
|
|
);
|
|
}
|
|
if (videoOptions?.rotate !== void 0 && ![0, 90, 180, 270].includes(videoOptions.rotate)) {
|
|
throw new TypeError("options.video.rotate, when provided, must be 0, 90, 180 or 270.");
|
|
}
|
|
if (videoOptions?.allowRotationMetadata !== void 0 && typeof videoOptions.allowRotationMetadata !== "boolean") {
|
|
throw new TypeError("options.video.allowRotationMetadata, when provided, must be a boolean.");
|
|
}
|
|
if (videoOptions?.crop !== void 0) {
|
|
validateCropRectangle(videoOptions.crop, "options.video.");
|
|
}
|
|
if (videoOptions?.frameRate !== void 0 && (!Number.isFinite(videoOptions.frameRate) || videoOptions.frameRate <= 0)) {
|
|
throw new TypeError("options.video.frameRate, when provided, must be a finite positive number.");
|
|
}
|
|
if (videoOptions?.alpha !== void 0 && !["discard", "keep"].includes(videoOptions.alpha)) {
|
|
throw new TypeError("options.video.alpha, when provided, must be either 'discard' or 'keep'.");
|
|
}
|
|
if (videoOptions?.keyFrameInterval !== void 0 && (!Number.isFinite(videoOptions.keyFrameInterval) || videoOptions.keyFrameInterval < 0)) {
|
|
throw new TypeError("options.video.keyFrameInterval, when provided, must be a non-negative number.");
|
|
}
|
|
if (videoOptions?.process !== void 0 && typeof videoOptions.process !== "function") {
|
|
throw new TypeError("options.video.process, when provided, must be a function.");
|
|
}
|
|
if (videoOptions?.processedWidth !== void 0 && (!Number.isInteger(videoOptions.processedWidth) || videoOptions.processedWidth <= 0)) {
|
|
throw new TypeError("options.video.processedWidth, when provided, must be a positive integer.");
|
|
}
|
|
if (videoOptions?.processedHeight !== void 0 && (!Number.isInteger(videoOptions.processedHeight) || videoOptions.processedHeight <= 0)) {
|
|
throw new TypeError("options.video.processedHeight, when provided, must be a positive integer.");
|
|
}
|
|
if (videoOptions?.hardwareAcceleration !== void 0 && !["no-preference", "prefer-hardware", "prefer-software"].includes(videoOptions.hardwareAcceleration)) {
|
|
throw new TypeError(
|
|
"options.video.hardwareAcceleration, when provided, must be 'no-preference', 'prefer-hardware' or 'prefer-software'."
|
|
);
|
|
}
|
|
};
|
|
var validateAudioOptions = (audioOptions) => {
|
|
if (audioOptions !== void 0 && (!audioOptions || typeof audioOptions !== "object")) {
|
|
throw new TypeError("options.audio, when provided, must be an object.");
|
|
}
|
|
if (audioOptions?.discard !== void 0 && typeof audioOptions.discard !== "boolean") {
|
|
throw new TypeError("options.audio.discard, when provided, must be a boolean.");
|
|
}
|
|
if (audioOptions?.forceTranscode !== void 0 && typeof audioOptions.forceTranscode !== "boolean") {
|
|
throw new TypeError("options.audio.forceTranscode, when provided, must be a boolean.");
|
|
}
|
|
if (audioOptions?.codec !== void 0 && !AUDIO_CODECS.includes(audioOptions.codec)) {
|
|
throw new TypeError(
|
|
`options.audio.codec, when provided, must be one of: ${AUDIO_CODECS.join(", ")}.`
|
|
);
|
|
}
|
|
if (audioOptions?.bitrate !== void 0 && !(audioOptions.bitrate instanceof Quality) && (!Number.isInteger(audioOptions.bitrate) || audioOptions.bitrate <= 0)) {
|
|
throw new TypeError("options.audio.bitrate, when provided, must be a positive integer or a quality.");
|
|
}
|
|
if (audioOptions?.numberOfChannels !== void 0 && (!Number.isInteger(audioOptions.numberOfChannels) || audioOptions.numberOfChannels <= 0)) {
|
|
throw new TypeError("options.audio.numberOfChannels, when provided, must be a positive integer.");
|
|
}
|
|
if (audioOptions?.sampleRate !== void 0 && (!Number.isInteger(audioOptions.sampleRate) || audioOptions.sampleRate <= 0)) {
|
|
throw new TypeError("options.audio.sampleRate, when provided, must be a positive integer.");
|
|
}
|
|
if (audioOptions?.process !== void 0 && typeof audioOptions.process !== "function") {
|
|
throw new TypeError("options.audio.process, when provided, must be a function.");
|
|
}
|
|
if (audioOptions?.processedNumberOfChannels !== void 0 && (!Number.isInteger(audioOptions.processedNumberOfChannels) || audioOptions.processedNumberOfChannels <= 0)) {
|
|
throw new TypeError("options.audio.processedNumberOfChannels, when provided, must be a positive integer.");
|
|
}
|
|
if (audioOptions?.processedSampleRate !== void 0 && (!Number.isInteger(audioOptions.processedSampleRate) || audioOptions.processedSampleRate <= 0)) {
|
|
throw new TypeError("options.audio.processedSampleRate, when provided, must be a positive integer.");
|
|
}
|
|
};
|
|
var FALLBACK_NUMBER_OF_CHANNELS = 2;
|
|
var FALLBACK_SAMPLE_RATE = 48e3;
|
|
var Conversion = class _Conversion {
|
|
/** Creates a new Conversion instance (duh). */
|
|
constructor(options) {
|
|
/** @internal */
|
|
this._addedCounts = {
|
|
video: 0,
|
|
audio: 0,
|
|
subtitle: 0
|
|
};
|
|
/** @internal */
|
|
this._totalTrackCount = 0;
|
|
/** @internal */
|
|
this._trackPromises = [];
|
|
/** @internal */
|
|
this._executed = false;
|
|
/** @internal */
|
|
this._synchronizer = new TrackSynchronizer();
|
|
/** @internal */
|
|
this._totalDuration = null;
|
|
/** @internal */
|
|
this._maxTimestamps = /* @__PURE__ */ new Map();
|
|
// Track ID -> timestamp
|
|
/** @internal */
|
|
this._canceled = false;
|
|
/**
|
|
* A callback that is fired whenever the conversion progresses. Returns a number between 0 and 1, indicating the
|
|
* completion of the conversion. Note that a progress of 1 doesn't necessarily mean the conversion is complete;
|
|
* the conversion is complete once `execute()` resolves.
|
|
*
|
|
* In order for progress to be computed, this property must be set before `execute` is called.
|
|
*/
|
|
this.onProgress = void 0;
|
|
/** @internal */
|
|
this._computeProgress = false;
|
|
/** @internal */
|
|
this._lastProgress = 0;
|
|
/**
|
|
* Whether this conversion, as it has been configured, is valid and can be executed. If this field is `false`, check
|
|
* the `discardedTracks` field for reasons.
|
|
*/
|
|
this.isValid = false;
|
|
/** The list of tracks that are included in the output file. */
|
|
this.utilizedTracks = [];
|
|
/** The list of tracks from the input file that have been discarded, alongside the discard reason. */
|
|
this.discardedTracks = [];
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (!(options.input instanceof Input)) {
|
|
throw new TypeError("options.input must be an Input.");
|
|
}
|
|
if (!(options.output instanceof Output)) {
|
|
throw new TypeError("options.output must be an Output.");
|
|
}
|
|
if (options.output._tracks.length > 0 || Object.keys(options.output._metadataTags).length > 0 || options.output.state !== "pending") {
|
|
throw new TypeError("options.output must be fresh: no tracks or metadata tags added and not started.");
|
|
}
|
|
if (typeof options.video !== "function") {
|
|
validateVideoOptions(options.video);
|
|
} else {
|
|
}
|
|
if (typeof options.audio !== "function") {
|
|
validateAudioOptions(options.audio);
|
|
} else {
|
|
}
|
|
if (options.trim !== void 0 && (!options.trim || typeof options.trim !== "object")) {
|
|
throw new TypeError("options.trim, when provided, must be an object.");
|
|
}
|
|
if (options.trim?.start !== void 0 && !Number.isFinite(options.trim.start)) {
|
|
throw new TypeError("options.trim.start, when provided, must be a finite number.");
|
|
}
|
|
if (options.trim?.end !== void 0 && !Number.isFinite(options.trim.end)) {
|
|
throw new TypeError("options.trim.end, when provided, must be a finite number.");
|
|
}
|
|
if (options.trim?.start !== void 0 && options.trim.end !== void 0 && options.trim.start >= options.trim.end) {
|
|
throw new TypeError("options.trim.start must be less than options.trim.end.");
|
|
}
|
|
if (options.tags !== void 0 && (typeof options.tags !== "object" || !options.tags) && typeof options.tags !== "function") {
|
|
throw new TypeError("options.tags, when provided, must be an object or a function.");
|
|
}
|
|
if (typeof options.tags === "object") {
|
|
validateMetadataTags(options.tags);
|
|
}
|
|
if (options.showWarnings !== void 0 && typeof options.showWarnings !== "boolean") {
|
|
throw new TypeError("options.showWarnings, when provided, must be a boolean.");
|
|
}
|
|
this._options = options;
|
|
this.input = options.input;
|
|
this.output = options.output;
|
|
const { promise: started, resolve: start } = promiseWithResolvers();
|
|
this._started = started;
|
|
this._start = start;
|
|
}
|
|
/** Initializes a new conversion process without starting the conversion. */
|
|
static async init(options) {
|
|
const conversion = new _Conversion(options);
|
|
await conversion._init();
|
|
return conversion;
|
|
}
|
|
/** @internal */
|
|
async _init() {
|
|
this._startTimestamp = this._options.trim?.start ?? Math.max(
|
|
await this.input.getFirstTimestamp(),
|
|
// Samples can also have negative timestamps, but the meaning typically is "don't present me", so let's cut
|
|
// those out by default.
|
|
0
|
|
);
|
|
this._endTimestamp = Math.max(this._options.trim?.end ?? Infinity, this._startTimestamp);
|
|
const inputTracks = await this.input.getTracks();
|
|
const outputTrackCounts = this.output.format.getSupportedTrackCounts();
|
|
let nVideo = 1;
|
|
let nAudio = 1;
|
|
for (const track of inputTracks) {
|
|
let trackOptions = void 0;
|
|
if (track.isVideoTrack()) {
|
|
if (this._options.video) {
|
|
if (typeof this._options.video === "function") {
|
|
trackOptions = await this._options.video(track, nVideo);
|
|
validateVideoOptions(trackOptions);
|
|
nVideo++;
|
|
} else {
|
|
trackOptions = this._options.video;
|
|
}
|
|
}
|
|
} else if (track.isAudioTrack()) {
|
|
if (this._options.audio) {
|
|
if (typeof this._options.audio === "function") {
|
|
trackOptions = await this._options.audio(track, nAudio);
|
|
validateAudioOptions(trackOptions);
|
|
nAudio++;
|
|
} else {
|
|
trackOptions = this._options.audio;
|
|
}
|
|
}
|
|
} else {
|
|
assert(false);
|
|
}
|
|
if (trackOptions?.discard) {
|
|
this.discardedTracks.push({
|
|
track,
|
|
reason: "discarded_by_user"
|
|
});
|
|
continue;
|
|
}
|
|
if (this._totalTrackCount === outputTrackCounts.total.max) {
|
|
this.discardedTracks.push({
|
|
track,
|
|
reason: "max_track_count_reached"
|
|
});
|
|
continue;
|
|
}
|
|
if (this._addedCounts[track.type] === outputTrackCounts[track.type].max) {
|
|
this.discardedTracks.push({
|
|
track,
|
|
reason: "max_track_count_of_type_reached"
|
|
});
|
|
continue;
|
|
}
|
|
if (track.isVideoTrack()) {
|
|
await this._processVideoTrack(track, trackOptions ?? {});
|
|
} else if (track.isAudioTrack()) {
|
|
await this._processAudioTrack(track, trackOptions ?? {});
|
|
}
|
|
}
|
|
const inputTags = await this.input.getMetadataTags();
|
|
let outputTags;
|
|
if (this._options.tags) {
|
|
const result = typeof this._options.tags === "function" ? await this._options.tags(inputTags) : this._options.tags;
|
|
validateMetadataTags(result);
|
|
outputTags = result;
|
|
} else {
|
|
outputTags = inputTags;
|
|
}
|
|
const inputAndOutputFormatMatch = (await this.input.getFormat()).mimeType === this.output.format.mimeType;
|
|
const rawTagsAreUnchanged = inputTags.raw === outputTags.raw;
|
|
if (inputTags.raw && rawTagsAreUnchanged && !inputAndOutputFormatMatch) {
|
|
delete outputTags.raw;
|
|
}
|
|
this.output.setMetadataTags(outputTags);
|
|
this.isValid = this._totalTrackCount >= outputTrackCounts.total.min && this._addedCounts.video >= outputTrackCounts.video.min && this._addedCounts.audio >= outputTrackCounts.audio.min && this._addedCounts.subtitle >= outputTrackCounts.subtitle.min;
|
|
if (this._options.showWarnings ?? true) {
|
|
const warnElements = [];
|
|
const unintentionallyDiscardedTracks = this.discardedTracks.filter((x) => x.reason !== "discarded_by_user");
|
|
if (unintentionallyDiscardedTracks.length > 0) {
|
|
warnElements.push(
|
|
"Some tracks had to be discarded from the conversion:",
|
|
unintentionallyDiscardedTracks
|
|
);
|
|
}
|
|
if (!this.isValid) {
|
|
warnElements.push("\n\n" + this._getInvalidityExplanation().join(""));
|
|
}
|
|
if (warnElements.length > 0) {
|
|
console.warn(...warnElements);
|
|
}
|
|
}
|
|
}
|
|
/** @internal */
|
|
_getInvalidityExplanation() {
|
|
const elements = [];
|
|
if (this.discardedTracks.length === 0) {
|
|
elements.push(
|
|
"Due to missing tracks, this conversion cannot be executed."
|
|
);
|
|
} else {
|
|
const encodabilityIsTheProblem = this.discardedTracks.every(
|
|
(x) => x.reason === "discarded_by_user" || x.reason === "no_encodable_target_codec"
|
|
);
|
|
elements.push(
|
|
"Due to discarded tracks, this conversion cannot be executed."
|
|
);
|
|
if (encodabilityIsTheProblem) {
|
|
const codecs = this.discardedTracks.flatMap((x) => {
|
|
if (x.reason === "discarded_by_user") return [];
|
|
if (x.track.type === "video") {
|
|
return this.output.format.getSupportedVideoCodecs();
|
|
} else if (x.track.type === "audio") {
|
|
return this.output.format.getSupportedAudioCodecs();
|
|
} else {
|
|
return this.output.format.getSupportedSubtitleCodecs();
|
|
}
|
|
});
|
|
if (codecs.length === 1) {
|
|
elements.push(
|
|
`
|
|
Tracks were discarded because your environment is not able to encode '${codecs[0]}'.`
|
|
);
|
|
} else {
|
|
elements.push(
|
|
`
|
|
Tracks were discarded because your environment is not able to encode any of the following codecs: ${codecs.map((x) => `'${x}'`).join(", ")}.`
|
|
);
|
|
}
|
|
if (codecs.includes("mp3")) {
|
|
elements.push(
|
|
`
|
|
The @mediabunny/mp3-encoder extension package provides support for encoding MP3.`
|
|
);
|
|
}
|
|
if (codecs.includes("ac3") || codecs.includes("eac3")) {
|
|
elements.push(
|
|
"\nThe @mediabunny/ac3 extension package provides support for encoding and decoding AC-3/E-AC-3."
|
|
);
|
|
}
|
|
} else {
|
|
elements.push("\nCheck the discardedTracks field for more info.");
|
|
}
|
|
}
|
|
return elements;
|
|
}
|
|
/**
|
|
* Executes the conversion process. Resolves once conversion is complete.
|
|
*
|
|
* Will throw if `isValid` is `false`.
|
|
*/
|
|
async execute() {
|
|
if (!this.isValid) {
|
|
throw new Error(
|
|
"Cannot execute this conversion because its output configuration is invalid. Make sure to always check the isValid field before executing a conversion.\n" + this._getInvalidityExplanation().join("")
|
|
);
|
|
}
|
|
if (this._executed) {
|
|
throw new Error("Conversion cannot be executed twice.");
|
|
}
|
|
this._executed = true;
|
|
if (this.onProgress) {
|
|
const durationPromises = this.utilizedTracks.map((x) => x.computeDuration());
|
|
const duration = Math.max(0, ...await Promise.all(durationPromises));
|
|
this._computeProgress = true;
|
|
this._totalDuration = Math.min(
|
|
duration - this._startTimestamp,
|
|
this._endTimestamp - this._startTimestamp
|
|
);
|
|
for (const track of this.utilizedTracks) {
|
|
this._maxTimestamps.set(track.id, 0);
|
|
}
|
|
this.onProgress?.(0);
|
|
}
|
|
await this.output.start();
|
|
this._start();
|
|
try {
|
|
await Promise.all(this._trackPromises);
|
|
} catch (error) {
|
|
if (!this._canceled) {
|
|
void this.cancel();
|
|
}
|
|
throw error;
|
|
}
|
|
if (this._canceled) {
|
|
throw new ConversionCanceledError();
|
|
}
|
|
await this.output.finalize();
|
|
if (this._computeProgress) {
|
|
this.onProgress?.(1);
|
|
}
|
|
}
|
|
/**
|
|
* Cancels the conversion process, causing any ongoing `execute` call to throw a `ConversionCanceledError`.
|
|
* Does nothing if the conversion is already complete.
|
|
*/
|
|
async cancel() {
|
|
if (this.output.state === "finalizing" || this.output.state === "finalized") {
|
|
return;
|
|
}
|
|
if (this._canceled) {
|
|
console.warn("Conversion already canceled.");
|
|
return;
|
|
}
|
|
this._canceled = true;
|
|
await this.output.cancel();
|
|
}
|
|
/** @internal */
|
|
async _processVideoTrack(track, trackOptions) {
|
|
const sourceCodec = track.codec;
|
|
if (!sourceCodec) {
|
|
this.discardedTracks.push({
|
|
track,
|
|
reason: "unknown_source_codec"
|
|
});
|
|
return;
|
|
}
|
|
let videoSource;
|
|
const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0));
|
|
const canUseRotationMetadata = this.output.format.supportsVideoRotationMetadata && (trackOptions.allowRotationMetadata ?? true);
|
|
const [rotatedWidth, rotatedHeight] = totalRotation % 180 === 0 ? [track.codedWidth, track.codedHeight] : [track.codedHeight, track.codedWidth];
|
|
const crop = trackOptions.crop;
|
|
if (crop) {
|
|
clampCropRectangle(crop, rotatedWidth, rotatedHeight);
|
|
}
|
|
const [originalWidth, originalHeight] = crop ? [crop.width, crop.height] : [rotatedWidth, rotatedHeight];
|
|
let width = originalWidth;
|
|
let height = originalHeight;
|
|
const aspectRatio = width / height;
|
|
const ceilToMultipleOfTwo = (value) => Math.ceil(value / 2) * 2;
|
|
if (trackOptions.width !== void 0 && trackOptions.height === void 0) {
|
|
width = ceilToMultipleOfTwo(trackOptions.width);
|
|
height = ceilToMultipleOfTwo(Math.round(width / aspectRatio));
|
|
} else if (trackOptions.width === void 0 && trackOptions.height !== void 0) {
|
|
height = ceilToMultipleOfTwo(trackOptions.height);
|
|
width = ceilToMultipleOfTwo(Math.round(height * aspectRatio));
|
|
} else if (trackOptions.width !== void 0 && trackOptions.height !== void 0) {
|
|
width = ceilToMultipleOfTwo(trackOptions.width);
|
|
height = ceilToMultipleOfTwo(trackOptions.height);
|
|
}
|
|
const firstTimestamp = await track.getFirstTimestamp();
|
|
const needsTranscode = !!trackOptions.forceTranscode || firstTimestamp < this._startTimestamp || !!trackOptions.frameRate || trackOptions.keyFrameInterval !== void 0 || trackOptions.process !== void 0;
|
|
let needsRerender = width !== originalWidth || height !== originalHeight || totalRotation !== 0 && (!canUseRotationMetadata || trackOptions.process !== void 0) || !!crop;
|
|
const alpha = trackOptions.alpha ?? "discard";
|
|
let videoCodecs = this.output.format.getSupportedVideoCodecs();
|
|
if (!needsTranscode && !trackOptions.bitrate && !needsRerender && videoCodecs.includes(sourceCodec) && (!trackOptions.codec || trackOptions.codec === sourceCodec)) {
|
|
const source = new EncodedVideoPacketSource(sourceCodec);
|
|
videoSource = source;
|
|
this._trackPromises.push((async () => {
|
|
await this._started;
|
|
const sink = new EncodedPacketSink(track);
|
|
const decoderConfig = await track.getDecoderConfig();
|
|
const meta = { decoderConfig: decoderConfig ?? void 0 };
|
|
const endPacket = Number.isFinite(this._endTimestamp) ? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? void 0 : void 0;
|
|
for await (const packet of sink.packets(void 0, endPacket, { verifyKeyPackets: true })) {
|
|
if (this._canceled) {
|
|
return;
|
|
}
|
|
const modifiedPacket = packet.clone({
|
|
timestamp: packet.timestamp - this._startTimestamp,
|
|
sideData: alpha === "discard" ? {} : packet.sideData
|
|
});
|
|
assert(modifiedPacket.timestamp >= 0);
|
|
this._reportProgress(track.id, modifiedPacket.timestamp);
|
|
await source.add(modifiedPacket, meta);
|
|
if (this._synchronizer.shouldWait(track.id, modifiedPacket.timestamp)) {
|
|
await this._synchronizer.wait(modifiedPacket.timestamp);
|
|
}
|
|
}
|
|
source.close();
|
|
this._synchronizer.closeTrack(track.id);
|
|
})());
|
|
} else {
|
|
const canDecode = await track.canDecode();
|
|
if (!canDecode) {
|
|
this.discardedTracks.push({
|
|
track,
|
|
reason: "undecodable_source_codec"
|
|
});
|
|
return;
|
|
}
|
|
if (trackOptions.codec) {
|
|
videoCodecs = videoCodecs.filter((codec) => codec === trackOptions.codec);
|
|
}
|
|
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
|
const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, {
|
|
width: trackOptions.process && trackOptions.processedWidth ? trackOptions.processedWidth : width,
|
|
height: trackOptions.process && trackOptions.processedHeight ? trackOptions.processedHeight : height,
|
|
bitrate
|
|
});
|
|
if (!encodableCodec) {
|
|
this.discardedTracks.push({
|
|
track,
|
|
reason: "no_encodable_target_codec"
|
|
});
|
|
return;
|
|
}
|
|
const encodingConfig = {
|
|
codec: encodableCodec,
|
|
bitrate,
|
|
keyFrameInterval: trackOptions.keyFrameInterval,
|
|
sizeChangeBehavior: trackOptions.fit ?? "passThrough",
|
|
alpha,
|
|
hardwareAcceleration: trackOptions.hardwareAcceleration
|
|
};
|
|
const source = new VideoSampleSource(encodingConfig);
|
|
videoSource = source;
|
|
if (!needsRerender) {
|
|
const tempOutput = new Output({
|
|
format: new Mp4OutputFormat(),
|
|
// Supports all video codecs
|
|
target: new NullTarget()
|
|
});
|
|
const tempSource = new VideoSampleSource(encodingConfig);
|
|
tempOutput.addVideoTrack(tempSource);
|
|
await tempOutput.start();
|
|
const sink = new VideoSampleSink(track);
|
|
const firstSample = await sink.getSample(firstTimestamp);
|
|
if (firstSample) {
|
|
try {
|
|
await tempSource.add(firstSample);
|
|
firstSample.close();
|
|
await tempOutput.finalize();
|
|
} catch (error) {
|
|
console.info("Error when probing encoder support. Falling back to rerender path.", error);
|
|
needsRerender = true;
|
|
void tempOutput.cancel();
|
|
}
|
|
} else {
|
|
await tempOutput.cancel();
|
|
}
|
|
}
|
|
if (needsRerender) {
|
|
this._trackPromises.push((async () => {
|
|
await this._started;
|
|
const sink = new CanvasSink(track, {
|
|
width,
|
|
height,
|
|
fit: trackOptions.fit ?? "fill",
|
|
rotation: totalRotation,
|
|
// Bake the rotation into the output
|
|
crop: trackOptions.crop,
|
|
poolSize: 1,
|
|
alpha: alpha === "keep"
|
|
});
|
|
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
|
|
const frameRate = trackOptions.frameRate;
|
|
let lastCanvas = null;
|
|
let lastCanvasTimestamp = null;
|
|
let lastCanvasEndTimestamp = null;
|
|
const padFrames = async (until) => {
|
|
assert(lastCanvas);
|
|
assert(frameRate !== void 0);
|
|
const frameDifference = Math.round((until - lastCanvasTimestamp) * frameRate);
|
|
for (let i = 1; i < frameDifference; i++) {
|
|
const sample = new VideoSample(lastCanvas, {
|
|
timestamp: lastCanvasTimestamp + i / frameRate,
|
|
duration: 1 / frameRate
|
|
});
|
|
await this._registerVideoSample(track, trackOptions, source, sample);
|
|
sample.close();
|
|
}
|
|
};
|
|
for await (const { canvas, timestamp, duration } of iterator) {
|
|
if (this._canceled) {
|
|
return;
|
|
}
|
|
let adjustedSampleTimestamp = Math.max(timestamp - this._startTimestamp, 0);
|
|
lastCanvasEndTimestamp = adjustedSampleTimestamp + duration;
|
|
if (frameRate !== void 0) {
|
|
const alignedTimestamp = Math.floor(adjustedSampleTimestamp * frameRate) / frameRate;
|
|
if (lastCanvas !== null) {
|
|
if (alignedTimestamp <= lastCanvasTimestamp) {
|
|
lastCanvas = canvas;
|
|
lastCanvasTimestamp = alignedTimestamp;
|
|
continue;
|
|
} else {
|
|
await padFrames(alignedTimestamp);
|
|
}
|
|
}
|
|
adjustedSampleTimestamp = alignedTimestamp;
|
|
}
|
|
const sample = new VideoSample(canvas, {
|
|
timestamp: adjustedSampleTimestamp,
|
|
duration: frameRate !== void 0 ? 1 / frameRate : duration
|
|
});
|
|
await this._registerVideoSample(track, trackOptions, source, sample);
|
|
sample.close();
|
|
if (frameRate !== void 0) {
|
|
lastCanvas = canvas;
|
|
lastCanvasTimestamp = adjustedSampleTimestamp;
|
|
}
|
|
}
|
|
if (lastCanvas) {
|
|
assert(lastCanvasEndTimestamp !== null);
|
|
assert(frameRate !== void 0);
|
|
await padFrames(Math.floor(lastCanvasEndTimestamp * frameRate) / frameRate);
|
|
}
|
|
source.close();
|
|
this._synchronizer.closeTrack(track.id);
|
|
})());
|
|
} else {
|
|
this._trackPromises.push((async () => {
|
|
await this._started;
|
|
const sink = new VideoSampleSink(track);
|
|
const frameRate = trackOptions.frameRate;
|
|
let lastSample = null;
|
|
let lastSampleTimestamp = null;
|
|
let lastSampleEndTimestamp = null;
|
|
const padFrames = async (until) => {
|
|
assert(lastSample);
|
|
assert(frameRate !== void 0);
|
|
const frameDifference = Math.round((until - lastSampleTimestamp) * frameRate);
|
|
for (let i = 1; i < frameDifference; i++) {
|
|
lastSample.setTimestamp(lastSampleTimestamp + i / frameRate);
|
|
lastSample.setDuration(1 / frameRate);
|
|
await this._registerVideoSample(track, trackOptions, source, lastSample);
|
|
}
|
|
lastSample.close();
|
|
};
|
|
for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
|
|
if (this._canceled) {
|
|
sample.close();
|
|
lastSample?.close();
|
|
return;
|
|
}
|
|
let adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0);
|
|
lastSampleEndTimestamp = adjustedSampleTimestamp + sample.duration;
|
|
if (frameRate !== void 0) {
|
|
const alignedTimestamp = Math.floor(adjustedSampleTimestamp * frameRate) / frameRate;
|
|
if (lastSample !== null) {
|
|
if (alignedTimestamp <= lastSampleTimestamp) {
|
|
lastSample.close();
|
|
lastSample = sample;
|
|
lastSampleTimestamp = alignedTimestamp;
|
|
continue;
|
|
} else {
|
|
await padFrames(alignedTimestamp);
|
|
}
|
|
}
|
|
adjustedSampleTimestamp = alignedTimestamp;
|
|
sample.setDuration(1 / frameRate);
|
|
}
|
|
sample.setTimestamp(adjustedSampleTimestamp);
|
|
await this._registerVideoSample(track, trackOptions, source, sample);
|
|
if (frameRate !== void 0) {
|
|
lastSample = sample;
|
|
lastSampleTimestamp = adjustedSampleTimestamp;
|
|
} else {
|
|
sample.close();
|
|
}
|
|
}
|
|
if (lastSample) {
|
|
assert(lastSampleEndTimestamp !== null);
|
|
assert(frameRate !== void 0);
|
|
await padFrames(Math.floor(lastSampleEndTimestamp * frameRate) / frameRate);
|
|
}
|
|
source.close();
|
|
this._synchronizer.closeTrack(track.id);
|
|
})());
|
|
}
|
|
}
|
|
this.output.addVideoTrack(videoSource, {
|
|
frameRate: trackOptions.frameRate,
|
|
// TODO: This condition can be removed when all demuxers properly homogenize to BCP47 in v2
|
|
languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : void 0,
|
|
name: track.name ?? void 0,
|
|
disposition: track.disposition,
|
|
rotation: needsRerender ? 0 : totalRotation
|
|
// Rerendering will bake the rotation into the output
|
|
});
|
|
this._addedCounts.video++;
|
|
this._totalTrackCount++;
|
|
this.utilizedTracks.push(track);
|
|
}
|
|
/** @internal */
|
|
async _registerVideoSample(track, trackOptions, source, sample) {
|
|
if (this._canceled) {
|
|
return;
|
|
}
|
|
this._reportProgress(track.id, sample.timestamp);
|
|
let finalSamples;
|
|
if (!trackOptions.process) {
|
|
finalSamples = [sample];
|
|
} else {
|
|
let processed = trackOptions.process(sample);
|
|
if (processed instanceof Promise) processed = await processed;
|
|
if (!Array.isArray(processed)) {
|
|
processed = processed === null ? [] : [processed];
|
|
}
|
|
finalSamples = processed.map((x) => {
|
|
if (x instanceof VideoSample) {
|
|
return x;
|
|
}
|
|
if (typeof VideoFrame !== "undefined" && x instanceof VideoFrame) {
|
|
return new VideoSample(x);
|
|
}
|
|
return new VideoSample(x, {
|
|
timestamp: sample.timestamp,
|
|
duration: sample.duration
|
|
});
|
|
});
|
|
}
|
|
for (const finalSample of finalSamples) {
|
|
if (this._canceled) {
|
|
break;
|
|
}
|
|
await source.add(finalSample);
|
|
if (this._synchronizer.shouldWait(track.id, finalSample.timestamp)) {
|
|
await this._synchronizer.wait(finalSample.timestamp);
|
|
}
|
|
}
|
|
for (const finalSample of finalSamples) {
|
|
if (finalSample !== sample) {
|
|
finalSample.close();
|
|
}
|
|
}
|
|
}
|
|
/** @internal */
|
|
async _processAudioTrack(track, trackOptions) {
|
|
const sourceCodec = track.codec;
|
|
if (!sourceCodec) {
|
|
this.discardedTracks.push({
|
|
track,
|
|
reason: "unknown_source_codec"
|
|
});
|
|
return;
|
|
}
|
|
let audioSource;
|
|
const originalNumberOfChannels = track.numberOfChannels;
|
|
const originalSampleRate = track.sampleRate;
|
|
const firstTimestamp = await track.getFirstTimestamp();
|
|
let numberOfChannels = trackOptions.numberOfChannels ?? originalNumberOfChannels;
|
|
let sampleRate = trackOptions.sampleRate ?? originalSampleRate;
|
|
let needsResample = numberOfChannels !== originalNumberOfChannels || sampleRate !== originalSampleRate || firstTimestamp < this._startTimestamp || firstTimestamp > this._startTimestamp && !this.output.format.supportsTimestampedMediaData;
|
|
let audioCodecs = this.output.format.getSupportedAudioCodecs();
|
|
if (!trackOptions.forceTranscode && !trackOptions.bitrate && !needsResample && audioCodecs.includes(sourceCodec) && (!trackOptions.codec || trackOptions.codec === sourceCodec) && !trackOptions.process) {
|
|
const source = new EncodedAudioPacketSource(sourceCodec);
|
|
audioSource = source;
|
|
this._trackPromises.push((async () => {
|
|
await this._started;
|
|
const sink = new EncodedPacketSink(track);
|
|
const decoderConfig = await track.getDecoderConfig();
|
|
const meta = { decoderConfig: decoderConfig ?? void 0 };
|
|
const endPacket = Number.isFinite(this._endTimestamp) ? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? void 0 : void 0;
|
|
for await (const packet of sink.packets(void 0, endPacket)) {
|
|
if (this._canceled) {
|
|
return;
|
|
}
|
|
const modifiedPacket = packet.clone({
|
|
timestamp: packet.timestamp - this._startTimestamp
|
|
});
|
|
assert(modifiedPacket.timestamp >= 0);
|
|
this._reportProgress(track.id, modifiedPacket.timestamp);
|
|
await source.add(modifiedPacket, meta);
|
|
if (this._synchronizer.shouldWait(track.id, modifiedPacket.timestamp)) {
|
|
await this._synchronizer.wait(modifiedPacket.timestamp);
|
|
}
|
|
}
|
|
source.close();
|
|
this._synchronizer.closeTrack(track.id);
|
|
})());
|
|
} else {
|
|
const canDecode = await track.canDecode();
|
|
if (!canDecode) {
|
|
this.discardedTracks.push({
|
|
track,
|
|
reason: "undecodable_source_codec"
|
|
});
|
|
return;
|
|
}
|
|
let codecOfChoice = null;
|
|
if (trackOptions.codec) {
|
|
audioCodecs = audioCodecs.filter((codec) => codec === trackOptions.codec);
|
|
}
|
|
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
|
const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, {
|
|
numberOfChannels: trackOptions.process && trackOptions.processedNumberOfChannels ? trackOptions.processedNumberOfChannels : numberOfChannels,
|
|
sampleRate: trackOptions.process && trackOptions.processedSampleRate ? trackOptions.processedSampleRate : sampleRate,
|
|
bitrate
|
|
});
|
|
if (!encodableCodecs.some((codec) => NON_PCM_AUDIO_CODECS.includes(codec)) && audioCodecs.some((codec) => NON_PCM_AUDIO_CODECS.includes(codec)) && (numberOfChannels !== FALLBACK_NUMBER_OF_CHANNELS || sampleRate !== FALLBACK_SAMPLE_RATE)) {
|
|
const encodableCodecsWithDefaultParams = await getEncodableAudioCodecs(audioCodecs, {
|
|
numberOfChannels: FALLBACK_NUMBER_OF_CHANNELS,
|
|
sampleRate: FALLBACK_SAMPLE_RATE,
|
|
bitrate
|
|
});
|
|
const nonPcmCodec = encodableCodecsWithDefaultParams.find((codec) => NON_PCM_AUDIO_CODECS.includes(codec));
|
|
if (nonPcmCodec) {
|
|
needsResample = true;
|
|
codecOfChoice = nonPcmCodec;
|
|
numberOfChannels = FALLBACK_NUMBER_OF_CHANNELS;
|
|
sampleRate = FALLBACK_SAMPLE_RATE;
|
|
}
|
|
} else {
|
|
codecOfChoice = encodableCodecs[0] ?? null;
|
|
}
|
|
if (codecOfChoice === null) {
|
|
this.discardedTracks.push({
|
|
track,
|
|
reason: "no_encodable_target_codec"
|
|
});
|
|
return;
|
|
}
|
|
if (needsResample) {
|
|
audioSource = this._resampleAudio(
|
|
track,
|
|
trackOptions,
|
|
codecOfChoice,
|
|
numberOfChannels,
|
|
sampleRate,
|
|
bitrate
|
|
);
|
|
} else {
|
|
const source = new AudioSampleSource({
|
|
codec: codecOfChoice,
|
|
bitrate
|
|
});
|
|
audioSource = source;
|
|
this._trackPromises.push((async () => {
|
|
await this._started;
|
|
const sink = new AudioSampleSink(track);
|
|
for await (const sample of sink.samples(void 0, this._endTimestamp)) {
|
|
if (this._canceled) {
|
|
sample.close();
|
|
return;
|
|
}
|
|
sample.setTimestamp(sample.timestamp - this._startTimestamp);
|
|
await this._registerAudioSample(track, trackOptions, source, sample);
|
|
sample.close();
|
|
}
|
|
source.close();
|
|
this._synchronizer.closeTrack(track.id);
|
|
})());
|
|
}
|
|
}
|
|
this.output.addAudioTrack(audioSource, {
|
|
// TODO: This condition can be removed when all demuxers properly homogenize to BCP47 in v2
|
|
languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : void 0,
|
|
name: track.name ?? void 0,
|
|
disposition: track.disposition
|
|
});
|
|
this._addedCounts.audio++;
|
|
this._totalTrackCount++;
|
|
this.utilizedTracks.push(track);
|
|
}
|
|
/** @internal */
|
|
async _registerAudioSample(track, trackOptions, source, sample) {
|
|
if (this._canceled) {
|
|
return;
|
|
}
|
|
this._reportProgress(track.id, sample.timestamp);
|
|
let finalSamples;
|
|
if (!trackOptions.process) {
|
|
finalSamples = [sample];
|
|
} else {
|
|
let processed = trackOptions.process(sample);
|
|
if (processed instanceof Promise) processed = await processed;
|
|
if (!Array.isArray(processed)) {
|
|
processed = processed === null ? [] : [processed];
|
|
}
|
|
if (!processed.every((x) => x instanceof AudioSample)) {
|
|
throw new TypeError(
|
|
"The audio process function must return an AudioSample, null, or an array of AudioSamples."
|
|
);
|
|
}
|
|
finalSamples = processed;
|
|
}
|
|
for (const finalSample of finalSamples) {
|
|
if (this._canceled) {
|
|
break;
|
|
}
|
|
await source.add(finalSample);
|
|
if (this._synchronizer.shouldWait(track.id, finalSample.timestamp)) {
|
|
await this._synchronizer.wait(finalSample.timestamp);
|
|
}
|
|
}
|
|
for (const finalSample of finalSamples) {
|
|
if (finalSample !== sample) {
|
|
finalSample.close();
|
|
}
|
|
}
|
|
}
|
|
/** @internal */
|
|
_resampleAudio(track, trackOptions, codec, targetNumberOfChannels, targetSampleRate, bitrate) {
|
|
const source = new AudioSampleSource({
|
|
codec,
|
|
bitrate
|
|
});
|
|
this._trackPromises.push((async () => {
|
|
await this._started;
|
|
const resampler = new AudioResampler({
|
|
targetNumberOfChannels,
|
|
targetSampleRate,
|
|
startTime: this._startTimestamp,
|
|
endTime: this._endTimestamp,
|
|
onSample: async (sample) => {
|
|
await this._registerAudioSample(track, trackOptions, source, sample);
|
|
sample.close();
|
|
}
|
|
});
|
|
const sink = new AudioSampleSink(track);
|
|
const iterator = sink.samples(this._startTimestamp, this._endTimestamp);
|
|
for await (const sample of iterator) {
|
|
if (this._canceled) {
|
|
sample.close();
|
|
return;
|
|
}
|
|
await resampler.add(sample);
|
|
sample.close();
|
|
}
|
|
await resampler.finalize();
|
|
source.close();
|
|
this._synchronizer.closeTrack(track.id);
|
|
})());
|
|
return source;
|
|
}
|
|
/** @internal */
|
|
_reportProgress(trackId, endTimestamp) {
|
|
if (!this._computeProgress) {
|
|
return;
|
|
}
|
|
assert(this._totalDuration !== null);
|
|
this._maxTimestamps.set(
|
|
trackId,
|
|
Math.max(endTimestamp, this._maxTimestamps.get(trackId))
|
|
);
|
|
const minTimestamp = Math.min(...this._maxTimestamps.values());
|
|
const newProgress = clamp(minTimestamp / this._totalDuration, 0, 1);
|
|
if (newProgress !== this._lastProgress) {
|
|
this._lastProgress = newProgress;
|
|
this.onProgress?.(newProgress);
|
|
}
|
|
}
|
|
};
|
|
var ConversionCanceledError = class extends Error {
|
|
/** Creates a new {@link ConversionCanceledError}. */
|
|
constructor(message = "Conversion has been canceled.") {
|
|
super(message);
|
|
this.name = "ConversionCanceledError";
|
|
}
|
|
};
|
|
var MAX_TIMESTAMP_GAP = 5;
|
|
var TrackSynchronizer = class {
|
|
constructor() {
|
|
this.maxTimestamps = /* @__PURE__ */ new Map();
|
|
// Track ID -> timestamp
|
|
this.resolvers = [];
|
|
}
|
|
computeMinAndMaybeResolve() {
|
|
let newMin = Infinity;
|
|
for (const [, timestamp] of this.maxTimestamps) {
|
|
newMin = Math.min(newMin, timestamp);
|
|
}
|
|
for (let i = 0; i < this.resolvers.length; i++) {
|
|
const entry = this.resolvers[i];
|
|
if (entry.timestamp - newMin < MAX_TIMESTAMP_GAP) {
|
|
entry.resolve();
|
|
this.resolvers.splice(i, 1);
|
|
i--;
|
|
}
|
|
}
|
|
return newMin;
|
|
}
|
|
shouldWait(trackId, timestamp) {
|
|
this.maxTimestamps.set(trackId, Math.max(timestamp, this.maxTimestamps.get(trackId) ?? -Infinity));
|
|
const newMin = this.computeMinAndMaybeResolve();
|
|
return timestamp - newMin >= MAX_TIMESTAMP_GAP;
|
|
}
|
|
wait(timestamp) {
|
|
const { promise, resolve } = promiseWithResolvers();
|
|
this.resolvers.push({
|
|
timestamp,
|
|
resolve
|
|
});
|
|
return promise;
|
|
}
|
|
closeTrack(trackId) {
|
|
this.maxTimestamps.delete(trackId);
|
|
this.computeMinAndMaybeResolve();
|
|
}
|
|
};
|
|
var AudioResampler = class {
|
|
constructor(options) {
|
|
this.sourceSampleRate = null;
|
|
this.sourceNumberOfChannels = null;
|
|
this.targetSampleRate = options.targetSampleRate;
|
|
this.targetNumberOfChannels = options.targetNumberOfChannels;
|
|
this.startTime = options.startTime;
|
|
this.endTime = options.endTime;
|
|
this.onSample = options.onSample;
|
|
this.bufferSizeInFrames = Math.floor(this.targetSampleRate * 5);
|
|
this.bufferSizeInSamples = this.bufferSizeInFrames * this.targetNumberOfChannels;
|
|
this.outputBuffer = new Float32Array(this.bufferSizeInSamples);
|
|
this.bufferStartFrame = 0;
|
|
this.maxWrittenFrame = -1;
|
|
}
|
|
/**
|
|
* Sets up the channel mixer to handle up/downmixing in the case where input and output channel counts don't match.
|
|
*/
|
|
doChannelMixerSetup() {
|
|
assert(this.sourceNumberOfChannels !== null);
|
|
const sourceNum = this.sourceNumberOfChannels;
|
|
const targetNum = this.targetNumberOfChannels;
|
|
if (sourceNum === 1 && targetNum === 2) {
|
|
this.channelMixer = (sourceData, sourceFrameIndex) => {
|
|
return sourceData[sourceFrameIndex * sourceNum];
|
|
};
|
|
} else if (sourceNum === 1 && targetNum === 4) {
|
|
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
return sourceData[sourceFrameIndex * sourceNum] * +(targetChannelIndex < 2);
|
|
};
|
|
} else if (sourceNum === 1 && targetNum === 6) {
|
|
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
return sourceData[sourceFrameIndex * sourceNum] * +(targetChannelIndex === 2);
|
|
};
|
|
} else if (sourceNum === 2 && targetNum === 1) {
|
|
this.channelMixer = (sourceData, sourceFrameIndex) => {
|
|
const baseIdx = sourceFrameIndex * sourceNum;
|
|
return 0.5 * (sourceData[baseIdx] + sourceData[baseIdx + 1]);
|
|
};
|
|
} else if (sourceNum === 2 && targetNum === 4) {
|
|
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
return sourceData[sourceFrameIndex * sourceNum + targetChannelIndex] * +(targetChannelIndex < 2);
|
|
};
|
|
} else if (sourceNum === 2 && targetNum === 6) {
|
|
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
return sourceData[sourceFrameIndex * sourceNum + targetChannelIndex] * +(targetChannelIndex < 2);
|
|
};
|
|
} else if (sourceNum === 4 && targetNum === 1) {
|
|
this.channelMixer = (sourceData, sourceFrameIndex) => {
|
|
const baseIdx = sourceFrameIndex * sourceNum;
|
|
return 0.25 * (sourceData[baseIdx] + sourceData[baseIdx + 1] + sourceData[baseIdx + 2] + sourceData[baseIdx + 3]);
|
|
};
|
|
} else if (sourceNum === 4 && targetNum === 2) {
|
|
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
const baseIdx = sourceFrameIndex * sourceNum;
|
|
return 0.5 * (sourceData[baseIdx + targetChannelIndex] + sourceData[baseIdx + targetChannelIndex + 2]);
|
|
};
|
|
} else if (sourceNum === 4 && targetNum === 6) {
|
|
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
const baseIdx = sourceFrameIndex * sourceNum;
|
|
if (targetChannelIndex < 2) return sourceData[baseIdx + targetChannelIndex];
|
|
if (targetChannelIndex === 2 || targetChannelIndex === 3) return 0;
|
|
return sourceData[baseIdx + targetChannelIndex - 2];
|
|
};
|
|
} else if (sourceNum === 6 && targetNum === 1) {
|
|
this.channelMixer = (sourceData, sourceFrameIndex) => {
|
|
const baseIdx = sourceFrameIndex * sourceNum;
|
|
return Math.SQRT1_2 * (sourceData[baseIdx] + sourceData[baseIdx + 1]) + sourceData[baseIdx + 2] + 0.5 * (sourceData[baseIdx + 4] + sourceData[baseIdx + 5]);
|
|
};
|
|
} else if (sourceNum === 6 && targetNum === 2) {
|
|
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
const baseIdx = sourceFrameIndex * sourceNum;
|
|
return sourceData[baseIdx + targetChannelIndex] + Math.SQRT1_2 * (sourceData[baseIdx + 2] + sourceData[baseIdx + targetChannelIndex + 4]);
|
|
};
|
|
} else if (sourceNum === 6 && targetNum === 4) {
|
|
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
const baseIdx = sourceFrameIndex * sourceNum;
|
|
if (targetChannelIndex < 2) {
|
|
return sourceData[baseIdx + targetChannelIndex] + Math.SQRT1_2 * sourceData[baseIdx + 2];
|
|
}
|
|
return sourceData[baseIdx + targetChannelIndex + 2];
|
|
};
|
|
} else {
|
|
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
return targetChannelIndex < sourceNum ? sourceData[sourceFrameIndex * sourceNum + targetChannelIndex] : 0;
|
|
};
|
|
}
|
|
}
|
|
ensureTempBufferSize(requiredSamples) {
|
|
let length = this.tempSourceBuffer.length;
|
|
while (length < requiredSamples) {
|
|
length *= 2;
|
|
}
|
|
if (length !== this.tempSourceBuffer.length) {
|
|
const newBuffer = new Float32Array(length);
|
|
newBuffer.set(this.tempSourceBuffer);
|
|
this.tempSourceBuffer = newBuffer;
|
|
}
|
|
}
|
|
async add(audioSample) {
|
|
if (this.sourceSampleRate === null) {
|
|
this.sourceSampleRate = audioSample.sampleRate;
|
|
this.sourceNumberOfChannels = audioSample.numberOfChannels;
|
|
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
|
|
this.doChannelMixerSetup();
|
|
}
|
|
const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels;
|
|
this.ensureTempBufferSize(requiredSamples);
|
|
const sourceDataSize = audioSample.allocationSize({ planeIndex: 0, format: "f32" });
|
|
const sourceView = new Float32Array(this.tempSourceBuffer.buffer, 0, sourceDataSize / 4);
|
|
audioSample.copyTo(sourceView, { planeIndex: 0, format: "f32" });
|
|
const inputStartTime = audioSample.timestamp - this.startTime;
|
|
const inputDuration = audioSample.numberOfFrames / this.sourceSampleRate;
|
|
const inputEndTime = Math.min(inputStartTime + inputDuration, this.endTime - this.startTime);
|
|
const outputStartFrame = Math.floor(inputStartTime * this.targetSampleRate);
|
|
const outputEndFrame = Math.ceil(inputEndTime * this.targetSampleRate);
|
|
for (let outputFrame = outputStartFrame; outputFrame < outputEndFrame; outputFrame++) {
|
|
if (outputFrame < this.bufferStartFrame) {
|
|
continue;
|
|
}
|
|
while (outputFrame >= this.bufferStartFrame + this.bufferSizeInFrames) {
|
|
await this.finalizeCurrentBuffer();
|
|
this.bufferStartFrame += this.bufferSizeInFrames;
|
|
}
|
|
const bufferFrameIndex = outputFrame - this.bufferStartFrame;
|
|
assert(bufferFrameIndex < this.bufferSizeInFrames);
|
|
const outputTime = outputFrame / this.targetSampleRate;
|
|
const inputTime = outputTime - inputStartTime;
|
|
const sourcePosition = inputTime * this.sourceSampleRate;
|
|
const sourceLowerFrame = Math.floor(sourcePosition);
|
|
const sourceUpperFrame = Math.ceil(sourcePosition);
|
|
const fraction = sourcePosition - sourceLowerFrame;
|
|
for (let targetChannel = 0; targetChannel < this.targetNumberOfChannels; targetChannel++) {
|
|
let lowerSample = 0;
|
|
let upperSample = 0;
|
|
if (sourceLowerFrame >= 0 && sourceLowerFrame < audioSample.numberOfFrames) {
|
|
lowerSample = this.channelMixer(sourceView, sourceLowerFrame, targetChannel);
|
|
}
|
|
if (sourceUpperFrame >= 0 && sourceUpperFrame < audioSample.numberOfFrames) {
|
|
upperSample = this.channelMixer(sourceView, sourceUpperFrame, targetChannel);
|
|
}
|
|
const outputSample = lowerSample + fraction * (upperSample - lowerSample);
|
|
const outputIndex = bufferFrameIndex * this.targetNumberOfChannels + targetChannel;
|
|
this.outputBuffer[outputIndex] += outputSample;
|
|
}
|
|
this.maxWrittenFrame = Math.max(this.maxWrittenFrame, bufferFrameIndex);
|
|
}
|
|
}
|
|
async finalizeCurrentBuffer() {
|
|
if (this.maxWrittenFrame < 0) {
|
|
return;
|
|
}
|
|
const samplesWritten = (this.maxWrittenFrame + 1) * this.targetNumberOfChannels;
|
|
const outputData = new Float32Array(samplesWritten);
|
|
outputData.set(this.outputBuffer.subarray(0, samplesWritten));
|
|
const timestampSeconds = this.bufferStartFrame / this.targetSampleRate;
|
|
const audioSample = new AudioSample({
|
|
format: "f32",
|
|
sampleRate: this.targetSampleRate,
|
|
numberOfChannels: this.targetNumberOfChannels,
|
|
timestamp: timestampSeconds,
|
|
data: outputData
|
|
});
|
|
await this.onSample(audioSample);
|
|
this.outputBuffer.fill(0);
|
|
this.maxWrittenFrame = -1;
|
|
}
|
|
finalize() {
|
|
return this.finalizeCurrentBuffer();
|
|
}
|
|
};
|
|
|
|
// src/index.ts
|
|
var MEDIABUNNY_LOADED_SYMBOL = Symbol.for("mediabunny loaded");
|
|
if (globalThis[MEDIABUNNY_LOADED_SYMBOL]) {
|
|
console.error(
|
|
"[WARNING]\nMediabunny was loaded twice. This will likely cause Mediabunny not to work correctly. Check if multiple dependencies are importing different versions of Mediabunny, or if something is being bundled incorrectly."
|
|
);
|
|
}
|
|
globalThis[MEDIABUNNY_LOADED_SYMBOL] = true;
|
|
return __toCommonJS(index_exports);
|
|
})();
|
|
if (typeof module === "object" && typeof module.exports === "object") Object.assign(module.exports, Mediabunny)
|