mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-01 21:03:46 +02:00
6307 lines
210 KiB
JavaScript
6307 lines
210 KiB
JavaScript
// src/misc.ts
|
|
function assert(x) {
|
|
if (!x) {
|
|
throw new Error("Assertion failed.");
|
|
}
|
|
}
|
|
var last = (arr) => {
|
|
return arr && arr[arr.length - 1];
|
|
};
|
|
var isU32 = (value) => {
|
|
return value >= 0 && value < 2 ** 32;
|
|
};
|
|
var readBits = (bytes2, start, end) => {
|
|
let result = 0;
|
|
for (let i = start; i < end; i++) {
|
|
const byteIndex = Math.floor(i / 8);
|
|
const byte = bytes2[byteIndex];
|
|
const bitIndex = 7 - (i & 7);
|
|
const bit = (byte & 1 << bitIndex) >> bitIndex;
|
|
result <<= 1;
|
|
result |= bit;
|
|
}
|
|
return result;
|
|
};
|
|
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 instanceof ArrayBuffer) {
|
|
return new Uint8Array(source);
|
|
} else {
|
|
return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
|
|
}
|
|
};
|
|
var textEncoder = new TextEncoder();
|
|
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
|
|
};
|
|
var COLOR_PRIMARIES_MAP_INVERSE = invertObject(COLOR_PRIMARIES_MAP);
|
|
var TRANSFER_CHARACTERISTICS_MAP = {
|
|
"bt709": 1,
|
|
// ITU-R BT.709
|
|
"smpte170m": 6,
|
|
// SMPTE 170M
|
|
"iec61966-2-1": 13
|
|
// IEC 61966-2-1
|
|
};
|
|
var TRANSFER_CHARACTERISTICS_MAP_INVERSE = 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
|
|
};
|
|
var MATRIX_COEFFICIENTS_MAP_INVERSE = 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) && !(x instanceof DataView);
|
|
};
|
|
var AsyncMutex = class {
|
|
currentPromise = Promise.resolve();
|
|
async acquire() {
|
|
let resolver;
|
|
const nextPromise = new Promise((resolve) => {
|
|
resolver = resolve;
|
|
});
|
|
const currentPromiseAlias = this.currentPromise;
|
|
this.currentPromise = nextPromise;
|
|
await currentPromiseAlias;
|
|
return resolver;
|
|
}
|
|
};
|
|
var rotationMatrix = (rotationInDegrees) => {
|
|
const theta = rotationInDegrees * (Math.PI / 180);
|
|
const cosTheta = Math.cos(theta);
|
|
const sinTheta = Math.sin(theta);
|
|
return [
|
|
cosTheta,
|
|
sinTheta,
|
|
0,
|
|
-sinTheta,
|
|
cosTheta,
|
|
0,
|
|
0,
|
|
0,
|
|
1
|
|
];
|
|
};
|
|
var IDENTITY_MATRIX = rotationMatrix(0);
|
|
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 res = -1;
|
|
while (low <= high) {
|
|
const mid = low + high >> 1;
|
|
const midVal = valueGetter(arr[mid]);
|
|
if (midVal === key) {
|
|
res = mid;
|
|
high = mid - 1;
|
|
} else if (midVal < key) {
|
|
low = mid + 1;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
return res;
|
|
};
|
|
var binarySearchLessOrEqual = (arr, key, valueGetter) => {
|
|
let ans = -1;
|
|
let low = 0;
|
|
let high = arr.length - 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 promiseWithResolvers = () => {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
};
|
|
var removeItem = (arr, item) => {
|
|
const index = arr.indexOf(item);
|
|
if (index !== -1) {
|
|
arr.splice(index, 1);
|
|
}
|
|
};
|
|
|
|
// 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 {
|
|
options;
|
|
preambleText = null;
|
|
preambleEmitted = false;
|
|
constructor(options) {
|
|
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)) {
|
|
const error = new Error("WebVTT preamble incorrect.");
|
|
this.options.error(error);
|
|
throw error;
|
|
}
|
|
match = cueBlockHeaderRegex.exec(text);
|
|
const preamble = text.slice(0, match?.index ?? text.length).trimEnd();
|
|
if (!preamble) {
|
|
const error = new Error("No WebVTT preamble provided.");
|
|
this.options.error(error);
|
|
throw error;
|
|
}
|
|
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;
|
|
}
|
|
helper = new Uint8Array(8);
|
|
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.
|
|
*/
|
|
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 = new Uint8Array(8);
|
|
var view = 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 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.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 = (trackDatas, creationTime, fragmented = false) => box("moov", void 0, [
|
|
mvhd(creationTime, trackDatas),
|
|
...trackDatas.map((x) => trak(x, creationTime)),
|
|
fragmented ? mvex(trackDatas) : null
|
|
]);
|
|
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) => box("trak", void 0, [
|
|
tkhd(trackData, creationTime),
|
|
mdia(trackData, creationTime)
|
|
]);
|
|
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 = rotation === void 0 || typeof rotation === "number" ? rotationMatrix(rotation ?? 0) : rotation;
|
|
} else {
|
|
matrix = IDENTITY_MATRIX;
|
|
}
|
|
return fullBox("tkhd", +needsU64, 3, [
|
|
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(trackData),
|
|
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(21956),
|
|
// Language ("und", undetermined)
|
|
u16(0)
|
|
// Quality
|
|
]);
|
|
};
|
|
var TRACK_TYPE_TO_COMPONENT_SUBTYPE = {
|
|
video: "vide",
|
|
audio: "soun",
|
|
subtitle: "text"
|
|
};
|
|
var TRACK_TYPE_TO_HANDLER_NAME = {
|
|
video: "VideoHandler",
|
|
audio: "SoundHandler",
|
|
subtitle: "TextHandler"
|
|
};
|
|
var hdlr = (trackData) => fullBox("hdlr", 0, 0, [
|
|
ascii("mhlr"),
|
|
// Component type
|
|
ascii(TRACK_TYPE_TO_COMPONENT_SUBTYPE[trackData.type]),
|
|
// Component subtype
|
|
u32(0),
|
|
// Component manufacturer
|
|
u32(0),
|
|
// Component flags
|
|
u32(0),
|
|
// Component flags mask
|
|
ascii(TRACK_TYPE_TO_HANDLER_NAME[trackData.type], 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),
|
|
stss(trackData),
|
|
stsc(trackData),
|
|
stsz(trackData),
|
|
stco(trackData),
|
|
needsCtts ? ctts(trackData) : null
|
|
]);
|
|
};
|
|
var stsd = (trackData) => {
|
|
let sampleDescription;
|
|
if (trackData.type === "video") {
|
|
sampleDescription = videoSampleDescription(
|
|
VIDEO_CODEC_TO_BOX_NAME[trackData.track.source._codec],
|
|
trackData
|
|
);
|
|
} else if (trackData.type === "audio") {
|
|
sampleDescription = soundSampleDescription(
|
|
AUDIO_CODEC_TO_BOX_NAME[trackData.track.source._codec],
|
|
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;
|
|
assert(decoderConfig.colorSpace);
|
|
const parts = decoderConfig.codec.split(".");
|
|
const profile = Number(parts[1]);
|
|
const level = Number(parts[2]);
|
|
const bitDepth = Number(parts[3]);
|
|
const chromaSubsampling = 0;
|
|
const thirdByte = (bitDepth << 4) + (chromaSubsampling << 1) + Number(decoderConfig.colorSpace.fullRange);
|
|
const colourPrimaries = 2;
|
|
const transferCharacteristics = 2;
|
|
const matrixCoefficients = 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 = () => {
|
|
const marker = 1;
|
|
const version = 1;
|
|
const firstByte = (marker << 7) + version;
|
|
return box("av1C", [
|
|
firstByte,
|
|
0,
|
|
0,
|
|
0
|
|
]);
|
|
};
|
|
var soundSampleDescription = (compressionType, trackData) => box(compressionType, [
|
|
Array(6).fill(0),
|
|
// Reserved
|
|
u16(1),
|
|
// Data reference index
|
|
u16(0),
|
|
// Version
|
|
u16(0),
|
|
// Revision level
|
|
u32(0),
|
|
// Vendor
|
|
u16(trackData.info.numberOfChannels),
|
|
// Number of channels
|
|
u16(16),
|
|
// Sample size (bits)
|
|
u16(0),
|
|
// Compression ID
|
|
u16(0),
|
|
// Packet size
|
|
fixed_16_16(trackData.info.sampleRate)
|
|
// Sample rate
|
|
], [
|
|
AUDIO_CODEC_TO_CONFIGURATION_BOX[trackData.track.source._codec](trackData)
|
|
]);
|
|
var esds = (trackData) => {
|
|
const description = toUint8Array(trackData.info.decoderConfig.description ?? new ArrayBuffer(0));
|
|
let bytes2 = [
|
|
...description
|
|
];
|
|
bytes2 = [
|
|
...u8(64),
|
|
// MPEG-4 Audio
|
|
...u8(21),
|
|
// stream type(6bits)=5 audio, flags(2bits)=1
|
|
...u24(0),
|
|
// 24bit buffer size
|
|
...u32(0),
|
|
// max bitrate
|
|
...u32(0),
|
|
// avg bitrate
|
|
...u8(5),
|
|
// TAG(5) = ASC ([2],[3]) embedded in above OD
|
|
...variableUnsignedInt(bytes2.length),
|
|
...bytes2
|
|
];
|
|
bytes2 = [
|
|
...u16(1),
|
|
// ES_ID = 1
|
|
...u8(0),
|
|
// flags etc = 0
|
|
...u8(4),
|
|
// TAG(4) = ES Descriptor ([2]) embedded in above OD
|
|
...variableUnsignedInt(bytes2.length),
|
|
...bytes2,
|
|
...u8(6),
|
|
// TAG(6)
|
|
...u8(1),
|
|
// length
|
|
...u8(2)
|
|
// data
|
|
];
|
|
bytes2 = [
|
|
...u8(3),
|
|
// TAG(3) = Object Descriptor ([2])
|
|
...variableUnsignedInt(bytes2.length),
|
|
...bytes2
|
|
];
|
|
return fullBox("esds", 0, 0, bytes2);
|
|
};
|
|
var dOps = (trackData) => {
|
|
let preskip = 3840;
|
|
let gain = 0;
|
|
const description = trackData.info.decoderConfig?.description;
|
|
if (description) {
|
|
assert(description.byteLength >= 18);
|
|
const view2 = ArrayBuffer.isView(description) ? new DataView(description.buffer, description.byteOffset, description.byteLength) : new DataView(description);
|
|
preskip = view2.getUint16(10, true);
|
|
gain = view2.getInt16(14, true);
|
|
}
|
|
return box("dOps", [
|
|
u8(0),
|
|
// Version
|
|
u8(trackData.info.numberOfChannels),
|
|
// OutputChannelCount
|
|
u16(preskip),
|
|
u32(trackData.info.sampleRate),
|
|
// InputSampleRate
|
|
fixed_8_8(gain),
|
|
// OutputGain
|
|
u8(0)
|
|
// ChannelMappingFamily
|
|
]);
|
|
};
|
|
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) => 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", 0, 0, [
|
|
u32(trackData.compositionTimeOffsetTable.length),
|
|
// Number of entries
|
|
trackData.compositionTimeOffsetTable.map((x) => [
|
|
// Time-to-sample table
|
|
u32(x.sampleCount),
|
|
// Sample count
|
|
u32(x.sampleCompositionTimeOffset)
|
|
// Sample offset
|
|
])
|
|
]);
|
|
};
|
|
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 VIDEO_CODEC_TO_BOX_NAME = {
|
|
avc: "avc1",
|
|
hevc: "hvc1",
|
|
vp8: "vp08",
|
|
vp9: "vp09",
|
|
av1: "av01"
|
|
};
|
|
var VIDEO_CODEC_TO_CONFIGURATION_BOX = {
|
|
avc: avcC,
|
|
hevc: hvcC,
|
|
vp8: vpcC,
|
|
vp9: vpcC,
|
|
av1: av1C
|
|
};
|
|
var AUDIO_CODEC_TO_BOX_NAME = {
|
|
aac: "mp4a",
|
|
opus: "Opus"
|
|
};
|
|
var AUDIO_CODEC_TO_CONFIGURATION_BOX = {
|
|
aac: esds,
|
|
opus: dOps
|
|
};
|
|
var SUBTITLE_CODEC_TO_BOX_NAME = {
|
|
webvtt: "wvtt"
|
|
};
|
|
var SUBTITLE_CODEC_TO_CONFIGURATION_BOX = {
|
|
webvtt: vttC
|
|
};
|
|
|
|
// src/muxer.ts
|
|
var Muxer = class {
|
|
output;
|
|
mutex = new AsyncMutex();
|
|
constructor(output) {
|
|
this.output = output;
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
beforeTrackAdd(track) {
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
onTrackClose(track) {
|
|
}
|
|
trackTimestampInfo = /* @__PURE__ */ new WeakMap();
|
|
validateAndNormalizeTimestamp(track, rawTimestampInUs, isKeyFrame) {
|
|
let timestampInSeconds = rawTimestampInUs / 1e6;
|
|
let timestampInfo = this.trackTimestampInfo.get(track);
|
|
if (!timestampInfo) {
|
|
if (!isKeyFrame) {
|
|
throw new Error("First frame must be a key frame.");
|
|
}
|
|
if (this.timestampsMustStartAtZero && timestampInSeconds > 0) {
|
|
throw new Error(`Timestamps must start at zero (got ${timestampInSeconds}s).`);
|
|
}
|
|
timestampInfo = {
|
|
timestampOffset: timestampInSeconds,
|
|
maxTimestamp: track.source._offsetTimestamps ? 0 : timestampInSeconds,
|
|
lastKeyFrameTimestamp: track.source._offsetTimestamps ? 0 : timestampInSeconds
|
|
};
|
|
this.trackTimestampInfo.set(track, timestampInfo);
|
|
}
|
|
if (track.source._offsetTimestamps) {
|
|
timestampInSeconds -= timestampInfo.timestampOffset;
|
|
}
|
|
if (timestampInSeconds < 0) {
|
|
throw new Error(`Timestamps must be non-negative (got ${timestampInSeconds}s).`);
|
|
}
|
|
if (timestampInSeconds < timestampInfo.lastKeyFrameTimestamp) {
|
|
throw new Error(
|
|
`Timestamp cannot be smaller than last key frame's timestamp (got ${timestampInSeconds}s, last key frame at ${timestampInfo.lastKeyFrameTimestamp}s).`
|
|
);
|
|
}
|
|
if (isKeyFrame) {
|
|
if (timestampInSeconds < timestampInfo.maxTimestamp) {
|
|
throw new Error(
|
|
`Key frame timestamps cannot be smaller than any timestamp that came before (got ${timestampInSeconds}s, max timestamp was ${timestampInfo.maxTimestamp}s).`
|
|
);
|
|
}
|
|
timestampInfo.lastKeyFrameTimestamp = timestampInSeconds;
|
|
}
|
|
timestampInfo.maxTimestamp = Math.max(timestampInfo.maxTimestamp, timestampInSeconds);
|
|
return timestampInSeconds;
|
|
}
|
|
};
|
|
|
|
// src/writer.ts
|
|
var Writer = class {
|
|
/** Setting this to true will cause the writer to ensure data is written in a strictly monotonic, streamable way. */
|
|
ensureMonotonicity = false;
|
|
start() {
|
|
}
|
|
};
|
|
var ArrayBufferTargetWriter = class extends Writer {
|
|
pos = 0;
|
|
target;
|
|
buffer = new ArrayBuffer(2 ** 16);
|
|
bytes = new Uint8Array(this.buffer);
|
|
maxPos = 0;
|
|
constructor(target) {
|
|
super();
|
|
this.target = target;
|
|
}
|
|
ensureSize(size) {
|
|
let newLength = this.buffer.byteLength;
|
|
while (newLength < size) newLength *= 2;
|
|
if (newLength === this.buffer.byteLength) return;
|
|
const newBuffer = new ArrayBuffer(newLength);
|
|
const newBytes = new Uint8Array(newBuffer);
|
|
newBytes.set(this.bytes, 0);
|
|
this.buffer = newBuffer;
|
|
this.bytes = newBytes;
|
|
}
|
|
write(data) {
|
|
this.ensureSize(this.pos + data.byteLength);
|
|
this.bytes.set(data, this.pos);
|
|
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));
|
|
}
|
|
getSlice(start, end) {
|
|
return this.bytes.slice(start, end);
|
|
}
|
|
};
|
|
var StreamTargetWriter = class extends Writer {
|
|
pos = 0;
|
|
target;
|
|
sections = [];
|
|
lastFlushEnd = 0;
|
|
writer = null;
|
|
constructor(target) {
|
|
super();
|
|
this.target = target;
|
|
}
|
|
start() {
|
|
this.writer = this.target._writable.getWriter();
|
|
}
|
|
write(data) {
|
|
this.sections.push({
|
|
data: data.slice(),
|
|
start: this.pos
|
|
});
|
|
this.pos += data.byteLength;
|
|
}
|
|
seek(newPos) {
|
|
this.pos = newPos;
|
|
}
|
|
getPos() {
|
|
return this.pos;
|
|
}
|
|
async flush() {
|
|
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.ensureMonotonicity && chunk.start !== this.lastFlushEnd) {
|
|
throw new Error("Internal error: Monotonicity violation.");
|
|
}
|
|
if (this.writer.desiredSize !== null && this.writer.desiredSize <= 0) {
|
|
await this.writer.ready;
|
|
}
|
|
void this.writer.write({
|
|
type: "write",
|
|
data: chunk.data,
|
|
position: chunk.start
|
|
});
|
|
this.lastFlushEnd = chunk.start + chunk.data.byteLength;
|
|
}
|
|
this.sections.length = 0;
|
|
}
|
|
finalize() {
|
|
assert(this.writer);
|
|
return this.writer.close();
|
|
}
|
|
};
|
|
var DEFAULT_CHUNK_SIZE = 2 ** 24;
|
|
var MAX_CHUNKS_AT_ONCE = 2;
|
|
var ChunkedStreamTargetWriter = class extends Writer {
|
|
pos = 0;
|
|
target;
|
|
chunkSize;
|
|
/**
|
|
* 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.
|
|
*/
|
|
chunks = [];
|
|
lastFlushEnd = 0;
|
|
writer = null;
|
|
flushedChunkQueue = [];
|
|
constructor(target) {
|
|
super();
|
|
this.target = target;
|
|
this.chunkSize = target._options?.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
if (!Number.isInteger(this.chunkSize) || this.chunkSize < 2 ** 10) {
|
|
throw new Error("Invalid StreamTarget options: chunkSize must be an integer not smaller than 1024.");
|
|
}
|
|
}
|
|
start() {
|
|
this.writer = this.target._writable.getWriter();
|
|
}
|
|
write(data) {
|
|
this.writeDataIntoChunks(data, this.pos);
|
|
this.queueChunksForFlush();
|
|
this.pos += data.byteLength;
|
|
}
|
|
seek(newPos) {
|
|
this.pos = newPos;
|
|
}
|
|
getPos() {
|
|
return this.pos;
|
|
}
|
|
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.queueChunksForFlush();
|
|
}
|
|
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);
|
|
}
|
|
queueChunksForFlush(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) {
|
|
if (this.ensureMonotonicity && chunk.start + section.start !== this.lastFlushEnd) {
|
|
throw new Error("Internal error: Monotonicity violation.");
|
|
}
|
|
this.flushedChunkQueue.push({
|
|
type: "write",
|
|
data: chunk.data.subarray(section.start, section.end),
|
|
position: chunk.start + section.start
|
|
});
|
|
this.lastFlushEnd = chunk.start + section.end;
|
|
}
|
|
this.chunks.splice(i--, 1);
|
|
}
|
|
}
|
|
async flush() {
|
|
assert(this.writer);
|
|
if (this.flushedChunkQueue.length === 0) return;
|
|
for (const chunk of this.flushedChunkQueue) {
|
|
if (this.writer.desiredSize !== null && this.writer.desiredSize <= 0) {
|
|
await this.writer.ready;
|
|
}
|
|
void this.writer.write(chunk);
|
|
}
|
|
this.flushedChunkQueue.length = 0;
|
|
}
|
|
async finalize() {
|
|
assert(this.writer);
|
|
this.queueChunksForFlush(true);
|
|
await this.flush();
|
|
return this.writer.close();
|
|
}
|
|
};
|
|
|
|
// src/target.ts
|
|
var Target = class {
|
|
/** @internal */
|
|
_output = null;
|
|
};
|
|
var ArrayBufferTarget = class extends Target {
|
|
buffer = null;
|
|
/** @internal */
|
|
_createWriter() {
|
|
return new ArrayBufferTargetWriter(this);
|
|
}
|
|
};
|
|
var StreamTarget = class extends Target {
|
|
/** @internal */
|
|
_writable;
|
|
/** @internal */
|
|
_options;
|
|
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 <= 0)) {
|
|
throw new TypeError("options.chunkSize, when provided, must be a positive integer.");
|
|
}
|
|
this._writable = writable;
|
|
this._options = options;
|
|
}
|
|
/** @internal */
|
|
_createWriter() {
|
|
return this._options.chunked ? new ChunkedStreamTargetWriter(this) : new StreamTargetWriter(this);
|
|
}
|
|
};
|
|
|
|
// src/codec.ts
|
|
var VIDEO_CODECS = ["avc", "hevc", "vp8", "vp9", "av1"];
|
|
var AUDIO_CODECS = ["aac", "opus"];
|
|
var SUBTITLE_CODECS = ["webvtt"];
|
|
var AVC_LEVEL_TABLE = [
|
|
{ maxMacroblocks: 99, maxBitrate: 64e3, level: 10 },
|
|
// Level 1
|
|
{ maxMacroblocks: 396, maxBitrate: 192e3, level: 11 },
|
|
// Level 1.1
|
|
{ maxMacroblocks: 396, maxBitrate: 384e3, level: 12 },
|
|
// Level 1.2
|
|
{ maxMacroblocks: 396, maxBitrate: 768e3, level: 13 },
|
|
// Level 1.3
|
|
{ maxMacroblocks: 396, maxBitrate: 2e6, level: 20 },
|
|
// Level 2
|
|
{ maxMacroblocks: 792, maxBitrate: 4e6, level: 21 },
|
|
// Level 2.1
|
|
{ maxMacroblocks: 1620, maxBitrate: 4e6, level: 22 },
|
|
// Level 2.2
|
|
{ maxMacroblocks: 1620, maxBitrate: 1e7, level: 30 },
|
|
// Level 3
|
|
{ maxMacroblocks: 3600, maxBitrate: 14e6, level: 31 },
|
|
// Level 3.1
|
|
{ maxMacroblocks: 5120, maxBitrate: 2e7, level: 32 },
|
|
// Level 3.2
|
|
{ maxMacroblocks: 8192, maxBitrate: 2e7, level: 40 },
|
|
// Level 4
|
|
{ maxMacroblocks: 8192, maxBitrate: 5e7, level: 41 },
|
|
// Level 4.1
|
|
{ maxMacroblocks: 8704, maxBitrate: 5e7, level: 42 },
|
|
// Level 4.2
|
|
{ maxMacroblocks: 22080, maxBitrate: 135e6, level: 50 },
|
|
// Level 5
|
|
{ maxMacroblocks: 36864, maxBitrate: 24e7, level: 51 },
|
|
// Level 5.1
|
|
{ maxMacroblocks: 36864, maxBitrate: 24e7, level: 52 },
|
|
// Level 5.2
|
|
{ maxMacroblocks: 139264, maxBitrate: 24e7, level: 60 },
|
|
// Level 6
|
|
{ maxMacroblocks: 139264, maxBitrate: 48e7, level: 61 },
|
|
// Level 6.1
|
|
{ maxMacroblocks: 139264, maxBitrate: 8e8, 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 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}.${bitDepth}`;
|
|
} else if (codec === "av1") {
|
|
const profile = 0;
|
|
const pictureSize = width * height;
|
|
const levelInfo = AV1_LEVEL_TABLE.find(
|
|
(level) => pictureSize <= level.maxPictureSize && bitrate <= level.maxBitrate
|
|
) ?? last(AV1_LEVEL_TABLE);
|
|
const bitDepth = "08";
|
|
return `av01.${profile}.${levelInfo.level.toString().padStart(2, "0")}${levelInfo.tier}.${bitDepth}`;
|
|
}
|
|
throw new TypeError(`Unhandled codec '${codec}'.`);
|
|
};
|
|
var extractVideoCodecString = (codec, description) => {
|
|
if (codec === "avc") {
|
|
if (!description || description.byteLength < 4) {
|
|
throw new TypeError("AVC description must be at least 4 bytes long.");
|
|
}
|
|
return `avc1.${bytesToHexString(description.subarray(1, 4))}`;
|
|
} else if (codec === "hevc") {
|
|
if (!description) {
|
|
throw new TypeError("HEVC description must be provided.");
|
|
}
|
|
const view2 = new DataView(description.buffer, description.byteOffset, description.byteLength);
|
|
let codecString = "hev1.";
|
|
const generalProfileSpace = description[1] >> 6 & 3;
|
|
const generalProfileIdc = description[1] & 31;
|
|
codecString += ["", "A", "B", "C"][generalProfileSpace] + generalProfileIdc;
|
|
codecString += ".";
|
|
const compatibilityFlags = reverseBitsU32(view2.getUint32(2));
|
|
codecString += compatibilityFlags.toString(16);
|
|
codecString += ".";
|
|
const generalTierFlag = description[1] >> 5 & 1;
|
|
const generalLevelIdc = description[12];
|
|
codecString += generalTierFlag === 0 ? "L" : "H";
|
|
codecString += generalLevelIdc;
|
|
codecString += ".";
|
|
const constraintFlags = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
const byte = description[i + 13];
|
|
constraintFlags.push(byte);
|
|
}
|
|
while (constraintFlags[constraintFlags.length - 1] === 0) {
|
|
constraintFlags.pop();
|
|
}
|
|
codecString += constraintFlags.map((x) => x.toString(16)).join(".");
|
|
return codecString;
|
|
}
|
|
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 === "opus") {
|
|
return "opus";
|
|
} else if (codec === "vorbis") {
|
|
return "vorbis";
|
|
}
|
|
throw new TypeError(`Unhandled codec '${codec}'.`);
|
|
};
|
|
var extractAudioCodecString = (codec, description) => {
|
|
if (codec === "aac") {
|
|
const audioSpecificConfig = parseAacAudioSpecificConfig(description);
|
|
return `mp4a.40.${audioSpecificConfig.objectType}`;
|
|
} else if (codec === "opus") {
|
|
return "opus";
|
|
} else if (codec === "vorbis") {
|
|
return "vorbis";
|
|
}
|
|
throw new TypeError(`Unhandled codec '${codec}'.`);
|
|
};
|
|
var parseAacAudioSpecificConfig = (bytes2) => {
|
|
if (!bytes2 || bytes2.byteLength < 2) {
|
|
throw new TypeError("AAC description must be at least 2 bytes long.");
|
|
}
|
|
let bitOffset = 0;
|
|
let objectType = readBits(bytes2, bitOffset, bitOffset + 5);
|
|
bitOffset += 5;
|
|
if (objectType === 31) {
|
|
objectType = 32 + readBits(bytes2, bitOffset, bitOffset + 6);
|
|
bitOffset += 6;
|
|
}
|
|
const frequencyIndex = readBits(bytes2, bitOffset, bitOffset + 4);
|
|
bitOffset += 4;
|
|
let sampleRate = null;
|
|
if (frequencyIndex === 15) {
|
|
sampleRate = readBits(bytes2, bitOffset, bitOffset + 24);
|
|
bitOffset += 24;
|
|
} else {
|
|
const freqTable = [
|
|
96e3,
|
|
88200,
|
|
64e3,
|
|
48e3,
|
|
44100,
|
|
32e3,
|
|
24e3,
|
|
22050,
|
|
16e3,
|
|
12e3,
|
|
11025,
|
|
8e3,
|
|
7350
|
|
];
|
|
if (frequencyIndex < freqTable.length) {
|
|
sampleRate = freqTable[frequencyIndex];
|
|
}
|
|
}
|
|
const channelConfiguration = readBits(bytes2, bitOffset, bitOffset + 4);
|
|
bitOffset += 4;
|
|
let numberOfChannels = null;
|
|
if (channelConfiguration >= 1 && channelConfiguration <= 7) {
|
|
const channelMap = {
|
|
1: 1,
|
|
2: 2,
|
|
3: 3,
|
|
4: 4,
|
|
5: 5,
|
|
6: 6,
|
|
7: 8
|
|
};
|
|
numberOfChannels = channelMap[channelConfiguration];
|
|
}
|
|
return {
|
|
objectType,
|
|
frequencyIndex,
|
|
sampleRate,
|
|
channelConfiguration,
|
|
numberOfChannels
|
|
};
|
|
};
|
|
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 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 (!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")) && !metadata.decoderConfig.description) {
|
|
throw new TypeError(
|
|
"Video chunk metadata decoder configuration for AVC must include a description, which is expected to be an AVCDecoderConfigurationRecord as specified in ISO 14496-15."
|
|
);
|
|
}
|
|
if ((metadata.decoderConfig.codec.startsWith("hev1") || metadata.decoderConfig.codec.startsWith("hvc1")) && !metadata.decoderConfig.description) {
|
|
throw new TypeError(
|
|
"Video chunk metadata decoder configuration for HEVC must include a description, which is expected to be an HEVCDecoderConfigurationRecord as specified in ISO 14496-15."
|
|
);
|
|
}
|
|
if ((metadata.decoderConfig.codec === "vp8" || metadata.decoderConfig.codec.startsWith("vp09")) && metadata.decoderConfig.colorSpace === void 0) {
|
|
throw new TypeError("Video chunk metadata decoder configuration for VP8/VP9 must include a colorSpace.");
|
|
}
|
|
};
|
|
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 (!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.description) {
|
|
throw new TypeError(
|
|
"Audio chunk metadata decoder configuration for AAC must include a description, which is expected to be an AudioSpecificConfig as specified in ISO 14496-3."
|
|
);
|
|
}
|
|
if (metadata.decoderConfig.codec === "opus" && metadata.decoderConfig.description && metadata.decoderConfig.description.byteLength < 18) {
|
|
throw new TypeError("Invalid decoder description provided for Opus; must be at least 18 bytes long.");
|
|
}
|
|
};
|
|
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.");
|
|
}
|
|
};
|
|
|
|
// src/isobmff/isobmff-muxer.ts
|
|
var GLOBAL_TIMESCALE = 1e3;
|
|
var TIMESTAMP_OFFSET = 2082844800;
|
|
var intoTimescale = (timeInSeconds, timescale, round = true) => {
|
|
const value = timeInSeconds * timescale;
|
|
return round ? Math.round(value) : value;
|
|
};
|
|
var IsobmffMuxer = class extends Muxer {
|
|
timestampsMustStartAtZero = true;
|
|
writer;
|
|
boxWriter;
|
|
fastStart;
|
|
auxTarget = new ArrayBufferTarget();
|
|
auxWriter = this.auxTarget._createWriter();
|
|
auxBoxWriter = new IsobmffBoxWriter(this.auxWriter);
|
|
ftypSize = null;
|
|
mdat = null;
|
|
trackDatas = [];
|
|
creationTime = Math.floor(Date.now() / 1e3) + TIMESTAMP_OFFSET;
|
|
finalizedChunks = [];
|
|
nextFragmentNumber = 1;
|
|
constructor(output, format) {
|
|
super(output);
|
|
this.writer = output._writer;
|
|
this.boxWriter = new IsobmffBoxWriter(this.writer);
|
|
const fastStartDefault = this.writer instanceof ArrayBufferTargetWriter ? "in-memory" : false;
|
|
this.fastStart = format._options.fastStart ?? fastStartDefault;
|
|
if (this.fastStart === "in-memory" || this.fastStart === "fragmented") {
|
|
this.writer.ensureMonotonicity = true;
|
|
}
|
|
}
|
|
async start() {
|
|
const release = await this.mutex.acquire();
|
|
const holdsAvc = this.output._tracks.some((x) => x.type === "video" && x.source._codec === "avc");
|
|
this.boxWriter.writeBox(ftyp({
|
|
holdsAvc,
|
|
fragmented: this.fastStart === "fragmented"
|
|
}));
|
|
this.ftypSize = this.writer.getPos();
|
|
if (this.fastStart === "in-memory") {
|
|
this.mdat = mdat(false);
|
|
} else if (this.fastStart === "fragmented") {
|
|
} else {
|
|
this.mdat = mdat(true);
|
|
this.boxWriter.writeBox(this.mdat);
|
|
}
|
|
await this.writer.flush();
|
|
release();
|
|
}
|
|
getVideoTrackData(track, 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
|
|
},
|
|
timescale: track.metadata.frameRate ?? 57600,
|
|
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);
|
|
return newTrackData;
|
|
}
|
|
getAudioTrackData(track, meta) {
|
|
const existingTrackData = this.trackDatas.find((x) => x.track === track);
|
|
if (existingTrackData) {
|
|
return existingTrackData;
|
|
}
|
|
validateAudioChunkMetadata(meta);
|
|
assert(meta);
|
|
assert(meta.decoderConfig);
|
|
const newTrackData = {
|
|
track,
|
|
type: "audio",
|
|
info: {
|
|
numberOfChannels: meta.decoderConfig.numberOfChannels,
|
|
sampleRate: meta.decoderConfig.sampleRate,
|
|
decoderConfig: meta.decoderConfig
|
|
},
|
|
timescale: meta.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);
|
|
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
|
|
},
|
|
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);
|
|
this.validateAndNormalizeTimestamp(track, 0, true);
|
|
return newTrackData;
|
|
}
|
|
async addEncodedVideoChunk(track, chunk, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getVideoTrackData(track, meta);
|
|
const data = new Uint8Array(chunk.byteLength);
|
|
chunk.copyTo(data);
|
|
const timestamp = this.validateAndNormalizeTimestamp(
|
|
trackData.track,
|
|
chunk.timestamp,
|
|
chunk.type === "key"
|
|
);
|
|
const sample = this.createSampleForTrack(
|
|
trackData,
|
|
data,
|
|
timestamp,
|
|
(chunk.duration ?? 0) / 1e6,
|
|
chunk.type
|
|
);
|
|
await this.registerSample(trackData, sample);
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async addEncodedAudioChunk(track, chunk, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getAudioTrackData(track, meta);
|
|
const data = new Uint8Array(chunk.byteLength);
|
|
chunk.copyTo(data);
|
|
const chunkType = chunk.type;
|
|
const timestamp = this.validateAndNormalizeTimestamp(
|
|
trackData.track,
|
|
chunk.timestamp,
|
|
chunkType === "key"
|
|
);
|
|
const sample = this.createSampleForTrack(
|
|
trackData,
|
|
data,
|
|
timestamp,
|
|
(chunk.duration ?? 0) / 1e6,
|
|
chunkType
|
|
);
|
|
await this.registerSample(trackData, sample);
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async addSubtitleCue(track, cue, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getSubtitleTrackData(track, meta);
|
|
this.validateAndNormalizeTimestamp(trackData.track, 1e6 * 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,
|
|
// Will be refined once the next sample comes in
|
|
timescaleUnitsToNextSample: intoTimescale(duration, trackData.timescale)
|
|
};
|
|
return sample;
|
|
}
|
|
processTimestamps(trackData) {
|
|
if (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];
|
|
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);
|
|
trackData.lastTimescaleUnits += delta;
|
|
trackData.lastSample.timescaleUnitsToNextSample = delta;
|
|
if (this.fastStart !== "fragmented") {
|
|
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 = 0;
|
|
if (this.fastStart !== "fragmented") {
|
|
trackData.timeToSampleTable.push({
|
|
sampleCount: 1,
|
|
sampleDelta: durationInTimescale
|
|
});
|
|
trackData.compositionTimeOffsetTable.push({
|
|
sampleCount: 1,
|
|
sampleCompositionTimeOffset
|
|
});
|
|
}
|
|
}
|
|
trackData.lastSample = sample;
|
|
}
|
|
trackData.timestampProcessingQueue.length = 0;
|
|
}
|
|
async registerSample(trackData, sample) {
|
|
if (this.fastStart === "fragmented") {
|
|
trackData.sampleQueue.push(sample);
|
|
await this.interleaveSamples();
|
|
} else {
|
|
await this.addSampleToTrack(trackData, sample);
|
|
}
|
|
}
|
|
async addSampleToTrack(trackData, sample) {
|
|
if (sample.type === "key") {
|
|
this.processTimestamps(trackData);
|
|
}
|
|
if (this.fastStart !== "fragmented") {
|
|
trackData.samples.push(sample);
|
|
}
|
|
let beginNewChunk = false;
|
|
if (!trackData.currentChunk) {
|
|
beginNewChunk = true;
|
|
} else {
|
|
const currentChunkDuration = sample.timestamp - trackData.currentChunk.startTimestamp;
|
|
if (this.fastStart === "fragmented") {
|
|
const keyFrameQueuedEverywhere = this.trackDatas.every((otherTrackData) => {
|
|
if (trackData === otherTrackData) {
|
|
return sample.type === "key";
|
|
}
|
|
const firstQueuedSample = otherTrackData.sampleQueue[0];
|
|
return firstQueuedSample && firstQueuedSample.type === "key";
|
|
});
|
|
if (currentChunkDuration >= 1 && keyFrameQueuedEverywhere) {
|
|
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);
|
|
trackData.timestampProcessingQueue.push(sample);
|
|
}
|
|
async finalizeCurrentChunk(trackData) {
|
|
assert(this.fastStart !== "fragmented");
|
|
if (!trackData.currentChunk) return;
|
|
trackData.finalizedChunks.push(trackData.currentChunk);
|
|
this.finalizedChunks.push(trackData.currentChunk);
|
|
if (trackData.compactlyCodedChunkTable.length === 0 || last(trackData.compactlyCodedChunkTable).samplesPerChunk !== trackData.currentChunk.samples.length) {
|
|
trackData.compactlyCodedChunkTable.push({
|
|
firstChunk: trackData.finalizedChunks.length,
|
|
// 1-indexed
|
|
samplesPerChunk: trackData.currentChunk.samples.length
|
|
});
|
|
}
|
|
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() {
|
|
assert(this.fastStart === "fragmented");
|
|
for (const track of this.output._tracks) {
|
|
if (!track.source._closed && !this.trackDatas.some((x) => x.track === track)) {
|
|
return;
|
|
}
|
|
}
|
|
outer:
|
|
while (true) {
|
|
let trackWithMinTimestamp = null;
|
|
let minTimestamp = Infinity;
|
|
for (const trackData of this.trackDatas) {
|
|
if (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.fastStart === "fragmented");
|
|
const fragmentNumber = this.nextFragmentNumber++;
|
|
if (fragmentNumber === 1) {
|
|
const movieBox = moov(this.trackDatas, this.creationTime, true);
|
|
this.boxWriter.writeBox(movieBox);
|
|
}
|
|
const tracksInFragment = this.trackDatas.filter((x) => x.currentChunk);
|
|
const moofOffset = this.writer.getPos();
|
|
const moofBox = moof(fragmentNumber, tracksInFragment);
|
|
this.boxWriter.writeBox(moofBox);
|
|
{
|
|
const mdatBox = mdat(false);
|
|
let totalTrackSampleSize = 0;
|
|
for (const trackData of tracksInFragment) {
|
|
for (const sample of trackData.currentChunk.samples) {
|
|
totalTrackSampleSize += sample.size;
|
|
}
|
|
}
|
|
let mdatSize = this.boxWriter.measureBox(mdatBox) + totalTrackSampleSize;
|
|
if (mdatSize >= 2 ** 32) {
|
|
mdatBox.largeSize = true;
|
|
mdatSize = this.boxWriter.measureBox(mdatBox) + totalTrackSampleSize;
|
|
}
|
|
mdatBox.size = mdatSize;
|
|
this.boxWriter.writeBox(mdatBox);
|
|
}
|
|
for (const trackData of tracksInFragment) {
|
|
trackData.currentChunk.offset = this.writer.getPos();
|
|
trackData.currentChunk.moofOffset = moofOffset;
|
|
for (const sample of trackData.currentChunk.samples) {
|
|
this.writer.write(sample.data);
|
|
sample.data = null;
|
|
}
|
|
}
|
|
const endPos = this.writer.getPos();
|
|
this.writer.seek(this.boxWriter.offsets.get(moofBox));
|
|
const newMoofBox = moof(fragmentNumber, tracksInFragment);
|
|
this.boxWriter.writeBox(newMoofBox);
|
|
this.writer.seek(endPos);
|
|
for (const trackData of tracksInFragment) {
|
|
trackData.finalizedChunks.push(trackData.currentChunk);
|
|
this.finalizedChunks.push(trackData.currentChunk);
|
|
trackData.currentChunk = null;
|
|
}
|
|
if (flushWriter) {
|
|
await this.writer.flush();
|
|
}
|
|
}
|
|
// 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.fastStart === "fragmented") {
|
|
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();
|
|
for (const trackData of this.trackDatas) {
|
|
if (trackData.type === "subtitle" && trackData.track.source._codec === "webvtt") {
|
|
await this.processWebVTTCues(trackData, Infinity);
|
|
}
|
|
}
|
|
if (this.fastStart === "fragmented") {
|
|
for (const trackData of this.trackDatas) {
|
|
for (const sample of trackData.sampleQueue) {
|
|
await this.addSampleToTrack(trackData, sample);
|
|
}
|
|
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") {
|
|
assert(this.mdat);
|
|
let mdatSize;
|
|
for (let i = 0; i < 2; i++) {
|
|
const movieBox2 = moov(this.trackDatas, this.creationTime);
|
|
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;
|
|
}
|
|
const movieBox = moov(this.trackDatas, this.creationTime);
|
|
this.boxWriter.writeBox(movieBox);
|
|
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;
|
|
}
|
|
}
|
|
} else if (this.fastStart === "fragmented") {
|
|
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);
|
|
assert(this.ftypSize !== null);
|
|
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);
|
|
const movieBox = moov(this.trackDatas, this.creationTime);
|
|
if (typeof this.fastStart === "object") {
|
|
this.writer.seek(this.ftypSize);
|
|
this.boxWriter.writeBox(movieBox);
|
|
const remainingBytes = mdatPos - this.writer.getPos();
|
|
this.boxWriter.writeBox(free(remainingBytes));
|
|
} else {
|
|
this.boxWriter.writeBox(movieBox);
|
|
}
|
|
}
|
|
release();
|
|
}
|
|
};
|
|
|
|
// src/matroska/ebml.ts
|
|
var EBMLFloat32 = class {
|
|
value;
|
|
constructor(value) {
|
|
this.value = value;
|
|
}
|
|
};
|
|
var EBMLFloat64 = class {
|
|
value;
|
|
constructor(value) {
|
|
this.value = value;
|
|
}
|
|
};
|
|
var EBMLSignedInt = class {
|
|
value;
|
|
constructor(value) {
|
|
this.value = value;
|
|
}
|
|
};
|
|
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 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 measureEBMLVarInt = (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 VINT size not supported " + value);
|
|
}
|
|
};
|
|
|
|
// src/matroska/matroska-muxer.ts
|
|
var MAX_CHUNK_LENGTH_MS = 2 ** 15;
|
|
var APP_NAME = "https://github.com/Vanilagy/webm-muxer";
|
|
var SEGMENT_SIZE_BYTES = 6;
|
|
var CLUSTER_SIZE_BYTES = 5;
|
|
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",
|
|
opus: "A_OPUS",
|
|
webvtt: "S_TEXT/WEBVTT"
|
|
};
|
|
var TRACK_TYPE_MAP = {
|
|
video: 1,
|
|
audio: 2,
|
|
subtitle: 17
|
|
};
|
|
var MatroskaMuxer = class extends Muxer {
|
|
timestampsMustStartAtZero = false;
|
|
writer;
|
|
format;
|
|
helper = new Uint8Array(8);
|
|
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.
|
|
*/
|
|
offsets = /* @__PURE__ */ new WeakMap();
|
|
/** Same as offsets, but stores position where the element's data starts (after ID and size fields). */
|
|
dataOffsets = /* @__PURE__ */ new WeakMap();
|
|
trackDatas = [];
|
|
segment = null;
|
|
segmentInfo = null;
|
|
seekHead = null;
|
|
tracksElement = null;
|
|
segmentDuration = null;
|
|
cues = null;
|
|
currentCluster = null;
|
|
currentClusterMsTimestamp = null;
|
|
trackDatasInCurrentCluster = /* @__PURE__ */ new Set();
|
|
duration = 0;
|
|
constructor(output, format) {
|
|
super(output);
|
|
this.writer = output._writer;
|
|
this.format = format;
|
|
if (this.format._options.streamable) {
|
|
this.writer.ensureMonotonicity = true;
|
|
}
|
|
}
|
|
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 UINT size " + width);
|
|
}
|
|
this.writer.write(this.helper.subarray(0, pos));
|
|
}
|
|
writeSignedInt(value, width = measureSignedInt(value)) {
|
|
if (value < 0) {
|
|
value += 2 ** (width * 8);
|
|
}
|
|
this.writeUnsignedInt(value, width);
|
|
}
|
|
writeEBMLVarInt(value, width = measureEBMLVarInt(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 VINT size " + width);
|
|
}
|
|
this.writer.write(this.helper.subarray(0, pos));
|
|
}
|
|
// Assumes the string is ASCII
|
|
writeString(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.writeEBMLVarInt(size, sizeSize);
|
|
this.writer.seek(endPos);
|
|
}
|
|
} else if (typeof data.data === "number") {
|
|
const size = data.size ?? measureUnsignedInt(data.data);
|
|
this.writeEBMLVarInt(size);
|
|
this.writeUnsignedInt(data.data, size);
|
|
} else if (typeof data.data === "string") {
|
|
this.writeEBMLVarInt(data.data.length);
|
|
this.writeString(data.data);
|
|
} else if (data.data instanceof Uint8Array) {
|
|
this.writeEBMLVarInt(data.data.byteLength, data.size);
|
|
this.writer.write(data.data);
|
|
} else if (data.data instanceof EBMLFloat32) {
|
|
this.writeEBMLVarInt(4);
|
|
this.writeFloat32(data.data.value);
|
|
} else if (data.data instanceof EBMLFloat64) {
|
|
this.writeEBMLVarInt(8);
|
|
this.writeFloat64(data.data.value);
|
|
} else if (data.data instanceof EBMLSignedInt) {
|
|
const size = data.size ?? measureSignedInt(data.data.value);
|
|
this.writeEBMLVarInt(size);
|
|
this.writeSignedInt(data.data.value, size);
|
|
}
|
|
}
|
|
}
|
|
beforeTrackAdd(track) {
|
|
if (!(this.format instanceof WebMOutputFormat)) {
|
|
return;
|
|
}
|
|
if (track.type === "video") {
|
|
if (!["vp8", "vp9", "av1"].includes(track.source._codec)) {
|
|
throw new Error(
|
|
`WebM only supports VP8, VP9 and AV1 as video codecs. Switching to MKV removes this restriction.`
|
|
);
|
|
}
|
|
} else if (track.type === "audio") {
|
|
if (!["opus", "vorbis"].includes(track.source._codec)) {
|
|
throw new Error(
|
|
`WebM only supports Opus and Vorbis as audio codecs. Switching to MKV removes this restriction.`
|
|
);
|
|
}
|
|
} else if (track.type === "subtitle") {
|
|
if (track.source._codec !== "webvtt") {
|
|
throw new Error(
|
|
`WebM only supports WebVTT as subtitle codec. Switching to MKV removes this restriction.`
|
|
);
|
|
}
|
|
} else {
|
|
throw new Error(
|
|
"WebM only supports video, audio and subtitle tracks. Switching to MKV removes this restriction."
|
|
);
|
|
}
|
|
}
|
|
async start() {
|
|
const release = await this.mutex.acquire();
|
|
this.writeEBMLHeader();
|
|
if (!this.format._options.streamable) {
|
|
this.createSeekHead();
|
|
}
|
|
this.createSegmentInfo();
|
|
this.createCues();
|
|
await this.writer.flush();
|
|
release();
|
|
}
|
|
writeEBMLHeader() {
|
|
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.writeEBML(ebmlHeader);
|
|
}
|
|
/**
|
|
* 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.
|
|
*/
|
|
createSeekHead() {
|
|
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 seekHead = { id: 290298740 /* SeekHead */, data: [
|
|
{ id: 19899 /* Seek */, data: [
|
|
{ id: 21419 /* SeekID */, data: kaxCues },
|
|
{ id: 21420 /* SeekPosition */, size: 5, data: 0 }
|
|
] },
|
|
{ id: 19899 /* Seek */, data: [
|
|
{ id: 21419 /* SeekID */, data: kaxInfo },
|
|
{ id: 21420 /* SeekPosition */, size: 5, data: 0 }
|
|
] },
|
|
{ id: 19899 /* Seek */, data: [
|
|
{ id: 21419 /* SeekID */, data: kaxTracks },
|
|
{ id: 21420 /* SeekPosition */, size: 5, data: 0 }
|
|
] }
|
|
] };
|
|
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.streamable ? segmentDuration : null
|
|
] };
|
|
this.segmentInfo = segmentInfo;
|
|
}
|
|
createTracks() {
|
|
const tracksElement = { id: 374648427 /* Tracks */, data: [] };
|
|
this.tracksElement = tracksElement;
|
|
for (const trackData of this.trackDatas) {
|
|
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] },
|
|
{ id: 134 /* CodecID */, data: CODEC_STRING_MAP[trackData.track.source._codec] },
|
|
trackData.type === "video" ? this.videoSpecificTrackInfo(trackData) : null,
|
|
trackData.type === "audio" ? this.audioSpecificTrackInfo(trackData) : null,
|
|
trackData.type === "subtitle" ? this.subtitleSpecificTrackInfo(trackData) : null
|
|
] });
|
|
}
|
|
}
|
|
videoSpecificTrackInfo(trackData) {
|
|
const elements = [
|
|
trackData.info.decoderConfig.description ? {
|
|
id: 25506 /* CodecPrivate */,
|
|
data: toUint8Array(trackData.info.decoderConfig.description)
|
|
} : null,
|
|
trackData.track.metadata.frameRate ? {
|
|
id: 2352003 /* DefaultDuration */,
|
|
data: 1e9 / trackData.track.metadata.frameRate
|
|
} : null
|
|
];
|
|
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 },
|
|
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
|
|
] };
|
|
elements.push(videoElement);
|
|
return elements;
|
|
}
|
|
audioSpecificTrackInfo(trackData) {
|
|
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 }
|
|
// TODO Bit depth for when PCM is a thing
|
|
] }
|
|
];
|
|
}
|
|
subtitleSpecificTrackInfo(trackData) {
|
|
return [
|
|
{ id: 25506 /* CodecPrivate */, data: textEncoder.encode(trackData.info.config.description) }
|
|
];
|
|
}
|
|
createSegment() {
|
|
const segment = {
|
|
id: 408125543 /* Segment */,
|
|
size: this.format._options.streamable ? -1 : SEGMENT_SIZE_BYTES,
|
|
data: [
|
|
!this.format._options.streamable ? this.seekHead : null,
|
|
this.segmentInfo,
|
|
this.tracksElement
|
|
]
|
|
};
|
|
this.segment = segment;
|
|
this.writeEBML(segment);
|
|
}
|
|
createCues() {
|
|
this.cues = { id: 475249515 /* Cues */, data: [] };
|
|
}
|
|
get segmentDataOffset() {
|
|
assert(this.segment);
|
|
return this.dataOffsets.get(this.segment);
|
|
}
|
|
getVideoTrackData(track, 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
|
|
},
|
|
chunkQueue: [],
|
|
lastWrittenMsTimestamp: null
|
|
};
|
|
this.trackDatas.push(newTrackData);
|
|
this.trackDatas.sort((a, b) => a.track.id - b.track.id);
|
|
return newTrackData;
|
|
}
|
|
getAudioTrackData(track, meta) {
|
|
const existingTrackData = this.trackDatas.find((x) => x.track === track);
|
|
if (existingTrackData) {
|
|
return existingTrackData;
|
|
}
|
|
validateAudioChunkMetadata(meta);
|
|
assert(meta);
|
|
assert(meta.decoderConfig);
|
|
const newTrackData = {
|
|
track,
|
|
type: "audio",
|
|
info: {
|
|
numberOfChannels: meta.decoderConfig.numberOfChannels,
|
|
sampleRate: meta.decoderConfig.sampleRate,
|
|
decoderConfig: meta.decoderConfig
|
|
},
|
|
chunkQueue: [],
|
|
lastWrittenMsTimestamp: null
|
|
};
|
|
this.trackDatas.push(newTrackData);
|
|
this.trackDatas.sort((a, b) => a.track.id - b.track.id);
|
|
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);
|
|
return newTrackData;
|
|
}
|
|
async addEncodedVideoChunk(track, chunk, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getVideoTrackData(track, meta);
|
|
const data = new Uint8Array(chunk.byteLength);
|
|
chunk.copyTo(data);
|
|
const isKeyFrame = chunk.type === "key";
|
|
const timestamp = this.validateAndNormalizeTimestamp(trackData.track, chunk.timestamp, isKeyFrame);
|
|
const videoChunk = this.createInternalChunk(data, timestamp, (chunk.duration ?? 0) / 1e6, chunk.type);
|
|
if (track.source._codec === "vp9") this.fixVP9ColorSpace(trackData, videoChunk);
|
|
trackData.chunkQueue.push(videoChunk);
|
|
await this.interleaveChunks();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
async addEncodedAudioChunk(track, chunk, meta) {
|
|
const release = await this.mutex.acquire();
|
|
try {
|
|
const trackData = this.getAudioTrackData(track, meta);
|
|
const data = new Uint8Array(chunk.byteLength);
|
|
chunk.copyTo(data);
|
|
const chunkType = chunk.type;
|
|
const isKeyFrame = chunkType === "key";
|
|
const timestamp = this.validateAndNormalizeTimestamp(trackData.track, chunk.timestamp, isKeyFrame);
|
|
const audioChunk = this.createInternalChunk(data, timestamp, (chunk.duration ?? 0) / 1e6, chunkType);
|
|
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, 1e6 * cue.timestamp, true);
|
|
let bodyText = cue.text;
|
|
const timestampMs = Math.floor(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() {
|
|
for (const track of this.output._tracks) {
|
|
if (!track.source._closed && !this.trackDatas.some((x) => x.track === track)) {
|
|
return;
|
|
}
|
|
}
|
|
outer:
|
|
while (true) {
|
|
let trackWithMinTimestamp = null;
|
|
let minTimestamp = Infinity;
|
|
for (const trackData of this.trackDatas) {
|
|
if (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);
|
|
}
|
|
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. */
|
|
// http://downloads.webmproject.org/docs/vp9/vp9-bitstream_superframe-and-uncompressed-header_v1.0.pdf
|
|
fixVP9ColorSpace(trackData, chunk) {
|
|
if (chunk.type !== "key") return;
|
|
if (!trackData.info.decoderConfig.colorSpace || !trackData.info.decoderConfig.colorSpace.matrix) return;
|
|
let i = 0;
|
|
if (readBits(chunk.data, 0, 2) !== 2) return;
|
|
i += 2;
|
|
const profile = (readBits(chunk.data, i + 1, i + 2) << 1) + readBits(chunk.data, i + 0, i + 1);
|
|
i += 2;
|
|
if (profile === 3) i++;
|
|
const showExistingFrame = readBits(chunk.data, i + 0, i + 1);
|
|
i++;
|
|
if (showExistingFrame) return;
|
|
const frameType = readBits(chunk.data, i + 0, i + 1);
|
|
i++;
|
|
if (frameType !== 0) return;
|
|
i += 2;
|
|
const syncCode = readBits(chunk.data, i + 0, i + 24);
|
|
i += 24;
|
|
if (syncCode !== 4817730) return;
|
|
if (profile >= 2) i++;
|
|
const colorSpaceID = {
|
|
rgb: 7,
|
|
bt709: 2,
|
|
bt470bg: 1,
|
|
smpte170m: 3
|
|
}[trackData.info.decoderConfig.colorSpace.matrix];
|
|
writeBits(chunk.data, i + 0, i + 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.createTracks();
|
|
this.createSegment();
|
|
}
|
|
const msTimestamp = Math.floor(1e3 * chunk.timestamp);
|
|
const keyFrameQueuedEverywhere = this.trackDatas.every((otherTrackData) => {
|
|
if (otherTrackData.track.source._closed) {
|
|
return true;
|
|
}
|
|
if (trackData === otherTrackData) {
|
|
return chunk.type === "key";
|
|
}
|
|
const firstQueuedSample = otherTrackData.chunkQueue[0];
|
|
return firstQueuedSample && firstQueuedSample.type === "key";
|
|
});
|
|
if (!this.currentCluster || keyFrameQueuedEverywhere && msTimestamp - this.currentClusterMsTimestamp >= 1e3) {
|
|
this.createNewCluster(msTimestamp);
|
|
}
|
|
const relativeTimestamp = msTimestamp - this.currentClusterMsTimestamp;
|
|
if (relativeTimestamp < 0) {
|
|
return;
|
|
}
|
|
const clusterIsTooLong = relativeTimestamp >= MAX_CHUNK_LENGTH_MS;
|
|
if (clusterIsTooLong) {
|
|
throw new Error(
|
|
`Current Matroska cluster exceeded its maximum allowed length of ${MAX_CHUNK_LENGTH_MS} milliseconds. In order to produce a correct WebM file, you must pass in a key frame at least every ${MAX_CHUNK_LENGTH_MS} milliseconds.`
|
|
);
|
|
}
|
|
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.floor(1e3 * chunk.duration);
|
|
if (msDuration === 0 && !chunk.additions) {
|
|
view2.setUint8(3, Number(chunk.type === "key") << 7);
|
|
const simpleBlock = { id: 163 /* SimpleBlock */, data: [
|
|
prelude,
|
|
chunk.data
|
|
] };
|
|
this.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: 165 /* BlockAdditional */, data: chunk.additions },
|
|
{ id: 238 /* BlockAddID */, data: 1 }
|
|
] }
|
|
] } : null,
|
|
msDuration > 0 ? { id: 155 /* BlockDuration */, data: msDuration } : null
|
|
] };
|
|
this.writeEBML(blockGroup);
|
|
}
|
|
this.duration = Math.max(this.duration, msTimestamp + msDuration);
|
|
trackData.lastWrittenMsTimestamp = msTimestamp;
|
|
this.trackDatasInCurrentCluster.add(trackData);
|
|
}
|
|
/** Creates a new Cluster element to contain media chunks. */
|
|
createNewCluster(msTimestamp) {
|
|
if (this.currentCluster && !this.format._options.streamable) {
|
|
this.finalizeCurrentCluster();
|
|
}
|
|
this.currentCluster = {
|
|
id: 524531317 /* Cluster */,
|
|
size: this.format._options.streamable ? -1 : CLUSTER_SIZE_BYTES,
|
|
data: [
|
|
{ id: 231 /* Timestamp */, data: msTimestamp }
|
|
]
|
|
};
|
|
this.writeEBML(this.currentCluster);
|
|
this.currentClusterMsTimestamp = msTimestamp;
|
|
this.trackDatasInCurrentCluster.clear();
|
|
}
|
|
finalizeCurrentCluster() {
|
|
assert(this.currentCluster);
|
|
const clusterSize = this.writer.getPos() - this.dataOffsets.get(this.currentCluster);
|
|
const endPos = this.writer.getPos();
|
|
this.writer.seek(this.offsets.get(this.currentCluster) + 4);
|
|
this.writeEBMLVarInt(clusterSize, CLUSTER_SIZE_BYTES);
|
|
this.writer.seek(endPos);
|
|
const clusterOffsetFromSegment = this.offsets.get(this.currentCluster) - this.segmentDataOffset;
|
|
assert(this.cues);
|
|
this.cues.data.push({ id: 187 /* CuePoint */, data: [
|
|
{ id: 179 /* CueTime */, data: this.currentClusterMsTimestamp },
|
|
// We only write out cues for tracks that have at least one chunk in this cluster
|
|
...[...this.trackDatasInCurrentCluster].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();
|
|
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();
|
|
if (!this.segment) {
|
|
this.createTracks();
|
|
this.createSegment();
|
|
}
|
|
for (const trackData of this.trackDatas) {
|
|
while (trackData.chunkQueue.length > 0) {
|
|
this.writeBlock(trackData, trackData.chunkQueue.shift());
|
|
}
|
|
}
|
|
if (!this.format._options.streamable && this.currentCluster) {
|
|
this.finalizeCurrentCluster();
|
|
}
|
|
assert(this.cues);
|
|
this.writeEBML(this.cues);
|
|
if (!this.format._options.streamable) {
|
|
const endPos = this.writer.getPos();
|
|
const segmentSize = this.writer.getPos() - this.segmentDataOffset;
|
|
this.writer.seek(this.offsets.get(this.segment) + 4);
|
|
this.writeEBMLVarInt(segmentSize, SEGMENT_SIZE_BYTES);
|
|
this.segmentDuration.data = new EBMLFloat64(this.duration);
|
|
this.writer.seek(this.offsets.get(this.segmentDuration));
|
|
this.writeEBML(this.segmentDuration);
|
|
this.seekHead.data[0].data[1].data = this.offsets.get(this.cues) - this.segmentDataOffset;
|
|
this.seekHead.data[1].data[1].data = this.offsets.get(this.segmentInfo) - this.segmentDataOffset;
|
|
this.seekHead.data[2].data[1].data = this.offsets.get(this.tracksElement) - this.segmentDataOffset;
|
|
this.writer.seek(this.offsets.get(this.seekHead));
|
|
this.writeEBML(this.seekHead);
|
|
this.writer.seek(endPos);
|
|
}
|
|
release();
|
|
}
|
|
};
|
|
|
|
// src/output-format.ts
|
|
var OutputFormat = class {
|
|
};
|
|
var Mp4OutputFormat = class extends OutputFormat {
|
|
/** @internal */
|
|
_options;
|
|
constructor(options = {}) {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.fastStart !== void 0 && ![false, "in-memory", "fragmented"].includes(options.fastStart)) {
|
|
throw new TypeError('options.fastStart, when provided, must be false, "in-memory", or "fragmented".');
|
|
}
|
|
super();
|
|
this._options = options;
|
|
}
|
|
/** @internal */
|
|
_createMuxer(output) {
|
|
return new IsobmffMuxer(output, this);
|
|
}
|
|
};
|
|
var MkvOutputFormat2 = class extends OutputFormat {
|
|
/** @internal */
|
|
_options;
|
|
constructor(options = {}) {
|
|
if (!options || typeof options !== "object") {
|
|
throw new TypeError("options must be an object.");
|
|
}
|
|
if (options.streamable !== void 0 && typeof options.streamable !== "boolean") {
|
|
throw new TypeError("options.streamable, when provided, must be a boolean.");
|
|
}
|
|
super();
|
|
this._options = options;
|
|
}
|
|
/** @internal */
|
|
_createMuxer(output) {
|
|
return new MatroskaMuxer(output, this);
|
|
}
|
|
};
|
|
var WebMOutputFormat = class extends MkvOutputFormat2 {
|
|
};
|
|
|
|
// src/media-source.ts
|
|
var MediaSource = class {
|
|
/** @internal */
|
|
_connectedTrack = null;
|
|
/** @internal */
|
|
_closed = false;
|
|
/** @internal */
|
|
_offsetTimestamps = false;
|
|
/** @internal */
|
|
_ensureValidDigest() {
|
|
if (!this._connectedTrack) {
|
|
throw new Error("Cannot call digest without connecting the source to an output track.");
|
|
}
|
|
if (!this._connectedTrack.output._started) {
|
|
throw new Error("Cannot call digest before output has been started.");
|
|
}
|
|
if (this._connectedTrack.output._finalizing) {
|
|
throw new Error("Cannot call digest after output has started finalizing.");
|
|
}
|
|
if (this._closed) {
|
|
throw new Error("Cannot call digest after source has been closed.");
|
|
}
|
|
}
|
|
/** @internal */
|
|
_start() {
|
|
}
|
|
/** @internal */
|
|
async _flush() {
|
|
}
|
|
close() {
|
|
if (this._closed) {
|
|
throw new Error("Source already closed.");
|
|
}
|
|
if (!this._connectedTrack) {
|
|
throw new Error("Cannot call close without connecting the source to an output track.");
|
|
}
|
|
if (!this._connectedTrack.output._started) {
|
|
throw new Error("Cannot call close before output has been started.");
|
|
}
|
|
this._closed = true;
|
|
if (this._connectedTrack.output._finalizing) {
|
|
return;
|
|
}
|
|
this._connectedTrack.output._muxer.onTrackClose(this._connectedTrack);
|
|
}
|
|
};
|
|
var VideoSource = class extends MediaSource {
|
|
/** @internal */
|
|
_connectedTrack = null;
|
|
/** @internal */
|
|
_codec;
|
|
constructor(codec) {
|
|
super();
|
|
if (!VIDEO_CODECS.includes(codec)) {
|
|
throw new TypeError(`Invalid video codec '${codec}'. Must be one of: ${VIDEO_CODECS.join(", ")}.`);
|
|
}
|
|
this._codec = codec;
|
|
}
|
|
};
|
|
var EncodedVideoChunkSource = class extends VideoSource {
|
|
constructor(codec) {
|
|
super(codec);
|
|
}
|
|
digest(chunk, meta) {
|
|
if (!(chunk instanceof EncodedVideoChunk)) {
|
|
throw new TypeError("chunk must be an EncodedVideoChunk.");
|
|
}
|
|
this._ensureValidDigest();
|
|
return this._connectedTrack.output._muxer.addEncodedVideoChunk(this._connectedTrack, chunk, meta);
|
|
}
|
|
};
|
|
var KEY_FRAME_INTERVAL = 5;
|
|
var validateVideoCodecConfig = (config) => {
|
|
if (!config || typeof config !== "object") {
|
|
throw new TypeError("Codec 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 (!Number.isInteger(config.bitrate) || config.bitrate <= 0) {
|
|
throw new TypeError("config.bitrate must be a positive integer.");
|
|
}
|
|
if (config.latencyMode !== void 0 && !["quality", "realtime"].includes(config.latencyMode)) {
|
|
throw new TypeError("config.latencyMode, when provided, must be 'quality' or 'realtime'.");
|
|
}
|
|
};
|
|
var VideoEncoderWrapper = class {
|
|
constructor(source, codecConfig) {
|
|
this.source = source;
|
|
this.codecConfig = codecConfig;
|
|
validateVideoCodecConfig(codecConfig);
|
|
}
|
|
encoder = null;
|
|
muxer = null;
|
|
lastMultipleOfKeyFrameInterval = -1;
|
|
lastWidth = null;
|
|
lastHeight = null;
|
|
async digest(videoFrame) {
|
|
this.source._ensureValidDigest();
|
|
if (this.lastWidth !== null && this.lastHeight !== null) {
|
|
if (videoFrame.codedWidth !== this.lastWidth || videoFrame.codedHeight !== this.lastHeight) {
|
|
throw new Error(
|
|
`Video frame size must remain constant. Expected ${this.lastWidth}x${this.lastHeight}, got ${videoFrame.codedWidth}x${videoFrame.codedHeight}.`
|
|
);
|
|
}
|
|
} else {
|
|
this.lastWidth = videoFrame.codedWidth;
|
|
this.lastHeight = videoFrame.codedHeight;
|
|
}
|
|
this.ensureEncoder(videoFrame);
|
|
assert(this.encoder);
|
|
const multipleOfKeyFrameInterval = Math.floor(videoFrame.timestamp / 1e6 / KEY_FRAME_INTERVAL);
|
|
this.encoder.encode(videoFrame, {
|
|
keyFrame: multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval
|
|
});
|
|
this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval;
|
|
if (this.encoder.encodeQueueSize >= 4) {
|
|
await new Promise((resolve) => this.encoder.addEventListener("dequeue", resolve, { once: true }));
|
|
}
|
|
await this.muxer.mutex.currentPromise;
|
|
}
|
|
ensureEncoder(videoFrame) {
|
|
if (this.encoder) {
|
|
return;
|
|
}
|
|
this.encoder = new VideoEncoder({
|
|
output: (chunk, meta) => void this.muxer.addEncodedVideoChunk(this.source._connectedTrack, chunk, meta),
|
|
error: (error) => console.error("Video encode error:", error)
|
|
});
|
|
this.encoder.configure({
|
|
codec: buildVideoCodecString(
|
|
this.codecConfig.codec,
|
|
videoFrame.codedWidth,
|
|
videoFrame.codedHeight,
|
|
this.codecConfig.bitrate
|
|
),
|
|
width: videoFrame.codedWidth,
|
|
height: videoFrame.codedHeight,
|
|
bitrate: this.codecConfig.bitrate,
|
|
framerate: this.source._connectedTrack?.metadata.frameRate,
|
|
latencyMode: this.codecConfig.latencyMode,
|
|
...getVideoEncoderConfigExtension(this.codecConfig.codec)
|
|
});
|
|
assert(this.source._connectedTrack);
|
|
this.muxer = this.source._connectedTrack.output._muxer;
|
|
}
|
|
async flush() {
|
|
if (this.encoder) {
|
|
await this.encoder.flush();
|
|
this.encoder.close();
|
|
}
|
|
}
|
|
};
|
|
var VideoFrameSource = class extends VideoSource {
|
|
/** @internal */
|
|
_encoder;
|
|
constructor(codecConfig) {
|
|
super(codecConfig.codec);
|
|
this._encoder = new VideoEncoderWrapper(this, codecConfig);
|
|
}
|
|
digest(videoFrame) {
|
|
if (!(videoFrame instanceof VideoFrame)) {
|
|
throw new TypeError("videoFrame must be a VideoFrame.");
|
|
}
|
|
return this._encoder.digest(videoFrame);
|
|
}
|
|
/** @internal */
|
|
_flush() {
|
|
return this._encoder.flush();
|
|
}
|
|
};
|
|
var CanvasSource = class extends VideoSource {
|
|
/** @internal */
|
|
_encoder;
|
|
/** @internal */
|
|
_canvas;
|
|
constructor(canvas, codecConfig) {
|
|
if (!(canvas instanceof HTMLCanvasElement)) {
|
|
throw new TypeError("canvas must be an HTMLCanvasElement.");
|
|
}
|
|
super(codecConfig.codec);
|
|
this._encoder = new VideoEncoderWrapper(this, codecConfig);
|
|
this._canvas = canvas;
|
|
}
|
|
digest(timestamp, duration = 0) {
|
|
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 frame = new VideoFrame(this._canvas, {
|
|
timestamp: Math.round(1e6 * timestamp),
|
|
duration: Math.round(1e6 * duration),
|
|
alpha: "discard"
|
|
});
|
|
const promise = this._encoder.digest(frame);
|
|
frame.close();
|
|
return promise;
|
|
}
|
|
/** @internal */
|
|
_flush() {
|
|
return this._encoder.flush();
|
|
}
|
|
};
|
|
var MediaStreamVideoTrackSource = class extends VideoSource {
|
|
/** @internal */
|
|
_encoder;
|
|
/** @internal */
|
|
_abortController = null;
|
|
/** @internal */
|
|
_track;
|
|
/** @internal */
|
|
_offsetTimestamps = true;
|
|
constructor(track, codecConfig) {
|
|
if (!(track instanceof MediaStreamTrack) || track.kind !== "video") {
|
|
throw new TypeError("track must be a video MediaStreamTrack.");
|
|
}
|
|
codecConfig = {
|
|
...codecConfig,
|
|
latencyMode: "realtime"
|
|
};
|
|
super(codecConfig.codec);
|
|
this._encoder = new VideoEncoderWrapper(this, codecConfig);
|
|
this._track = track;
|
|
}
|
|
/** @internal */
|
|
_start() {
|
|
this._abortController = new AbortController();
|
|
const processor = new MediaStreamTrackProcessor({ track: this._track });
|
|
const consumer = new WritableStream({
|
|
write: (videoFrame) => {
|
|
void this._encoder.digest(videoFrame);
|
|
videoFrame.close();
|
|
}
|
|
});
|
|
processor.readable.pipeTo(consumer, {
|
|
signal: this._abortController.signal
|
|
}).catch((err) => {
|
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
console.error("Pipe error:", err);
|
|
});
|
|
}
|
|
/** @internal */
|
|
async _flush() {
|
|
if (this._abortController) {
|
|
this._abortController.abort();
|
|
this._abortController = null;
|
|
}
|
|
await this._encoder.flush();
|
|
}
|
|
};
|
|
var AudioSource = class extends MediaSource {
|
|
/** @internal */
|
|
_connectedTrack = null;
|
|
/** @internal */
|
|
_codec;
|
|
constructor(codec) {
|
|
super();
|
|
if (!AUDIO_CODECS.includes(codec)) {
|
|
throw new TypeError(`Invalid audio codec '${codec}'. Must be one of: ${AUDIO_CODECS.join(", ")}.`);
|
|
}
|
|
this._codec = codec;
|
|
}
|
|
};
|
|
var EncodedAudioChunkSource = class extends AudioSource {
|
|
constructor(codec) {
|
|
super(codec);
|
|
}
|
|
digest(chunk, meta) {
|
|
if (!(chunk instanceof EncodedAudioChunk)) {
|
|
throw new TypeError("chunk must be an EncodedAudioChunk.");
|
|
}
|
|
this._ensureValidDigest();
|
|
return this._connectedTrack.output._muxer.addEncodedAudioChunk(this._connectedTrack, chunk, meta);
|
|
}
|
|
};
|
|
var validateAudioCodecConfig = (config) => {
|
|
if (!config || typeof config !== "object") {
|
|
throw new TypeError("Codec 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 (!Number.isInteger(config.bitrate) || config.bitrate <= 0) {
|
|
throw new TypeError("config.bitrate must be a positive integer.");
|
|
}
|
|
};
|
|
var AudioEncoderWrapper = class {
|
|
constructor(source, codecConfig) {
|
|
this.source = source;
|
|
this.codecConfig = codecConfig;
|
|
validateAudioCodecConfig(codecConfig);
|
|
}
|
|
encoder = null;
|
|
muxer = null;
|
|
lastNumberOfChannels = null;
|
|
lastSampleRate = null;
|
|
async digest(audioData) {
|
|
this.source._ensureValidDigest();
|
|
if (this.lastNumberOfChannels !== null && this.lastSampleRate !== null) {
|
|
if (audioData.numberOfChannels !== this.lastNumberOfChannels || audioData.sampleRate !== this.lastSampleRate) {
|
|
throw new Error(
|
|
`Audio parameters must remain constant. Expected ${this.lastNumberOfChannels} channels at ${this.lastSampleRate} Hz, got ${audioData.numberOfChannels} channels at ${audioData.sampleRate} Hz.`
|
|
);
|
|
}
|
|
} else {
|
|
this.lastNumberOfChannels = audioData.numberOfChannels;
|
|
this.lastSampleRate = audioData.sampleRate;
|
|
}
|
|
this.ensureEncoder(audioData);
|
|
assert(this.encoder);
|
|
this.encoder.encode(audioData);
|
|
if (this.encoder.encodeQueueSize >= 4) {
|
|
await new Promise((resolve) => this.encoder.addEventListener("dequeue", resolve, { once: true }));
|
|
}
|
|
await this.muxer.mutex.currentPromise;
|
|
}
|
|
ensureEncoder(audioData) {
|
|
if (this.encoder) {
|
|
return;
|
|
}
|
|
this.encoder = new AudioEncoder({
|
|
output: (chunk, meta) => void this.muxer.addEncodedAudioChunk(this.source._connectedTrack, chunk, meta),
|
|
error: (error) => console.error("Audio encode error:", error)
|
|
});
|
|
this.encoder.configure({
|
|
codec: buildAudioCodecString(this.codecConfig.codec, audioData.numberOfChannels, audioData.sampleRate),
|
|
numberOfChannels: audioData.numberOfChannels,
|
|
sampleRate: audioData.sampleRate,
|
|
bitrate: this.codecConfig.bitrate,
|
|
...getAudioEncoderConfigExtension(this.codecConfig.codec)
|
|
});
|
|
assert(this.source._connectedTrack);
|
|
this.muxer = this.source._connectedTrack.output._muxer;
|
|
}
|
|
async flush() {
|
|
if (this.encoder) {
|
|
await this.encoder.flush();
|
|
this.encoder.close();
|
|
}
|
|
}
|
|
};
|
|
var AudioDataSource = class extends AudioSource {
|
|
/** @internal */
|
|
_encoder;
|
|
constructor(codecConfig) {
|
|
super(codecConfig.codec);
|
|
this._encoder = new AudioEncoderWrapper(this, codecConfig);
|
|
}
|
|
digest(audioData) {
|
|
if (!(audioData instanceof AudioData)) {
|
|
throw new TypeError("audioData must be an AudioData.");
|
|
}
|
|
return this._encoder.digest(audioData);
|
|
}
|
|
/** @internal */
|
|
_flush() {
|
|
return this._encoder.flush();
|
|
}
|
|
};
|
|
var AudioBufferSource = class extends AudioSource {
|
|
/** @internal */
|
|
_encoder;
|
|
/** @internal */
|
|
_accumulatedFrameCount = 0;
|
|
constructor(codecConfig) {
|
|
super(codecConfig.codec);
|
|
this._encoder = new AudioEncoderWrapper(this, codecConfig);
|
|
}
|
|
digest(audioBuffer) {
|
|
if (!(audioBuffer instanceof AudioBuffer)) {
|
|
throw new TypeError("audioBuffer must be an AudioBuffer.");
|
|
}
|
|
const numberOfChannels = audioBuffer.numberOfChannels;
|
|
const sampleRate = audioBuffer.sampleRate;
|
|
const numberOfFrames = audioBuffer.length;
|
|
const data = new Float32Array(numberOfChannels * numberOfFrames);
|
|
for (let channel = 0; channel < numberOfChannels; channel++) {
|
|
const channelData = audioBuffer.getChannelData(channel);
|
|
data.set(channelData, channel * numberOfFrames);
|
|
}
|
|
const audioData = new AudioData({
|
|
format: "f32-planar",
|
|
sampleRate,
|
|
numberOfFrames,
|
|
numberOfChannels,
|
|
timestamp: Math.round(1e6 * this._accumulatedFrameCount / sampleRate),
|
|
data
|
|
});
|
|
const promise = this._encoder.digest(audioData);
|
|
audioData.close();
|
|
this._accumulatedFrameCount += numberOfFrames;
|
|
return promise;
|
|
}
|
|
/** @internal */
|
|
_flush() {
|
|
return this._encoder.flush();
|
|
}
|
|
};
|
|
var MediaStreamAudioTrackSource = class extends AudioSource {
|
|
/** @internal */
|
|
_encoder;
|
|
/** @internal */
|
|
_abortController = null;
|
|
/** @internal */
|
|
_track;
|
|
/** @internal */
|
|
_offsetTimestamps = true;
|
|
constructor(track, codecConfig) {
|
|
if (!(track instanceof MediaStreamTrack) || track.kind !== "audio") {
|
|
throw new TypeError("track must be an audio MediaStreamTrack.");
|
|
}
|
|
super(codecConfig.codec);
|
|
this._encoder = new AudioEncoderWrapper(this, codecConfig);
|
|
this._track = track;
|
|
}
|
|
/** @internal */
|
|
_start() {
|
|
this._abortController = new AbortController();
|
|
const processor = new MediaStreamTrackProcessor({ track: this._track });
|
|
const consumer = new WritableStream({
|
|
write: (audioData) => {
|
|
void this._encoder.digest(audioData);
|
|
audioData.close();
|
|
}
|
|
});
|
|
processor.readable.pipeTo(consumer, {
|
|
signal: this._abortController.signal
|
|
}).catch((err) => {
|
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
console.error("Pipe error:", err);
|
|
});
|
|
}
|
|
/** @internal */
|
|
async _flush() {
|
|
if (this._abortController) {
|
|
this._abortController.abort();
|
|
this._abortController = null;
|
|
}
|
|
await this._encoder.flush();
|
|
}
|
|
};
|
|
var SubtitleSource = class extends MediaSource {
|
|
/** @internal */
|
|
_connectedTrack = null;
|
|
/** @internal */
|
|
_codec;
|
|
constructor(codec) {
|
|
super();
|
|
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 {
|
|
/** @internal */
|
|
_parser;
|
|
constructor(codec) {
|
|
super(codec);
|
|
this._parser = new SubtitleParser({
|
|
codec,
|
|
output: (cue, metadata) => this._connectedTrack?.output._muxer.addSubtitleCue(this._connectedTrack, cue, metadata),
|
|
error: (error) => console.error("Subtitle parse error:", error)
|
|
});
|
|
}
|
|
digest(text) {
|
|
if (typeof text !== "string") {
|
|
throw new TypeError("text must be a string.");
|
|
}
|
|
this._ensureValidDigest();
|
|
this._parser.parse(text);
|
|
return this._connectedTrack.output._muxer.mutex.currentPromise;
|
|
}
|
|
};
|
|
|
|
// src/output.ts
|
|
var Output = class {
|
|
/** @internal */
|
|
_muxer;
|
|
/** @internal */
|
|
_writer;
|
|
/** @internal */
|
|
_tracks = [];
|
|
/** @internal */
|
|
_started = false;
|
|
/** @internal */
|
|
_finalizing = false;
|
|
/** @internal */
|
|
_mutex = new AsyncMutex();
|
|
constructor(options) {
|
|
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._writer = options.target._createWriter();
|
|
this._muxer = options.format._createMuxer(this);
|
|
}
|
|
addVideoTrack(source, metadata = {}) {
|
|
if (!(source instanceof VideoSource)) {
|
|
throw new TypeError("source must be a VideoSource.");
|
|
}
|
|
if (!metadata || typeof metadata !== "object") {
|
|
throw new TypeError("metadata must be an object.");
|
|
}
|
|
if (typeof metadata.rotation === "number" && ![0, 90, 180, 270].includes(metadata.rotation)) {
|
|
throw new TypeError(`Invalid video rotation: ${metadata.rotation}. Has to be 0, 90, 180 or 270.`);
|
|
} else if (Array.isArray(metadata.rotation) && (metadata.rotation.length !== 9 || metadata.rotation.some((value) => !Number.isFinite(value)))) {
|
|
throw new TypeError(`Invalid video transformation matrix: ${metadata.rotation.join()}`);
|
|
}
|
|
if (metadata.frameRate !== void 0 && (!Number.isInteger(metadata.frameRate) || metadata.frameRate <= 0)) {
|
|
throw new TypeError(
|
|
`Invalid video frame rate: ${metadata.frameRate}. Must be a positive integer.`
|
|
);
|
|
}
|
|
this._addTrack("video", source, metadata);
|
|
}
|
|
addAudioTrack(source, metadata = {}) {
|
|
if (!(source instanceof AudioSource)) {
|
|
throw new TypeError("source must be an AudioSource.");
|
|
}
|
|
if (!metadata || typeof metadata !== "object") {
|
|
throw new TypeError("metadata must be an object.");
|
|
}
|
|
this._addTrack("audio", source, metadata);
|
|
}
|
|
addSubtitleTrack(source, metadata = {}) {
|
|
if (!(source instanceof SubtitleSource)) {
|
|
throw new TypeError("source must be a SubtitleSource.");
|
|
}
|
|
if (!metadata || typeof metadata !== "object") {
|
|
throw new TypeError("metadata must be an object.");
|
|
}
|
|
this._addTrack("subtitle", source, metadata);
|
|
}
|
|
/** @internal */
|
|
_addTrack(type, source, metadata) {
|
|
if (this._started) {
|
|
throw new Error("Cannot add track after output has started.");
|
|
}
|
|
if (source._connectedTrack) {
|
|
throw new Error("Source is already used for a track.");
|
|
}
|
|
const track = {
|
|
id: this._tracks.length + 1,
|
|
output: this,
|
|
type,
|
|
source,
|
|
metadata
|
|
};
|
|
this._muxer.beforeTrackAdd(track);
|
|
this._tracks.push(track);
|
|
source._connectedTrack = track;
|
|
}
|
|
async start() {
|
|
if (this._started) {
|
|
throw new Error("Output already started.");
|
|
}
|
|
this._started = true;
|
|
this._writer.start();
|
|
const release = await this._mutex.acquire();
|
|
await this._muxer.start();
|
|
for (const track of this._tracks) {
|
|
track.source._start();
|
|
}
|
|
release();
|
|
}
|
|
async finalize() {
|
|
if (!this._started) {
|
|
throw new Error("Cannot finalize before starting.");
|
|
}
|
|
if (this._finalizing) {
|
|
throw new Error("Cannot call finalize twice.");
|
|
}
|
|
this._finalizing = true;
|
|
const release = await this._mutex.acquire();
|
|
const promises = this._tracks.map((x) => x.source._flush());
|
|
await Promise.all(promises);
|
|
await this._muxer.finalize();
|
|
await this._writer.flush();
|
|
await this._writer.finalize();
|
|
release();
|
|
}
|
|
};
|
|
|
|
// src/source.ts
|
|
var Source = class {
|
|
/** @internal */
|
|
_sizePromise = null;
|
|
/** @internal */
|
|
_getSize() {
|
|
return this._sizePromise ??= this._retrieveSize();
|
|
}
|
|
};
|
|
var ArrayBufferSource = class extends Source {
|
|
constructor(buffer) {
|
|
super();
|
|
this.buffer = buffer;
|
|
}
|
|
/** @internal */
|
|
async _read(start, end) {
|
|
return new Uint8Array(this.buffer, start, end - start);
|
|
}
|
|
/** @internal */
|
|
async _retrieveSize() {
|
|
return this.buffer.byteLength;
|
|
}
|
|
};
|
|
var BlobSource = class extends Source {
|
|
constructor(blob) {
|
|
super();
|
|
this.blob = blob;
|
|
}
|
|
/** @internal */
|
|
async _read(start, end) {
|
|
const slice = this.blob.slice(start, end);
|
|
const buffer = await slice.arrayBuffer();
|
|
return new Uint8Array(buffer);
|
|
}
|
|
/** @internal */
|
|
async _retrieveSize() {
|
|
return this.blob.size;
|
|
}
|
|
};
|
|
|
|
// src/demuxer.ts
|
|
var Demuxer = class {
|
|
input;
|
|
constructor(input) {
|
|
this.input = input;
|
|
}
|
|
};
|
|
|
|
// src/input-track.ts
|
|
var InputTrack = class {
|
|
/** @internal */
|
|
_backing;
|
|
/** @internal */
|
|
constructor(backing) {
|
|
this._backing = backing;
|
|
}
|
|
isVideoTrack() {
|
|
return this instanceof InputVideoTrack;
|
|
}
|
|
isAudioTrack() {
|
|
return this instanceof InputAudioTrack;
|
|
}
|
|
computeDuration() {
|
|
return this._backing.computeDuration();
|
|
}
|
|
};
|
|
var InputVideoTrack = class extends InputTrack {
|
|
/** @internal */
|
|
_backing;
|
|
/** @internal */
|
|
constructor(backing) {
|
|
super(backing);
|
|
this._backing = backing;
|
|
}
|
|
getCodec() {
|
|
return this._backing.getCodec();
|
|
}
|
|
getWidth() {
|
|
return this._backing.getWidth();
|
|
}
|
|
getHeight() {
|
|
return this._backing.getHeight();
|
|
}
|
|
getRotation() {
|
|
return this._backing.getRotation();
|
|
}
|
|
getDecoderConfig() {
|
|
return this._backing.getDecoderConfig();
|
|
}
|
|
async getCodecMimeType() {
|
|
const decoderConfig = await this.getDecoderConfig();
|
|
return decoderConfig.codec;
|
|
}
|
|
};
|
|
var InputAudioTrack = class extends InputTrack {
|
|
/** @internal */
|
|
_backing;
|
|
/** @internal */
|
|
constructor(backing) {
|
|
super(backing);
|
|
this._backing = backing;
|
|
}
|
|
getCodec() {
|
|
return this._backing.getCodec();
|
|
}
|
|
getNumberOfChannels() {
|
|
return this._backing.getNumberOfChannels();
|
|
}
|
|
getSampleRate() {
|
|
return this._backing.getSampleRate();
|
|
}
|
|
getDecoderConfig() {
|
|
return this._backing.getDecoderConfig();
|
|
}
|
|
async getCodecMimeType() {
|
|
const decoderConfig = await this.getDecoderConfig();
|
|
return decoderConfig.codec;
|
|
}
|
|
};
|
|
|
|
// src/reader.ts
|
|
var Reader = class {
|
|
constructor(source, maxStorableBytes = Infinity) {
|
|
this.source = source;
|
|
this.maxStorableBytes = maxStorableBytes;
|
|
}
|
|
loadedSegments = [];
|
|
loadingSegments = [];
|
|
sourceSizePromise = null;
|
|
nextAge = 0;
|
|
totalStoredBytes = 0;
|
|
async loadRange(start, end) {
|
|
end = Math.min(end, await this.source._getSize());
|
|
const matchingLoadingSegment = this.loadingSegments.find((x) => x.start <= start && x.end >= end);
|
|
if (matchingLoadingSegment) {
|
|
await matchingLoadingSegment.promise;
|
|
return;
|
|
}
|
|
const encasingSegmentExists = this.loadedSegments.some((x) => x.start <= start && x.end >= end);
|
|
if (encasingSegmentExists) {
|
|
return;
|
|
}
|
|
const bytesPromise = this.source._read(start, end);
|
|
const loadingSegment = { start, end, promise: bytesPromise };
|
|
this.loadingSegments.push(loadingSegment);
|
|
const bytes2 = await bytesPromise;
|
|
removeItem(this.loadingSegments, loadingSegment);
|
|
this.insertIntoLoadedSegments(start, bytes2);
|
|
}
|
|
insertIntoLoadedSegments(start, bytes2) {
|
|
const segment = {
|
|
start,
|
|
end: start + bytes2.byteLength,
|
|
bytes: bytes2,
|
|
view: new DataView(bytes2.buffer),
|
|
age: this.nextAge++
|
|
};
|
|
let index = binarySearchLessOrEqual(this.loadedSegments, start, (x) => x.start);
|
|
if (index === -1 || this.loadedSegments[index].start < segment.start) {
|
|
index++;
|
|
}
|
|
this.loadedSegments.splice(index, 0, segment);
|
|
this.totalStoredBytes += bytes2.byteLength;
|
|
for (let i = index + 1; i < this.loadedSegments.length; i++) {
|
|
const otherSegment = this.loadedSegments[i];
|
|
if (otherSegment.start >= segment.end) {
|
|
break;
|
|
}
|
|
if (segment.start <= otherSegment.start && otherSegment.end <= segment.end) {
|
|
this.loadedSegments.splice(i, 1);
|
|
i--;
|
|
}
|
|
}
|
|
while (this.totalStoredBytes > this.maxStorableBytes && this.loadedSegments.length > 1) {
|
|
let oldestSegment = null;
|
|
let oldestSegmentIndex = -1;
|
|
for (let i = 0; i < this.loadedSegments.length; i++) {
|
|
const candidate = this.loadedSegments[i];
|
|
if (!oldestSegment || candidate.age < oldestSegment.age) {
|
|
oldestSegment = candidate;
|
|
oldestSegmentIndex = i;
|
|
}
|
|
}
|
|
assert(oldestSegment);
|
|
this.totalStoredBytes -= oldestSegment.bytes.byteLength;
|
|
this.loadedSegments.splice(oldestSegmentIndex, 1);
|
|
}
|
|
}
|
|
getViewAndOffset(start, end) {
|
|
const startIndex = binarySearchLessOrEqual(this.loadedSegments, start, (x) => x.start);
|
|
let segment = null;
|
|
if (startIndex !== -1) {
|
|
for (let i = startIndex; i < this.loadedSegments.length; i++) {
|
|
const candidate = this.loadedSegments[i];
|
|
if (candidate.start > start) {
|
|
break;
|
|
}
|
|
if (end <= candidate.end) {
|
|
segment = candidate;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!segment) {
|
|
throw new Error(`No segment loaded for range [${start}, ${end}).`);
|
|
}
|
|
segment.age = this.nextAge++;
|
|
return {
|
|
view: segment.view,
|
|
offset: segment.bytes.byteOffset + start - segment.start
|
|
};
|
|
}
|
|
forgetRange(start, end) {
|
|
if (end <= start) {
|
|
return;
|
|
}
|
|
const startIndex = binarySearchLessOrEqual(this.loadedSegments, start, (x) => x.start);
|
|
if (startIndex === -1) {
|
|
return;
|
|
}
|
|
const segment = this.loadedSegments[startIndex];
|
|
if (segment.start !== start || segment.end !== end) {
|
|
return;
|
|
}
|
|
this.loadedSegments.splice(startIndex, 1);
|
|
this.totalStoredBytes -= segment.bytes.byteLength;
|
|
}
|
|
};
|
|
|
|
// src/isobmff/isobmff-reader.ts
|
|
var IsobmffReader = class {
|
|
constructor(reader) {
|
|
this.reader = reader;
|
|
}
|
|
pos = 0;
|
|
readRange(start, end) {
|
|
const { view: view2, offset } = this.reader.getViewAndOffset(start, end);
|
|
return new Uint8Array(view2.buffer, offset, end - start);
|
|
}
|
|
readU8() {
|
|
const { view: view2, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1);
|
|
this.pos++;
|
|
return view2.getUint8(offset);
|
|
}
|
|
readU16() {
|
|
const { view: view2, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 2);
|
|
this.pos += 2;
|
|
return view2.getUint16(offset, false);
|
|
}
|
|
readU24() {
|
|
const { view: view2, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 3);
|
|
this.pos += 3;
|
|
const high = view2.getUint16(offset, false);
|
|
const low = view2.getUint8(offset + 2);
|
|
return high * 256 + low;
|
|
}
|
|
readS32() {
|
|
const { view: view2, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
|
|
this.pos += 4;
|
|
return view2.getInt32(offset, false);
|
|
}
|
|
readU32() {
|
|
const { view: view2, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
|
|
this.pos += 4;
|
|
return view2.getUint32(offset, false);
|
|
}
|
|
readI32() {
|
|
const { view: view2, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
|
|
this.pos += 4;
|
|
return view2.getInt32(offset, false);
|
|
}
|
|
readU64() {
|
|
const high = this.readU32();
|
|
const low = this.readU32();
|
|
return high * 4294967296 + low;
|
|
}
|
|
readF64() {
|
|
const { view: view2, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 8);
|
|
this.pos += 8;
|
|
return view2.getFloat64(offset, false);
|
|
}
|
|
readFixed_16_16() {
|
|
return this.readS32() / 65536;
|
|
}
|
|
readFixed_2_30() {
|
|
return this.readS32() / 1073741824;
|
|
}
|
|
readAscii(length) {
|
|
const { view: view2, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
|
|
this.pos += length;
|
|
let str = "";
|
|
for (let i = 0; i < length; i++) {
|
|
str += String.fromCharCode(view2.getUint8(offset + i));
|
|
}
|
|
return str;
|
|
}
|
|
readIsomVariableInteger() {
|
|
let result = 0;
|
|
for (let i = 0; i < 4; i++) {
|
|
result <<= 7;
|
|
const nextByte = this.readU8();
|
|
result |= nextByte & 127;
|
|
if ((nextByte & 128) === 0) {
|
|
break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
readBoxHeader() {
|
|
let totalSize = this.readU32();
|
|
const name = this.readAscii(4);
|
|
let headerSize = 8;
|
|
const hasLargeSize = totalSize === 1;
|
|
if (hasLargeSize) {
|
|
totalSize = this.readU64();
|
|
headerSize = 16;
|
|
}
|
|
return { name, totalSize, headerSize, contentSize: totalSize - headerSize };
|
|
}
|
|
};
|
|
|
|
// src/isobmff/isobmff-demuxer.ts
|
|
var knownMatrixes = [rotationMatrix(0), rotationMatrix(90), rotationMatrix(180), rotationMatrix(270)];
|
|
var IsobmffDemuxer = class extends Demuxer {
|
|
isobmffReader;
|
|
currentTrack = null;
|
|
tracks = [];
|
|
metadataPromise = null;
|
|
movieTimescale = -1;
|
|
movieDurationInTimescale = -1;
|
|
isFragmented = false;
|
|
fragmentTrackDefaults = [];
|
|
fragments = [];
|
|
currentFragment = null;
|
|
fragmentLookupMutex = new AsyncMutex();
|
|
chunkReader;
|
|
constructor(input) {
|
|
super(input);
|
|
this.isobmffReader = new IsobmffReader(input._mainReader);
|
|
this.chunkReader = new IsobmffReader(new Reader(input._source, 64 * 2 ** 20));
|
|
}
|
|
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();
|
|
let string = "video/mp4";
|
|
if (this.tracks.length > 0) {
|
|
const codecMimeTypes = await Promise.all(this.tracks.map((x) => x.inputTrack.getCodecMimeType()));
|
|
const uniqueCodecMimeTypes = [...new Set(codecMimeTypes)];
|
|
string += `; codecs="${uniqueCodecMimeTypes.join(", ")}"`;
|
|
}
|
|
return string;
|
|
}
|
|
readMetadata() {
|
|
return this.metadataPromise ??= (async () => {
|
|
const sourceSize = await this.isobmffReader.reader.source._getSize();
|
|
while (this.isobmffReader.pos < sourceSize) {
|
|
await this.isobmffReader.reader.loadRange(this.isobmffReader.pos, this.isobmffReader.pos + 16);
|
|
const startPos = this.isobmffReader.pos;
|
|
const boxInfo = this.isobmffReader.readBoxHeader();
|
|
if (boxInfo.name === "moov") {
|
|
await this.isobmffReader.reader.loadRange(
|
|
this.isobmffReader.pos,
|
|
this.isobmffReader.pos + boxInfo.contentSize
|
|
);
|
|
this.readContiguousBoxes(boxInfo.contentSize);
|
|
break;
|
|
}
|
|
this.isobmffReader.pos = startPos + boxInfo.totalSize;
|
|
}
|
|
if (this.isFragmented) {
|
|
await this.isobmffReader.reader.loadRange(sourceSize - 4, sourceSize);
|
|
this.isobmffReader.pos = sourceSize - 4;
|
|
const lastWord = this.isobmffReader.readU32();
|
|
const potentialMfraPos = sourceSize - lastWord;
|
|
if (potentialMfraPos >= 0 && potentialMfraPos < sourceSize) {
|
|
await this.isobmffReader.reader.loadRange(potentialMfraPos, sourceSize);
|
|
this.isobmffReader.pos = potentialMfraPos;
|
|
const boxInfo = this.isobmffReader.readBoxHeader();
|
|
if (boxInfo.name === "mfra") {
|
|
this.readContiguousBoxes(boxInfo.contentSize);
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
getSampleTableForTrack(internalTrack) {
|
|
if (internalTrack.sampleTable) {
|
|
return internalTrack.sampleTable;
|
|
}
|
|
const sampleTable = {
|
|
sampleTimingEntries: [],
|
|
sampleCompositionTimeOffsets: [],
|
|
sampleSizes: [],
|
|
keySampleIndices: null,
|
|
chunkOffsets: [],
|
|
sampleToChunk: [],
|
|
presentationTimestamps: []
|
|
};
|
|
internalTrack.sampleTable = sampleTable;
|
|
this.isobmffReader.pos = internalTrack.sampleTableOffset;
|
|
this.currentTrack = internalTrack;
|
|
this.traverseBox();
|
|
this.currentTrack = null;
|
|
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);
|
|
return internalTrack.sampleTable;
|
|
}
|
|
async readFragment() {
|
|
const startPos = this.isobmffReader.pos;
|
|
await this.isobmffReader.reader.loadRange(this.isobmffReader.pos, this.isobmffReader.pos + 16);
|
|
const moofBoxInfo = this.isobmffReader.readBoxHeader();
|
|
assert(moofBoxInfo.name === "moof");
|
|
await this.isobmffReader.reader.loadRange(startPos, startPos + moofBoxInfo.totalSize);
|
|
this.isobmffReader.pos = startPos;
|
|
this.traverseBox();
|
|
const index = binarySearchExact(this.fragments, startPos, (x) => x.moofOffset);
|
|
assert(index !== -1);
|
|
const fragment = this.fragments[index];
|
|
assert(fragment.moofOffset === startPos);
|
|
this.isobmffReader.reader.forgetRange(startPos, startPos + moofBoxInfo.totalSize);
|
|
for (const [trackId, trackData] of fragment.trackData) {
|
|
if (trackData.startTimestampIsFinal) {
|
|
continue;
|
|
}
|
|
const internalTrack = this.tracks.find((x) => x.id === trackId);
|
|
this.isobmffReader.pos = 0;
|
|
let currentFragment = null;
|
|
let lastFragment = null;
|
|
const index2 = binarySearchLessOrEqual(
|
|
internalTrack.fragments,
|
|
startPos - 1,
|
|
(x) => x.moofOffset
|
|
);
|
|
if (index2 !== -1) {
|
|
currentFragment = internalTrack.fragments[index2];
|
|
lastFragment = currentFragment;
|
|
this.isobmffReader.pos = currentFragment.moofOffset + currentFragment.moofSize;
|
|
}
|
|
while (this.isobmffReader.pos < startPos) {
|
|
if (currentFragment?.nextFragment) {
|
|
currentFragment = currentFragment.nextFragment;
|
|
this.isobmffReader.pos = currentFragment.moofOffset + currentFragment.moofSize;
|
|
} else {
|
|
await this.isobmffReader.reader.loadRange(this.isobmffReader.pos, this.isobmffReader.pos + 16);
|
|
const startPos2 = this.isobmffReader.pos;
|
|
const boxInfo = this.isobmffReader.readBoxHeader();
|
|
if (boxInfo.name === "moof") {
|
|
const index3 = binarySearchExact(this.fragments, startPos2, (x) => x.moofOffset);
|
|
if (index3 === -1) {
|
|
this.isobmffReader.pos = startPos2;
|
|
const fragment2 = await this.readFragment();
|
|
if (currentFragment) currentFragment.nextFragment = fragment2;
|
|
currentFragment = fragment2;
|
|
} else {
|
|
const fragment2 = this.fragments[index3];
|
|
if (currentFragment) currentFragment.nextFragment = fragment2;
|
|
currentFragment = fragment2;
|
|
}
|
|
}
|
|
this.isobmffReader.pos = startPos2 + boxInfo.totalSize;
|
|
}
|
|
if (currentFragment && currentFragment.trackData.has(trackId)) {
|
|
lastFragment = currentFragment;
|
|
}
|
|
}
|
|
if (lastFragment) {
|
|
const otherTrackData = lastFragment.trackData.get(trackId);
|
|
assert(otherTrackData.startTimestampIsFinal);
|
|
offsetFragmentTrackDataByTimestamp(trackData, otherTrackData.endTimestamp);
|
|
}
|
|
trackData.startTimestampIsFinal = true;
|
|
}
|
|
return fragment;
|
|
}
|
|
readContiguousBoxes(totalSize) {
|
|
const startIndex = this.isobmffReader.pos;
|
|
while (this.isobmffReader.pos - startIndex < totalSize) {
|
|
this.traverseBox();
|
|
}
|
|
}
|
|
traverseBox() {
|
|
const startPos = this.isobmffReader.pos;
|
|
const boxInfo = this.isobmffReader.readBoxHeader();
|
|
const boxEndPos = startPos + boxInfo.totalSize;
|
|
switch (boxInfo.name) {
|
|
case "mdia":
|
|
case "minf":
|
|
case "dinf":
|
|
case "mfra":
|
|
{
|
|
this.readContiguousBoxes(boxInfo.contentSize);
|
|
}
|
|
;
|
|
break;
|
|
case "mvhd":
|
|
{
|
|
const version = this.isobmffReader.readU8();
|
|
this.isobmffReader.pos += 3;
|
|
if (version === 1) {
|
|
this.isobmffReader.pos += 8 + 8;
|
|
this.movieTimescale = this.isobmffReader.readU32();
|
|
this.movieDurationInTimescale = this.isobmffReader.readU64();
|
|
} else {
|
|
this.isobmffReader.pos += 4 + 4;
|
|
this.movieTimescale = this.isobmffReader.readU32();
|
|
this.movieDurationInTimescale = this.isobmffReader.readU32();
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "trak":
|
|
{
|
|
const track = {
|
|
id: -1,
|
|
demuxer: this,
|
|
inputTrack: null,
|
|
info: null,
|
|
timescale: -1,
|
|
durationInTimescale: -1,
|
|
rotation: 0,
|
|
sampleTableOffset: -1,
|
|
sampleTable: null,
|
|
fragmentLookupTable: null,
|
|
currentFragmentState: null,
|
|
fragments: []
|
|
};
|
|
this.currentTrack = track;
|
|
this.readContiguousBoxes(boxInfo.contentSize);
|
|
if (track.id !== -1 && track.timescale !== -1 && track.info !== null) {
|
|
if (track.info.type === "video" && track.info.codec !== null) {
|
|
const videoTrack = track;
|
|
track.inputTrack = new InputVideoTrack(new IsobmffVideoTrackBacking(videoTrack));
|
|
this.tracks.push(track);
|
|
} else if (track.info.type === "audio" && track.info.codec !== null) {
|
|
const audioTrack = track;
|
|
track.inputTrack = new InputAudioTrack(new IsobmffAudioTrackBacking(audioTrack));
|
|
this.tracks.push(track);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this.currentTrack = null;
|
|
}
|
|
;
|
|
break;
|
|
case "tkhd":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track);
|
|
const version = this.isobmffReader.readU8();
|
|
const flags = this.isobmffReader.readU24();
|
|
const trackEnabled = (flags & 1) !== 0;
|
|
if (!trackEnabled) {
|
|
break;
|
|
}
|
|
if (version === 0) {
|
|
this.isobmffReader.pos += 8;
|
|
track.id = this.isobmffReader.readU32();
|
|
this.isobmffReader.pos += 8;
|
|
} else if (version === 1) {
|
|
this.isobmffReader.pos += 16;
|
|
track.id = this.isobmffReader.readU32();
|
|
this.isobmffReader.pos += 12;
|
|
} else {
|
|
throw new Error(`Incorrect track header version ${version}.`);
|
|
}
|
|
this.isobmffReader.pos += 2 * 4 + 2 + 2 + 2 + 2;
|
|
const rotationMatrix2 = [];
|
|
rotationMatrix2.push(this.isobmffReader.readFixed_16_16(), this.isobmffReader.readFixed_16_16());
|
|
this.isobmffReader.pos += 4;
|
|
rotationMatrix2.push(this.isobmffReader.readFixed_16_16(), this.isobmffReader.readFixed_16_16());
|
|
const matrixIndex = knownMatrixes.findIndex((x) => x.every((y, i) => y === rotationMatrix2[i]));
|
|
if (matrixIndex === -1) {
|
|
track.rotation = 0;
|
|
} else {
|
|
track.rotation = 90 * matrixIndex;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "mdhd":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track);
|
|
const version = this.isobmffReader.readU8();
|
|
this.isobmffReader.pos += 3;
|
|
if (version === 0) {
|
|
this.isobmffReader.pos += 8;
|
|
track.timescale = this.isobmffReader.readU32();
|
|
track.durationInTimescale = this.isobmffReader.readU32();
|
|
} else if (version === 1) {
|
|
this.isobmffReader.pos += 16;
|
|
track.timescale = this.isobmffReader.readU32();
|
|
track.durationInTimescale = this.isobmffReader.readU64();
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "hdlr":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track);
|
|
this.isobmffReader.pos += 8;
|
|
const handlerType = this.isobmffReader.readAscii(4);
|
|
if (handlerType === "vide") {
|
|
track.info = {
|
|
type: "video",
|
|
width: -1,
|
|
height: -1,
|
|
codec: null,
|
|
codecDescription: null,
|
|
colorSpace: null
|
|
};
|
|
} else if (handlerType === "soun") {
|
|
track.info = {
|
|
type: "audio",
|
|
numberOfChannels: -1,
|
|
sampleRate: -1,
|
|
codec: null,
|
|
codecDescription: null
|
|
};
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "stbl":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track);
|
|
track.sampleTableOffset = startPos;
|
|
this.readContiguousBoxes(boxInfo.contentSize);
|
|
}
|
|
;
|
|
break;
|
|
case "stsd":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track);
|
|
if (track.info === null || track.sampleTable) {
|
|
break;
|
|
}
|
|
const stsdVersion = this.isobmffReader.readU8();
|
|
this.isobmffReader.pos += 3;
|
|
const entries = this.isobmffReader.readU32();
|
|
for (let i = 0; i < entries; i++) {
|
|
const sampleBoxInfo = this.isobmffReader.readBoxHeader();
|
|
if (track.info.type === "video") {
|
|
if (sampleBoxInfo.name === "avc1") {
|
|
track.info.codec = "avc";
|
|
} else if (sampleBoxInfo.name === "hvc1" || sampleBoxInfo.name === "hev1") {
|
|
track.info.codec = "hevc";
|
|
} else {
|
|
console.warn(`Unsupported video sample entry type ${sampleBoxInfo.name}.`);
|
|
break;
|
|
}
|
|
this.isobmffReader.pos += 6 * 1 + 2 + 2 + 2 + 3 * 4;
|
|
track.info.width = this.isobmffReader.readU16();
|
|
track.info.height = this.isobmffReader.readU16();
|
|
this.isobmffReader.pos += 4 + 4 + 4 + 2 + 32 + 2 + 2;
|
|
this.readContiguousBoxes(startPos + sampleBoxInfo.totalSize - this.isobmffReader.pos);
|
|
} else {
|
|
if (sampleBoxInfo.name === "mp4a") {
|
|
track.info.codec = "aac";
|
|
} else if (sampleBoxInfo.name.toLowerCase() === "opus") {
|
|
track.info.codec = "opus";
|
|
} else {
|
|
console.warn(`Unsupported audio sample entry type ${sampleBoxInfo.name}.`);
|
|
break;
|
|
}
|
|
this.isobmffReader.pos += 6 * 1 + 2;
|
|
const version = this.isobmffReader.readU16();
|
|
this.isobmffReader.pos += 3 * 2;
|
|
let channelCount = this.isobmffReader.readU16();
|
|
this.isobmffReader.pos += 2 + 2 + 2;
|
|
let sampleRate = this.isobmffReader.readU32() / 65536;
|
|
if (stsdVersion === 0 && version > 0) {
|
|
if (version === 1) {
|
|
this.isobmffReader.pos += 4 * 4;
|
|
} else if (version === 2) {
|
|
this.isobmffReader.pos += 4;
|
|
sampleRate = this.isobmffReader.readF64();
|
|
channelCount = this.isobmffReader.readU32();
|
|
this.isobmffReader.pos += 4;
|
|
const sampleSize = this.isobmffReader.readU32();
|
|
const flags = this.isobmffReader.readU32();
|
|
const bytesPerFrame = this.isobmffReader.readU32();
|
|
const samplesPerFrame = this.isobmffReader.readU32();
|
|
}
|
|
}
|
|
track.info.numberOfChannels = channelCount;
|
|
track.info.sampleRate = sampleRate;
|
|
this.readContiguousBoxes(startPos + sampleBoxInfo.totalSize - this.isobmffReader.pos);
|
|
}
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "avcC":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track && track.info);
|
|
track.info.codecDescription = this.isobmffReader.readRange(
|
|
this.isobmffReader.pos,
|
|
this.isobmffReader.pos + boxInfo.contentSize
|
|
);
|
|
}
|
|
;
|
|
break;
|
|
case "hvcC":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track && track.info);
|
|
track.info.codecDescription = this.isobmffReader.readRange(
|
|
this.isobmffReader.pos,
|
|
this.isobmffReader.pos + boxInfo.contentSize
|
|
);
|
|
}
|
|
;
|
|
break;
|
|
case "colr":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track && track.info?.type === "video");
|
|
const colourType = this.isobmffReader.readAscii(4);
|
|
if (colourType !== "nclx") {
|
|
break;
|
|
}
|
|
const colourPrimaries = this.isobmffReader.readU16();
|
|
const transferCharacteristics = this.isobmffReader.readU16();
|
|
const matrixCoefficients = this.isobmffReader.readU16();
|
|
const fullRangeFlag = Boolean(this.isobmffReader.readU8() & 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":
|
|
{
|
|
if (boxInfo.totalSize > 8) {
|
|
this.readContiguousBoxes(boxInfo.contentSize);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "esds":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track && track.info);
|
|
this.isobmffReader.pos += 4;
|
|
const tag = this.isobmffReader.readU8();
|
|
assert(tag === 3);
|
|
this.isobmffReader.readIsomVariableInteger();
|
|
this.isobmffReader.pos += 2;
|
|
const mixed = this.isobmffReader.readU8();
|
|
const streamDependenceFlag = (mixed & 128) !== 0;
|
|
const urlFlag = (mixed & 64) !== 0;
|
|
const ocrStreamFlag = (mixed & 32) !== 0;
|
|
if (streamDependenceFlag) {
|
|
this.isobmffReader.pos += 2;
|
|
}
|
|
if (urlFlag) {
|
|
const urlLength = this.isobmffReader.readU8();
|
|
this.isobmffReader.pos += urlLength;
|
|
}
|
|
if (ocrStreamFlag) {
|
|
this.isobmffReader.pos += 2;
|
|
}
|
|
const decoderConfigTag = this.isobmffReader.readU8();
|
|
assert(decoderConfigTag === 4);
|
|
this.isobmffReader.readIsomVariableInteger();
|
|
const objectTypeIndication = this.isobmffReader.readU8();
|
|
assert(objectTypeIndication === 64);
|
|
this.isobmffReader.pos += 1 + 3 + 4 + 4;
|
|
const decoderSpecificInfoTag = this.isobmffReader.readU8();
|
|
assert(decoderSpecificInfoTag === 5);
|
|
const decoderSpecificInfoLength = this.isobmffReader.readIsomVariableInteger();
|
|
track.info.codecDescription = this.isobmffReader.readRange(
|
|
this.isobmffReader.pos,
|
|
this.isobmffReader.pos + decoderSpecificInfoLength
|
|
);
|
|
}
|
|
;
|
|
break;
|
|
case "stts":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track);
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
this.isobmffReader.pos += 4;
|
|
const entryCount = this.isobmffReader.readU32();
|
|
let currentIndex = 0;
|
|
let currentTimestamp = 0;
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const sampleCount = this.isobmffReader.readU32();
|
|
const sampleDelta = this.isobmffReader.readU32();
|
|
track.sampleTable.sampleTimingEntries.push({
|
|
startIndex: currentIndex,
|
|
startDecodeTimestamp: currentTimestamp,
|
|
count: sampleCount,
|
|
delta: sampleDelta
|
|
});
|
|
currentIndex += sampleCount;
|
|
currentTimestamp += sampleCount * sampleDelta;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "ctts":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track);
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
this.isobmffReader.pos += 1 + 3;
|
|
const entryCount = this.isobmffReader.readU32();
|
|
let sampleIndex = 0;
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const sampleCount = this.isobmffReader.readU32();
|
|
const sampleOffset = this.isobmffReader.readI32();
|
|
track.sampleTable.sampleCompositionTimeOffsets.push({
|
|
startIndex: sampleIndex,
|
|
count: sampleCount,
|
|
offset: sampleOffset
|
|
});
|
|
sampleIndex += sampleCount;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "stsz":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track);
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
this.isobmffReader.pos += 4;
|
|
const sampleSize = this.isobmffReader.readU32();
|
|
const sampleCount = this.isobmffReader.readU32();
|
|
if (sampleSize === 0) {
|
|
for (let i = 0; i < sampleCount; i++) {
|
|
const sampleSize2 = this.isobmffReader.readU32();
|
|
track.sampleTable.sampleSizes.push(sampleSize2);
|
|
}
|
|
} else {
|
|
track.sampleTable.sampleSizes.push(sampleSize);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "stz2":
|
|
{
|
|
throw new Error("Unsupported.");
|
|
}
|
|
;
|
|
case "stss":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track);
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
this.isobmffReader.pos += 4;
|
|
track.sampleTable.keySampleIndices = [];
|
|
const entryCount = this.isobmffReader.readU32();
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const sampleIndex = this.isobmffReader.readU32() - 1;
|
|
track.sampleTable.keySampleIndices.push(sampleIndex);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "stsc":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track);
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
this.isobmffReader.pos += 4;
|
|
const entryCount = this.isobmffReader.readU32();
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const startChunkIndex = this.isobmffReader.readU32() - 1;
|
|
const samplesPerChunk = this.isobmffReader.readU32();
|
|
const sampleDescriptionIndex = this.isobmffReader.readU32();
|
|
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;
|
|
assert(track);
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
this.isobmffReader.pos += 4;
|
|
const entryCount = this.isobmffReader.readU32();
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const chunkOffset = this.isobmffReader.readU32();
|
|
track.sampleTable.chunkOffsets.push(chunkOffset);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "co64":
|
|
{
|
|
const track = this.currentTrack;
|
|
assert(track);
|
|
if (!track.sampleTable) {
|
|
break;
|
|
}
|
|
this.isobmffReader.pos += 4;
|
|
const entryCount = this.isobmffReader.readU32();
|
|
for (let i = 0; i < entryCount; i++) {
|
|
const chunkOffset = this.isobmffReader.readU64();
|
|
track.sampleTable.chunkOffsets.push(chunkOffset);
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "mvex":
|
|
{
|
|
this.isFragmented = true;
|
|
this.readContiguousBoxes(boxInfo.contentSize);
|
|
}
|
|
;
|
|
break;
|
|
case "mehd":
|
|
{
|
|
const version = this.isobmffReader.readU8();
|
|
this.isobmffReader.pos += 3;
|
|
const fragmentDuration = version === 1 ? this.isobmffReader.readU64() : this.isobmffReader.readU32();
|
|
this.movieDurationInTimescale = fragmentDuration;
|
|
}
|
|
;
|
|
break;
|
|
case "trex":
|
|
{
|
|
this.isobmffReader.pos += 4;
|
|
const trackId = this.isobmffReader.readU32();
|
|
const defaultSampleDescriptionIndex = this.isobmffReader.readU32();
|
|
const defaultSampleDuration = this.isobmffReader.readU32();
|
|
const defaultSampleSize = this.isobmffReader.readU32();
|
|
const defaultSampleFlags = this.isobmffReader.readU32();
|
|
this.fragmentTrackDefaults.push({
|
|
trackId,
|
|
defaultSampleDescriptionIndex,
|
|
defaultSampleDuration,
|
|
defaultSampleSize,
|
|
defaultSampleFlags
|
|
});
|
|
}
|
|
;
|
|
break;
|
|
case "tfra":
|
|
{
|
|
const version = this.isobmffReader.readU8();
|
|
this.isobmffReader.pos += 3;
|
|
const trackId = this.isobmffReader.readU32();
|
|
const track = this.tracks.find((x2) => x2.id === trackId);
|
|
if (!track) {
|
|
break;
|
|
}
|
|
track.fragmentLookupTable = [];
|
|
const word = this.isobmffReader.readU32();
|
|
const lengthSizeOfTrafNum = (word & 48) >> 4;
|
|
const lengthSizeOfTrunNum = (word & 12) >> 2;
|
|
const lengthSizeOfSampleNum = word & 3;
|
|
const x = this.isobmffReader;
|
|
const functions = [x.readU8.bind(x), x.readU16.bind(x), x.readU24.bind(x), x.readU32.bind(x)];
|
|
const readTrafNum = functions[lengthSizeOfTrafNum];
|
|
const readTrunNum = functions[lengthSizeOfTrunNum];
|
|
const readSampleNum = functions[lengthSizeOfSampleNum];
|
|
const numberOfEntries = this.isobmffReader.readU32();
|
|
for (let i = 0; i < numberOfEntries; i++) {
|
|
const time = version === 1 ? this.isobmffReader.readU64() : this.isobmffReader.readU32();
|
|
const moofOffset = version === 1 ? this.isobmffReader.readU64() : this.isobmffReader.readU32();
|
|
const trafNumber = readTrafNum();
|
|
const trunNumber = readTrunNum();
|
|
const sampleNumber = readSampleNum();
|
|
track.fragmentLookupTable.push({
|
|
timestamp: time,
|
|
moofOffset
|
|
});
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "moof":
|
|
{
|
|
this.currentFragment = {
|
|
moofOffset: startPos,
|
|
moofSize: boxInfo.totalSize,
|
|
implicitBaseDataOffset: startPos,
|
|
trackData: /* @__PURE__ */ new Map(),
|
|
dataStart: Infinity,
|
|
dataEnd: 0,
|
|
nextFragment: null
|
|
};
|
|
this.readContiguousBoxes(boxInfo.contentSize);
|
|
const insertionIndex = binarySearchLessOrEqual(
|
|
this.fragments,
|
|
this.currentFragment.moofOffset,
|
|
(x) => x.moofOffset
|
|
);
|
|
this.fragments.splice(insertionIndex + 1, 0, this.currentFragment);
|
|
for (const [, trackData] of this.currentFragment.trackData) {
|
|
const firstSample = trackData.samples[0];
|
|
const lastSample = last(trackData.samples);
|
|
this.currentFragment.dataStart = Math.min(
|
|
this.currentFragment.dataStart,
|
|
firstSample.byteOffset
|
|
);
|
|
this.currentFragment.dataEnd = Math.max(
|
|
this.currentFragment.dataEnd,
|
|
lastSample.byteOffset + lastSample.byteSize
|
|
);
|
|
}
|
|
this.currentFragment = null;
|
|
}
|
|
;
|
|
break;
|
|
case "traf":
|
|
{
|
|
assert(this.currentFragment);
|
|
this.readContiguousBoxes(boxInfo.contentSize);
|
|
if (this.currentTrack) {
|
|
const trackData = this.currentFragment.trackData.get(this.currentTrack.id);
|
|
if (trackData) {
|
|
const insertionIndex = binarySearchLessOrEqual(
|
|
this.currentTrack.fragments,
|
|
this.currentFragment.moofOffset,
|
|
(x) => x.moofOffset
|
|
);
|
|
this.currentTrack.fragments.splice(insertionIndex + 1, 0, this.currentFragment);
|
|
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);
|
|
this.isobmffReader.pos += 1;
|
|
const flags = this.isobmffReader.readU24();
|
|
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 = this.isobmffReader.readU32();
|
|
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 = this.isobmffReader.readU64();
|
|
} else if (defaultBaseIsMoof) {
|
|
track.currentFragmentState.baseDataOffset = this.currentFragment.moofOffset;
|
|
}
|
|
if (sampleDescriptionIndexPresent) {
|
|
track.currentFragmentState.sampleDescriptionIndex = this.isobmffReader.readU32();
|
|
}
|
|
if (defaultSampleDurationPresent) {
|
|
track.currentFragmentState.defaultSampleDuration = this.isobmffReader.readU32();
|
|
}
|
|
if (defaultSampleSizePresent) {
|
|
track.currentFragmentState.defaultSampleSize = this.isobmffReader.readU32();
|
|
}
|
|
if (defaultSampleFlagsPresent) {
|
|
track.currentFragmentState.defaultSampleFlags = this.isobmffReader.readU32();
|
|
}
|
|
if (durationIsEmpty) {
|
|
track.currentFragmentState.defaultSampleDuration = 0;
|
|
}
|
|
}
|
|
;
|
|
break;
|
|
case "tfdt":
|
|
{
|
|
const track = this.currentTrack;
|
|
if (!track) {
|
|
break;
|
|
}
|
|
assert(track.currentFragmentState);
|
|
const version = this.isobmffReader.readU8();
|
|
this.isobmffReader.pos += 3;
|
|
const baseMediaDecodeTime = version === 0 ? this.isobmffReader.readU32() : this.isobmffReader.readU64();
|
|
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)) {
|
|
throw new Error("Can't have two trun boxes for the same track in one fragment.");
|
|
}
|
|
const version = this.isobmffReader.readU8();
|
|
const flags = this.isobmffReader.readU24();
|
|
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 = this.isobmffReader.readU32();
|
|
let dataOffset = track.currentFragmentState.baseDataOffset;
|
|
if (dataOffsetPresent) {
|
|
dataOffset += this.isobmffReader.readI32();
|
|
}
|
|
let firstSampleFlags = null;
|
|
if (firstSampleFlagsPresent) {
|
|
firstSampleFlags = this.isobmffReader.readU32();
|
|
}
|
|
let currentOffset = dataOffset;
|
|
if (sampleCount === 0) {
|
|
this.currentFragment.implicitBaseDataOffset = currentOffset;
|
|
break;
|
|
}
|
|
let currentTimestamp = 0;
|
|
const trackData = {
|
|
startTimestamp: 0,
|
|
endTimestamp: 0,
|
|
samples: [],
|
|
presentationTimestamps: [],
|
|
startTimestampIsFinal: false
|
|
};
|
|
this.currentFragment.trackData.set(track.id, trackData);
|
|
for (let i = 0; i < sampleCount; i++) {
|
|
let sampleDuration;
|
|
if (sampleDurationPresent) {
|
|
sampleDuration = this.isobmffReader.readU32();
|
|
} else {
|
|
assert(track.currentFragmentState.defaultSampleDuration !== null);
|
|
sampleDuration = track.currentFragmentState.defaultSampleDuration;
|
|
}
|
|
let sampleSize;
|
|
if (sampleSizePresent) {
|
|
sampleSize = this.isobmffReader.readU32();
|
|
} else {
|
|
assert(track.currentFragmentState.defaultSampleSize !== null);
|
|
sampleSize = track.currentFragmentState.defaultSampleSize;
|
|
}
|
|
let sampleFlags;
|
|
if (sampleFlagsPresent) {
|
|
sampleFlags = this.isobmffReader.readU32();
|
|
} 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 = this.isobmffReader.readU32();
|
|
} else {
|
|
sampleCompositionTimeOffset = this.isobmffReader.readI32();
|
|
}
|
|
}
|
|
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);
|
|
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;
|
|
}
|
|
this.isobmffReader.pos = boxEndPos;
|
|
}
|
|
};
|
|
var IsobmffTrackBacking = class {
|
|
constructor(internalTrack) {
|
|
this.internalTrack = internalTrack;
|
|
}
|
|
chunkToSampleIndex = /* @__PURE__ */ new WeakMap();
|
|
sampleIndexToChunk = /* @__PURE__ */ new Map();
|
|
chunkToFragmentLocation = /* @__PURE__ */ new WeakMap();
|
|
fragmentLocationToChunk = /* @__PURE__ */ new Map();
|
|
getCodec() {
|
|
throw new Error("Not implemented on base class.");
|
|
}
|
|
async computeDuration() {
|
|
const lastChunk = await this.getChunk(Infinity, { metadataOnly: true });
|
|
const timestamp = lastChunk?.timestamp;
|
|
return timestamp ? timestamp / 1e6 : 0;
|
|
}
|
|
async getFirstChunk(options) {
|
|
if (this.internalTrack.demuxer.isFragmented) {
|
|
return this.performFragmentedLookup(
|
|
() => {
|
|
const fragment = this.internalTrack.fragments[0];
|
|
return {
|
|
fragmentIndex: fragment ? 0 : -1,
|
|
sampleIndex: fragment ? 0 : -1,
|
|
correctSampleFound: !!fragment
|
|
};
|
|
},
|
|
0,
|
|
Infinity,
|
|
options
|
|
);
|
|
}
|
|
return this.fetchChunkForSampleIndex(0, options);
|
|
}
|
|
async getChunk(timestamp, options) {
|
|
const timestampInTimescale = timestamp * this.internalTrack.timescale;
|
|
if (this.internalTrack.demuxer.isFragmented) {
|
|
return this.performFragmentedLookup(
|
|
() => this.findSampleInFragmentsForTimestamp(timestampInTimescale),
|
|
timestampInTimescale,
|
|
timestampInTimescale,
|
|
options
|
|
);
|
|
} else {
|
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
|
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
|
return this.fetchChunkForSampleIndex(sampleIndex, options);
|
|
}
|
|
}
|
|
async getNextChunk(chunk, options) {
|
|
if (this.internalTrack.demuxer.isFragmented) {
|
|
const locationInFragment = this.chunkToFragmentLocation.get(chunk);
|
|
if (locationInFragment === void 0) {
|
|
throw new Error("Chunk was not created from this track.");
|
|
}
|
|
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id);
|
|
const sample = trackData.samples[locationInFragment.sampleIndex];
|
|
const fragmentIndex = binarySearchExact(
|
|
this.internalTrack.fragments,
|
|
locationInFragment.fragment.moofOffset,
|
|
(x) => x.moofOffset
|
|
);
|
|
assert(fragmentIndex !== -1);
|
|
return this.performFragmentedLookup(
|
|
() => {
|
|
if (locationInFragment.sampleIndex + 1 < trackData.samples.length) {
|
|
return {
|
|
fragmentIndex,
|
|
sampleIndex: locationInFragment.sampleIndex + 1,
|
|
correctSampleFound: true
|
|
};
|
|
} else {
|
|
let currentFragment = locationInFragment.fragment;
|
|
while (currentFragment.nextFragment) {
|
|
currentFragment = currentFragment.nextFragment;
|
|
const trackData2 = currentFragment.trackData.get(this.internalTrack.id);
|
|
if (trackData2) {
|
|
const fragmentIndex2 = binarySearchExact(
|
|
this.internalTrack.fragments,
|
|
currentFragment.moofOffset,
|
|
(x) => x.moofOffset
|
|
);
|
|
assert(fragmentIndex2 !== -1);
|
|
return {
|
|
fragmentIndex: fragmentIndex2,
|
|
sampleIndex: 0,
|
|
correctSampleFound: true
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
fragmentIndex,
|
|
sampleIndex: -1,
|
|
correctSampleFound: false
|
|
};
|
|
}
|
|
},
|
|
sample.presentationTimestamp,
|
|
Infinity,
|
|
options
|
|
);
|
|
}
|
|
const sampleIndex = this.chunkToSampleIndex.get(chunk);
|
|
if (sampleIndex === void 0) {
|
|
throw new Error("Chunk was not created from this track.");
|
|
}
|
|
return this.fetchChunkForSampleIndex(sampleIndex + 1, options);
|
|
}
|
|
async getKeyChunk(timestamp, options) {
|
|
const timestampInTimescale = timestamp * this.internalTrack.timescale;
|
|
if (this.internalTrack.demuxer.isFragmented) {
|
|
return this.performFragmentedLookup(
|
|
() => this.findKeySampleInFragmentsForTimestamp(timestampInTimescale),
|
|
timestampInTimescale,
|
|
timestampInTimescale,
|
|
options
|
|
);
|
|
}
|
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
|
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
|
const keyFrameSampleIndex = sampleIndex === -1 ? -1 : getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
|
|
return this.fetchChunkForSampleIndex(keyFrameSampleIndex, options);
|
|
}
|
|
async getNextKeyChunk(chunk, options) {
|
|
if (this.internalTrack.demuxer.isFragmented) {
|
|
const locationInFragment = this.chunkToFragmentLocation.get(chunk);
|
|
if (locationInFragment === void 0) {
|
|
throw new Error("Chunk was not created from this track.");
|
|
}
|
|
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id);
|
|
const sample = trackData.samples[locationInFragment.sampleIndex];
|
|
const fragmentIndex = binarySearchExact(
|
|
this.internalTrack.fragments,
|
|
locationInFragment.fragment.moofOffset,
|
|
(x) => x.moofOffset
|
|
);
|
|
assert(fragmentIndex !== -1);
|
|
return this.performFragmentedLookup(
|
|
() => {
|
|
const nextKeyFrameIndex = trackData.samples.findIndex(
|
|
(x, i) => x.isKeyFrame && i > locationInFragment.sampleIndex
|
|
);
|
|
if (nextKeyFrameIndex !== -1) {
|
|
return {
|
|
fragmentIndex,
|
|
sampleIndex: nextKeyFrameIndex,
|
|
correctSampleFound: true
|
|
};
|
|
} else {
|
|
let currentFragment = locationInFragment.fragment;
|
|
while (currentFragment.nextFragment) {
|
|
currentFragment = currentFragment.nextFragment;
|
|
const trackData2 = currentFragment.trackData.get(this.internalTrack.id);
|
|
if (trackData2) {
|
|
const fragmentIndex2 = binarySearchExact(
|
|
this.internalTrack.fragments,
|
|
currentFragment.moofOffset,
|
|
(x) => x.moofOffset
|
|
);
|
|
assert(fragmentIndex2 !== -1);
|
|
const keyFrameIndex = trackData2.samples.findIndex((x) => x.isKeyFrame);
|
|
if (keyFrameIndex === -1) {
|
|
throw new Error("Not supported: Fragment does not contain key sample.");
|
|
}
|
|
return {
|
|
fragmentIndex: fragmentIndex2,
|
|
sampleIndex: keyFrameIndex,
|
|
correctSampleFound: true
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
fragmentIndex,
|
|
sampleIndex: -1,
|
|
correctSampleFound: false
|
|
};
|
|
}
|
|
},
|
|
sample.presentationTimestamp,
|
|
Infinity,
|
|
options
|
|
);
|
|
}
|
|
const sampleIndex = this.chunkToSampleIndex.get(chunk);
|
|
if (sampleIndex === void 0) {
|
|
throw new Error("Chunk was not created from this track.");
|
|
}
|
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
|
const nextKeyFrameSampleIndex = getNextKeyframeIndexForSample(sampleTable, sampleIndex);
|
|
return this.fetchChunkForSampleIndex(nextKeyFrameSampleIndex, options);
|
|
}
|
|
async fetchChunkForSampleIndex(sampleIndex, options) {
|
|
if (sampleIndex === -1) {
|
|
return null;
|
|
}
|
|
const existingChunk = this.sampleIndexToChunk.get(sampleIndex)?.deref();
|
|
if (existingChunk) {
|
|
return existingChunk;
|
|
}
|
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
|
const sampleInfo = getSampleInfo(sampleTable, sampleIndex);
|
|
if (!sampleInfo) {
|
|
return null;
|
|
}
|
|
let data;
|
|
if (options.metadataOnly) {
|
|
data = new Uint8Array(0);
|
|
} else {
|
|
await this.internalTrack.demuxer.chunkReader.reader.loadRange(
|
|
sampleInfo.chunkOffset,
|
|
sampleInfo.chunkOffset + sampleInfo.chunkSize
|
|
);
|
|
data = this.internalTrack.demuxer.chunkReader.readRange(
|
|
sampleInfo.sampleOffset,
|
|
sampleInfo.sampleOffset + sampleInfo.sampleSize
|
|
);
|
|
}
|
|
const timestamp = 1e6 * sampleInfo.presentationTimestamp / this.internalTrack.timescale;
|
|
const duration = 1e6 * sampleInfo.duration / this.internalTrack.timescale;
|
|
const chunk = this.createChunk(data, timestamp, duration, sampleInfo.isKeyFrame);
|
|
this.chunkToSampleIndex.set(chunk, sampleIndex);
|
|
this.sampleIndexToChunk.set(sampleIndex, new WeakRef(chunk));
|
|
return chunk;
|
|
}
|
|
async fetchChunkInFragment(fragment, sampleIndex, options) {
|
|
if (sampleIndex === -1) {
|
|
return null;
|
|
}
|
|
const compositeKey = `${fragment.moofOffset}:${sampleIndex}`;
|
|
const existingChunk = this.fragmentLocationToChunk.get(compositeKey)?.deref();
|
|
if (existingChunk) {
|
|
return existingChunk;
|
|
}
|
|
const trackData = fragment.trackData.get(this.internalTrack.id);
|
|
const sample = trackData.samples[sampleIndex];
|
|
assert(sample);
|
|
let data;
|
|
if (options.metadataOnly) {
|
|
data = new Uint8Array(0);
|
|
} else {
|
|
await this.internalTrack.demuxer.chunkReader.reader.loadRange(fragment.dataStart, fragment.dataEnd);
|
|
data = this.internalTrack.demuxer.chunkReader.readRange(
|
|
sample.byteOffset,
|
|
sample.byteOffset + sample.byteSize
|
|
);
|
|
}
|
|
const timestamp = 1e6 * sample.presentationTimestamp / this.internalTrack.timescale;
|
|
const duration = 1e6 * sample.duration / this.internalTrack.timescale;
|
|
const chunk = this.createChunk(data, timestamp, duration, sample.isKeyFrame);
|
|
this.chunkToFragmentLocation.set(chunk, { fragment, sampleIndex });
|
|
this.fragmentLocationToChunk.set(compositeKey, new WeakRef(chunk));
|
|
return chunk;
|
|
}
|
|
findSampleInFragmentsForTimestamp(timestampInTimescale) {
|
|
const fragmentIndex = binarySearchLessOrEqual(
|
|
this.internalTrack.fragments,
|
|
timestampInTimescale,
|
|
(x) => x.trackData.get(this.internalTrack.id).startTimestamp
|
|
);
|
|
let sampleIndex = -1;
|
|
let correctSampleFound = false;
|
|
if (fragmentIndex !== -1) {
|
|
const fragment = this.internalTrack.fragments[fragmentIndex];
|
|
const trackData = fragment.trackData.get(this.internalTrack.id);
|
|
const index = binarySearchLessOrEqual(
|
|
trackData.presentationTimestamps,
|
|
timestampInTimescale,
|
|
(x) => x.presentationTimestamp
|
|
);
|
|
assert(index !== -1);
|
|
sampleIndex = trackData.presentationTimestamps[index].sampleIndex;
|
|
correctSampleFound = timestampInTimescale < trackData.endTimestamp;
|
|
}
|
|
return { fragmentIndex, sampleIndex, correctSampleFound };
|
|
}
|
|
findKeySampleInFragmentsForTimestamp(timestampInTimescale) {
|
|
const fragmentIndex = binarySearchLessOrEqual(
|
|
this.internalTrack.fragments,
|
|
timestampInTimescale,
|
|
(x) => x.trackData.get(this.internalTrack.id).startTimestamp
|
|
);
|
|
let sampleIndex = -1;
|
|
let correctSampleFound = false;
|
|
if (fragmentIndex !== -1) {
|
|
const fragment = this.internalTrack.fragments[fragmentIndex];
|
|
const trackData = fragment.trackData.get(this.internalTrack.id);
|
|
const index = trackData.presentationTimestamps.findLastIndex((x) => {
|
|
const sample = trackData.samples[x.sampleIndex];
|
|
return sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale;
|
|
});
|
|
if (index === -1) {
|
|
throw new Error("Not supported: Fragment does not begin with a key sample.");
|
|
}
|
|
const entry = trackData.presentationTimestamps[index];
|
|
sampleIndex = entry.sampleIndex;
|
|
correctSampleFound = timestampInTimescale < trackData.endTimestamp;
|
|
}
|
|
return { fragmentIndex, sampleIndex, correctSampleFound };
|
|
}
|
|
/** Looks for a sample in the fragments while trying to load as few fragments as possible to retrieve it. */
|
|
async performFragmentedLookup(getBestMatch, searchTimestamp, latestTimestamp, options) {
|
|
const demuxer = this.internalTrack.demuxer;
|
|
const release = await demuxer.fragmentLookupMutex.acquire();
|
|
try {
|
|
const { fragmentIndex, sampleIndex, correctSampleFound } = getBestMatch();
|
|
if (correctSampleFound) {
|
|
const fragment = this.internalTrack.fragments[fragmentIndex];
|
|
return this.fetchChunkInFragment(fragment, sampleIndex, options);
|
|
}
|
|
const isobmffReader = demuxer.isobmffReader;
|
|
const sourceSize = await isobmffReader.reader.source._getSize();
|
|
let prevFragment = null;
|
|
let bestFragmentIndex = fragmentIndex;
|
|
let bestSampleIndex = sampleIndex;
|
|
let lookupEntry = null;
|
|
if (this.internalTrack.fragmentLookupTable) {
|
|
const index = binarySearchLessOrEqual(
|
|
this.internalTrack.fragmentLookupTable,
|
|
searchTimestamp,
|
|
(x) => x.timestamp
|
|
);
|
|
if (index !== -1) {
|
|
lookupEntry = this.internalTrack.fragmentLookupTable[index];
|
|
}
|
|
}
|
|
if (fragmentIndex === -1) {
|
|
isobmffReader.pos = lookupEntry?.moofOffset ?? 0;
|
|
} else {
|
|
const fragment = this.internalTrack.fragments[fragmentIndex];
|
|
if (!lookupEntry || fragment.moofOffset >= fragment.moofOffset) {
|
|
isobmffReader.pos = fragment.moofOffset + fragment.moofSize;
|
|
prevFragment = fragment;
|
|
} else {
|
|
isobmffReader.pos = lookupEntry.moofOffset;
|
|
}
|
|
}
|
|
while (isobmffReader.pos < sourceSize) {
|
|
if (prevFragment) {
|
|
const trackData = prevFragment.trackData.get(this.internalTrack.id);
|
|
if (trackData && trackData.startTimestamp > latestTimestamp) {
|
|
break;
|
|
}
|
|
if (prevFragment.nextFragment) {
|
|
isobmffReader.pos = prevFragment.nextFragment.moofOffset + prevFragment.nextFragment.moofSize;
|
|
prevFragment = prevFragment.nextFragment;
|
|
continue;
|
|
}
|
|
}
|
|
await isobmffReader.reader.loadRange(isobmffReader.pos, isobmffReader.pos + 16);
|
|
const startPos = isobmffReader.pos;
|
|
const boxInfo = isobmffReader.readBoxHeader();
|
|
if (boxInfo.name === "moof") {
|
|
const index = binarySearchExact(demuxer.fragments, startPos, (x) => x.moofOffset);
|
|
if (index === -1) {
|
|
isobmffReader.pos = startPos;
|
|
const fragment = await demuxer.readFragment();
|
|
if (prevFragment) prevFragment.nextFragment = fragment;
|
|
prevFragment = fragment;
|
|
const { fragmentIndex: fragmentIndex2, sampleIndex: sampleIndex2, correctSampleFound: correctSampleFound2 } = getBestMatch();
|
|
if (correctSampleFound2) {
|
|
const fragment2 = this.internalTrack.fragments[fragmentIndex2];
|
|
return this.fetchChunkInFragment(fragment2, sampleIndex2, options);
|
|
}
|
|
if (fragmentIndex2 !== -1) {
|
|
bestFragmentIndex = fragmentIndex2;
|
|
bestSampleIndex = sampleIndex2;
|
|
}
|
|
} else {
|
|
const fragment = demuxer.fragments[index];
|
|
if (prevFragment) prevFragment.nextFragment = fragment;
|
|
prevFragment = fragment;
|
|
}
|
|
}
|
|
isobmffReader.pos = startPos + boxInfo.totalSize;
|
|
}
|
|
if (bestFragmentIndex !== -1) {
|
|
const fragment = this.internalTrack.fragments[bestFragmentIndex];
|
|
return this.fetchChunkInFragment(fragment, bestSampleIndex, options);
|
|
}
|
|
return null;
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
};
|
|
var IsobmffVideoTrackBacking = class extends IsobmffTrackBacking {
|
|
internalTrack;
|
|
constructor(internalTrack) {
|
|
super(internalTrack);
|
|
this.internalTrack = internalTrack;
|
|
}
|
|
async getCodec() {
|
|
return this.internalTrack.info.codec;
|
|
}
|
|
async getWidth() {
|
|
return this.internalTrack.info.width;
|
|
}
|
|
async getHeight() {
|
|
return this.internalTrack.info.height;
|
|
}
|
|
async getRotation() {
|
|
return this.internalTrack.rotation;
|
|
}
|
|
async getDecoderConfig() {
|
|
return {
|
|
codec: extractVideoCodecString(this.internalTrack.info.codec, this.internalTrack.info.codecDescription),
|
|
codedWidth: this.internalTrack.info.width,
|
|
codedHeight: this.internalTrack.info.height,
|
|
description: this.internalTrack.info.codecDescription ?? void 0,
|
|
colorSpace: this.internalTrack.info.colorSpace ?? void 0
|
|
};
|
|
}
|
|
createChunk(data, timestamp, duration, isKeyFrame) {
|
|
return new EncodedVideoChunk({
|
|
data,
|
|
timestamp,
|
|
duration,
|
|
type: isKeyFrame ? "key" : "delta"
|
|
});
|
|
}
|
|
};
|
|
var IsobmffAudioTrackBacking = class extends IsobmffTrackBacking {
|
|
internalTrack;
|
|
constructor(internalTrack) {
|
|
super(internalTrack);
|
|
this.internalTrack = internalTrack;
|
|
}
|
|
async getCodec() {
|
|
return this.internalTrack.info.codec;
|
|
}
|
|
async getNumberOfChannels() {
|
|
return this.internalTrack.info.numberOfChannels;
|
|
}
|
|
async getSampleRate() {
|
|
return this.internalTrack.info.sampleRate;
|
|
}
|
|
async getDecoderConfig() {
|
|
return {
|
|
codec: extractAudioCodecString(this.internalTrack.info.codec, this.internalTrack.info.codecDescription),
|
|
numberOfChannels: this.internalTrack.info.numberOfChannels,
|
|
sampleRate: this.internalTrack.info.sampleRate,
|
|
description: this.internalTrack.info.codecDescription ?? void 0
|
|
};
|
|
}
|
|
createChunk(data, timestamp, duration, isKeyFrame) {
|
|
return new EncodedAudioChunk({
|
|
data,
|
|
timestamp,
|
|
duration,
|
|
type: isKeyFrame ? "key" : "delta"
|
|
});
|
|
}
|
|
};
|
|
var getSampleIndexForTimestamp = (sampleTable, timescaleUnits) => {
|
|
const index = binarySearchLessOrEqual(
|
|
sampleTable.presentationTimestamps,
|
|
timescaleUnits,
|
|
(x) => x.presentationTimestamp
|
|
);
|
|
if (index === -1) {
|
|
return -1;
|
|
}
|
|
return sampleTable.presentationTimestamps[index].sampleIndex;
|
|
};
|
|
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) {
|
|
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];
|
|
let chunkSize = 0;
|
|
let sampleOffset = chunkOffset;
|
|
if (sampleTable.sampleSizes.length === 1) {
|
|
sampleOffset += sampleSize * (sampleIndex - chunkEntry.startSampleIndex);
|
|
chunkSize += sampleSize * chunkEntry.samplesPerChunk;
|
|
} else {
|
|
const startSampleIndex = chunkEntry.startSampleIndex + (chunkIndex - chunkEntry.startChunkIndex) * chunkEntry.samplesPerChunk;
|
|
for (let i = startSampleIndex; i < startSampleIndex + chunkEntry.samplesPerChunk; i++) {
|
|
const sampleSize2 = sampleTable.sampleSizes[i];
|
|
if (i < sampleIndex) {
|
|
sampleOffset += sampleSize2;
|
|
}
|
|
chunkSize += sampleSize2;
|
|
}
|
|
}
|
|
return {
|
|
presentationTimestamp,
|
|
duration: timingEntry.delta,
|
|
sampleOffset,
|
|
sampleSize,
|
|
chunkOffset,
|
|
chunkSize,
|
|
isKeyFrame: sampleTable.keySampleIndices ? binarySearchExact(sampleTable.keySampleIndices, sampleIndex, (x) => x) !== -1 : true
|
|
};
|
|
};
|
|
var getRelevantKeyframeIndexForSample = (sampleTable, sampleIndex) => {
|
|
if (!sampleTable.keySampleIndices) {
|
|
return sampleIndex;
|
|
}
|
|
const index = binarySearchLessOrEqual(sampleTable.keySampleIndices, sampleIndex, (x) => x);
|
|
return sampleTable.keySampleIndices[index] ?? -1;
|
|
};
|
|
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;
|
|
}
|
|
};
|
|
|
|
// src/matroska/matroska-demuxer.ts
|
|
var MatroskaDemuxer = class extends Demuxer {
|
|
};
|
|
|
|
// src/input-format.ts
|
|
var InputFormat = class {
|
|
};
|
|
var IsobmffInputFormat = class extends InputFormat {
|
|
/** @internal */
|
|
async _canReadInput(input) {
|
|
const sourceSize = await input._mainReader.source._getSize();
|
|
if (sourceSize < 8) {
|
|
return false;
|
|
}
|
|
const isobmffReader = new IsobmffReader(input._mainReader);
|
|
isobmffReader.pos = 4;
|
|
const fourCc = isobmffReader.readAscii(4);
|
|
return fourCc === "ftyp";
|
|
}
|
|
_createDemuxer(input) {
|
|
return new IsobmffDemuxer(input);
|
|
}
|
|
};
|
|
var MatroskaInputFormat = class extends InputFormat {
|
|
/** @internal */
|
|
async _canReadInput() {
|
|
return false;
|
|
}
|
|
_createDemuxer(input) {
|
|
return new MatroskaDemuxer(input);
|
|
}
|
|
};
|
|
var ISOBMFF = new IsobmffInputFormat();
|
|
var MP4 = ISOBMFF;
|
|
var MOV = ISOBMFF;
|
|
var MATROSKA = new MatroskaInputFormat();
|
|
var MKV = MATROSKA;
|
|
var WEBM = MATROSKA;
|
|
var ALL_FORMATS = [ISOBMFF, MKV];
|
|
|
|
// src/input.ts
|
|
var Input = class {
|
|
/** @internal */
|
|
_source;
|
|
/** @internal */
|
|
_formats;
|
|
/** @internal */
|
|
_mainReader;
|
|
/** @internal */
|
|
_demuxerPromise = null;
|
|
/** @internal */
|
|
_format = null;
|
|
constructor(options) {
|
|
this._formats = options.formats;
|
|
this._source = options.source;
|
|
this._mainReader = new Reader(options.source);
|
|
}
|
|
/** @internal */
|
|
_getDemuxer() {
|
|
return this._demuxerPromise ??= (async () => {
|
|
await this._mainReader.loadRange(0, 4096);
|
|
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 unrecognizable format.");
|
|
})();
|
|
}
|
|
async getFormat() {
|
|
await this._getDemuxer();
|
|
assert(this._format);
|
|
return this._format;
|
|
}
|
|
async computeDuration() {
|
|
const demuxer = await this._getDemuxer();
|
|
return demuxer.computeDuration();
|
|
}
|
|
async getTracks() {
|
|
const demuxer = await this._getDemuxer();
|
|
return demuxer.getTracks();
|
|
}
|
|
async getVideoTracks() {
|
|
const tracks = await this.getTracks();
|
|
return tracks.filter((x) => x.isVideoTrack());
|
|
}
|
|
async getPrimaryVideoTrack() {
|
|
const tracks = await this.getTracks();
|
|
return tracks.find((x) => x.isVideoTrack()) ?? null;
|
|
}
|
|
async getAudioTracks() {
|
|
const tracks = await this.getTracks();
|
|
return tracks.filter((x) => x.isAudioTrack());
|
|
}
|
|
async getPrimaryAudioTrack() {
|
|
const tracks = await this.getTracks();
|
|
return tracks.find((x) => x.isAudioTrack()) ?? null;
|
|
}
|
|
async getMimeType() {
|
|
const demuxer = await this._getDemuxer();
|
|
return demuxer.getMimeType();
|
|
}
|
|
};
|
|
|
|
// src/media-drain.ts
|
|
var BaseChunkDrain = class {
|
|
async *chunks(startChunk, endTimestamp = Infinity) {
|
|
const chunkQueue = [];
|
|
let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();
|
|
let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();
|
|
let ended = false;
|
|
const timestamps = [];
|
|
const maxQueueSize = () => Math.max(2, timestamps.length);
|
|
void (async () => {
|
|
let chunk = startChunk ?? await this.getFirstChunk();
|
|
while (chunk && !ended) {
|
|
if (chunk.timestamp / 1e6 >= endTimestamp) {
|
|
break;
|
|
}
|
|
if (chunkQueue.length > maxQueueSize()) {
|
|
({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());
|
|
await queueDequeue;
|
|
continue;
|
|
}
|
|
chunkQueue.push(chunk);
|
|
onQueueNotEmpty();
|
|
({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());
|
|
chunk = await this.getNextChunk(chunk);
|
|
}
|
|
ended = true;
|
|
onQueueNotEmpty();
|
|
})();
|
|
try {
|
|
while (true) {
|
|
if (chunkQueue.length > 0) {
|
|
yield chunkQueue.shift();
|
|
const now = performance.now();
|
|
timestamps.push(now);
|
|
while (timestamps.length > 0 && now - timestamps[0] >= 1e3) {
|
|
timestamps.shift();
|
|
}
|
|
onQueueDequeue();
|
|
} else if (!ended) {
|
|
await queueNotEmpty;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
} finally {
|
|
ended = true;
|
|
onQueueDequeue();
|
|
}
|
|
}
|
|
};
|
|
var BaseMediaFrameDrain = class {
|
|
async getKeyMediaFrame(timestamp) {
|
|
let result = null;
|
|
const decoder = await this.createDecoder((frame) => result = frame);
|
|
const chunkDrain = this.createChunkDrain();
|
|
const chunk = await chunkDrain.getKeyChunk(timestamp);
|
|
if (!chunk) {
|
|
return null;
|
|
}
|
|
decoder.decode(chunk);
|
|
await decoder.flush();
|
|
decoder.close();
|
|
return result;
|
|
}
|
|
async getMediaFrame(timestamp) {
|
|
let result = null;
|
|
const decoder = await this.createDecoder((frame) => {
|
|
if (frame.timestamp / 1e6 <= timestamp) {
|
|
result?.close();
|
|
result = frame;
|
|
} else {
|
|
frame.close();
|
|
}
|
|
});
|
|
const chunkDrain = this.createChunkDrain();
|
|
const keyChunk = await chunkDrain.getKeyChunk(timestamp);
|
|
if (!keyChunk) {
|
|
return null;
|
|
}
|
|
const targetChunk = await chunkDrain.getChunk(timestamp);
|
|
assert(targetChunk);
|
|
decoder.decode(keyChunk);
|
|
let currentChunk = keyChunk;
|
|
while (currentChunk !== targetChunk) {
|
|
const nextChunk = await chunkDrain.getNextChunk(currentChunk);
|
|
assert(nextChunk);
|
|
currentChunk = nextChunk;
|
|
decoder.decode(nextChunk);
|
|
if (decoder.decodeQueueSize >= 10) {
|
|
await new Promise((resolve) => decoder.addEventListener("dequeue", resolve, { once: true }));
|
|
}
|
|
}
|
|
await decoder.flush();
|
|
decoder.close();
|
|
return result;
|
|
}
|
|
async *mediaFrames(startTimestamp = 0, endTimestamp = Infinity) {
|
|
const frameQueue = [];
|
|
let firstFrameQueued = false;
|
|
let lastFrame = null;
|
|
let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();
|
|
let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();
|
|
let ended = false;
|
|
const MAX_QUEUE_SIZE = 8;
|
|
const decoder = await this.createDecoder((frame) => {
|
|
onQueueDequeue();
|
|
const frameTimestamp = frame.timestamp / 1e6;
|
|
if (frameTimestamp >= endTimestamp) {
|
|
ended = true;
|
|
}
|
|
if (ended) {
|
|
frame.close();
|
|
return;
|
|
}
|
|
if (lastFrame) {
|
|
if (frameTimestamp > startTimestamp) {
|
|
frameQueue.push(lastFrame);
|
|
firstFrameQueued = true;
|
|
} else {
|
|
lastFrame.close();
|
|
}
|
|
}
|
|
if (frameTimestamp >= startTimestamp) {
|
|
frameQueue.push(frame);
|
|
firstFrameQueued = true;
|
|
}
|
|
lastFrame = firstFrameQueued ? null : frame;
|
|
if (frameQueue.length > 0) {
|
|
onQueueNotEmpty();
|
|
({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());
|
|
}
|
|
});
|
|
const chunkDrain = this.createChunkDrain();
|
|
const keyChunk = await chunkDrain.getKeyChunk(startTimestamp) ?? await chunkDrain.getFirstChunk();
|
|
if (!keyChunk) {
|
|
return;
|
|
}
|
|
let decoderIsFlushed = false;
|
|
void (async () => {
|
|
let currentChunk = keyChunk;
|
|
let chunksEndTimestamp = Infinity;
|
|
if (endTimestamp < Infinity) {
|
|
const endFrame = await chunkDrain.getChunk(endTimestamp);
|
|
const endKeyFrame = !endFrame ? null : endFrame.type === "key" && endFrame.timestamp / 1e6 === endTimestamp ? endFrame : await chunkDrain.getNextKeyChunk(endFrame);
|
|
if (endKeyFrame) {
|
|
chunksEndTimestamp = endKeyFrame.timestamp / 1e6;
|
|
}
|
|
}
|
|
const chunks = chunkDrain.chunks(keyChunk, chunksEndTimestamp);
|
|
await chunks.next();
|
|
while (currentChunk && !ended) {
|
|
if (frameQueue.length + decoder.decodeQueueSize > MAX_QUEUE_SIZE) {
|
|
({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());
|
|
await queueDequeue;
|
|
continue;
|
|
}
|
|
decoder.decode(currentChunk);
|
|
const chunkResult = await chunks.next();
|
|
if (chunkResult.done) {
|
|
break;
|
|
}
|
|
currentChunk = chunkResult.value;
|
|
}
|
|
await chunks.return();
|
|
await decoder.flush();
|
|
decoder.close();
|
|
if (!firstFrameQueued && lastFrame) {
|
|
frameQueue.push(lastFrame);
|
|
}
|
|
decoderIsFlushed = true;
|
|
onQueueNotEmpty();
|
|
})();
|
|
try {
|
|
while (true) {
|
|
if (frameQueue.length > 0) {
|
|
yield frameQueue.shift();
|
|
onQueueDequeue();
|
|
} else if (!decoderIsFlushed) {
|
|
await queueNotEmpty;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
} finally {
|
|
ended = true;
|
|
onQueueDequeue();
|
|
for (const frame of frameQueue) {
|
|
frame.close();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
var EncodedVideoChunkDrain = class extends BaseChunkDrain {
|
|
constructor(videoTrack) {
|
|
super();
|
|
this.videoTrack = videoTrack;
|
|
}
|
|
getFirstChunk(options = {}) {
|
|
return this.videoTrack._backing.getFirstChunk(options);
|
|
}
|
|
getChunk(timestamp, options = {}) {
|
|
return this.videoTrack._backing.getChunk(timestamp, options);
|
|
}
|
|
getNextChunk(chunk, options = {}) {
|
|
return this.videoTrack._backing.getNextChunk(chunk, options);
|
|
}
|
|
getKeyChunk(timestamp, options = {}) {
|
|
return this.videoTrack._backing.getKeyChunk(timestamp, options);
|
|
}
|
|
getNextKeyChunk(chunk, options = {}) {
|
|
return this.videoTrack._backing.getNextKeyChunk(chunk, options);
|
|
}
|
|
};
|
|
var VideoFrameDrain = class extends BaseMediaFrameDrain {
|
|
constructor(videoTrack) {
|
|
super();
|
|
this.videoTrack = videoTrack;
|
|
}
|
|
decoderConfig = null;
|
|
async createDecoder(onFrame) {
|
|
if (!this.decoderConfig) {
|
|
this.decoderConfig = await this.videoTrack.getDecoderConfig();
|
|
}
|
|
const decoder = new VideoDecoder({
|
|
output: onFrame,
|
|
error: (error) => console.error(error)
|
|
});
|
|
decoder.configure(this.decoderConfig);
|
|
return decoder;
|
|
}
|
|
createChunkDrain() {
|
|
return new EncodedVideoChunkDrain(this.videoTrack);
|
|
}
|
|
getKeyFrame(timestamp) {
|
|
return this.getKeyMediaFrame(timestamp);
|
|
}
|
|
getFrame(timestamp) {
|
|
return this.getMediaFrame(timestamp);
|
|
}
|
|
frames(startTimestamp = 0, endTimestamp = Infinity) {
|
|
return this.mediaFrames(startTimestamp, endTimestamp);
|
|
}
|
|
};
|
|
var EncodedAudioChunkDrain = class extends BaseChunkDrain {
|
|
constructor(audioTrack) {
|
|
super();
|
|
this.audioTrack = audioTrack;
|
|
}
|
|
getFirstChunk(options = {}) {
|
|
return this.audioTrack._backing.getFirstChunk(options);
|
|
}
|
|
getChunk(timestamp, options = {}) {
|
|
return this.audioTrack._backing.getChunk(timestamp, options);
|
|
}
|
|
getNextChunk(chunk, options = {}) {
|
|
return this.audioTrack._backing.getNextChunk(chunk, options);
|
|
}
|
|
getKeyChunk(timestamp, options = {}) {
|
|
return this.audioTrack._backing.getKeyChunk(timestamp, options);
|
|
}
|
|
getNextKeyChunk(chunk, options = {}) {
|
|
return this.audioTrack._backing.getNextKeyChunk(chunk, options);
|
|
}
|
|
};
|
|
var AudioDataDrain = class extends BaseMediaFrameDrain {
|
|
constructor(audioTrack) {
|
|
super();
|
|
this.audioTrack = audioTrack;
|
|
}
|
|
decoderConfig = null;
|
|
async createDecoder(onData) {
|
|
if (!this.decoderConfig) {
|
|
this.decoderConfig = await this.audioTrack.getDecoderConfig();
|
|
}
|
|
const decoder = new AudioDecoder({
|
|
output: onData,
|
|
error: (error) => console.error(error)
|
|
});
|
|
decoder.configure(this.decoderConfig);
|
|
return decoder;
|
|
}
|
|
createChunkDrain() {
|
|
return new EncodedAudioChunkDrain(this.audioTrack);
|
|
}
|
|
getKeyData(timestamp) {
|
|
return this.getKeyMediaFrame(timestamp);
|
|
}
|
|
getData(timestamp) {
|
|
return this.getMediaFrame(timestamp);
|
|
}
|
|
data(startTimestamp = 0, endTimestamp = Infinity) {
|
|
return this.mediaFrames(startTimestamp, endTimestamp);
|
|
}
|
|
};
|
|
var AudioBufferDrain = class {
|
|
constructor(audioTrack) {
|
|
this.audioTrack = audioTrack;
|
|
this.audioDataDrain = new AudioDataDrain(audioTrack);
|
|
}
|
|
audioDataDrain;
|
|
audioDataToWrappedArrayBuffer(data) {
|
|
if (!data) {
|
|
return null;
|
|
}
|
|
const audioBuffer = new AudioBuffer({
|
|
numberOfChannels: data.numberOfChannels,
|
|
length: data.numberOfFrames,
|
|
sampleRate: data.sampleRate
|
|
});
|
|
for (let i = 0; i < data.numberOfChannels; i++) {
|
|
const dataBytes = new Float32Array(data.allocationSize({ planeIndex: i }));
|
|
data.copyTo(dataBytes, { planeIndex: i });
|
|
audioBuffer.copyToChannel(dataBytes, i);
|
|
}
|
|
const sampleDuration = 1 / data.sampleRate;
|
|
return {
|
|
buffer: audioBuffer,
|
|
// Rounding the timestamp based on the sample duration removes audio playback artifacts
|
|
timestamp: Math.round(data.timestamp / 1e6 / sampleDuration) * sampleDuration
|
|
};
|
|
}
|
|
async getBuffer(timestamp) {
|
|
const data = await this.audioDataDrain.getData(timestamp);
|
|
return this.audioDataToWrappedArrayBuffer(data);
|
|
}
|
|
async *buffers(startTimestamp = 0, endTimestamp = Infinity) {
|
|
for await (const data of this.audioDataDrain.data(startTimestamp, endTimestamp)) {
|
|
const result = this.audioDataToWrappedArrayBuffer(data);
|
|
data.close();
|
|
yield result;
|
|
}
|
|
}
|
|
};
|
|
export {
|
|
ALL_FORMATS,
|
|
AUDIO_CODECS,
|
|
ArrayBufferSource,
|
|
ArrayBufferTarget,
|
|
AudioBufferDrain,
|
|
AudioBufferSource,
|
|
AudioDataDrain,
|
|
AudioDataSource,
|
|
AudioSource,
|
|
BlobSource,
|
|
CanvasSource,
|
|
EncodedAudioChunkDrain,
|
|
EncodedAudioChunkSource,
|
|
EncodedVideoChunkDrain,
|
|
EncodedVideoChunkSource,
|
|
ISOBMFF,
|
|
Input,
|
|
MATROSKA,
|
|
MKV,
|
|
MOV,
|
|
MP4,
|
|
MediaSource,
|
|
MediaStreamAudioTrackSource,
|
|
MediaStreamVideoTrackSource,
|
|
MkvOutputFormat2 as MkvOutputFormat,
|
|
Mp4OutputFormat,
|
|
Output,
|
|
OutputFormat,
|
|
SUBTITLE_CODECS,
|
|
Source,
|
|
StreamTarget,
|
|
SubtitleSource,
|
|
Target,
|
|
TextSubtitleSource,
|
|
VIDEO_CODECS,
|
|
VideoFrameDrain,
|
|
VideoFrameSource,
|
|
VideoSource,
|
|
WEBM,
|
|
WebMOutputFormat
|
|
};
|