mediabunny
Version:
Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.
2,178 lines • 99.4 kB
JavaScript
/*!
* Copyright (c) 2026-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { extractAv1CodecInfoFromPacket, extractAvcDecoderConfigurationRecord, extractHevcDecoderConfigurationRecord, extractVp9CodecInfoFromPacket, } from '../codec-data.js';
import { extractAudioCodecString, extractVideoCodecString, OPUS_SAMPLE_RATE, } from '../codec.js';
import { Demuxer } from '../demuxer.js';
import { AttachedFile, DEFAULT_TRACK_DISPOSITION } from '../metadata.js';
import { assert, binarySearchLessOrEqual, COLOR_PRIMARIES_MAP_INVERSE, findLastIndex, isIso639Dash2LanguageCode, last, MATRIX_COEFFICIENTS_MAP_INVERSE, normalizeRotation, roundIfAlmostInteger, TRANSFER_CHARACTERISTICS_MAP_INVERSE, UNDETERMINED_LANGUAGE, } from '../misc.js';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet.js';
import { assertDefinedSize, CODEC_STRING_MAP, EBMLId, LEVEL_0_AND_1_EBML_IDS, LEVEL_1_EBML_IDS, MAX_HEADER_SIZE, MIN_HEADER_SIZE, readAsciiString, readUnicodeString, readElementHeader, readElementId, readFloat, readUnsignedInt, readVarInt, resync, searchForNextElementId, readUnsignedBigInt, } from './ebml.js';
import { buildMatroskaMimeType } from './matroska-misc.js';
import { FileSlice, readBytes, readI16Be, readU8 } from '../reader.js';
var BlockLacing;
(function (BlockLacing) {
BlockLacing[BlockLacing["None"] = 0] = "None";
BlockLacing[BlockLacing["Xiph"] = 1] = "Xiph";
BlockLacing[BlockLacing["FixedSize"] = 2] = "FixedSize";
BlockLacing[BlockLacing["Ebml"] = 3] = "Ebml";
})(BlockLacing || (BlockLacing = {}));
var ContentEncodingScope;
(function (ContentEncodingScope) {
ContentEncodingScope[ContentEncodingScope["Block"] = 1] = "Block";
ContentEncodingScope[ContentEncodingScope["Private"] = 2] = "Private";
ContentEncodingScope[ContentEncodingScope["Next"] = 4] = "Next";
})(ContentEncodingScope || (ContentEncodingScope = {}));
var ContentCompAlgo;
(function (ContentCompAlgo) {
ContentCompAlgo[ContentCompAlgo["Zlib"] = 0] = "Zlib";
ContentCompAlgo[ContentCompAlgo["Bzlib"] = 1] = "Bzlib";
ContentCompAlgo[ContentCompAlgo["lzo1x"] = 2] = "lzo1x";
ContentCompAlgo[ContentCompAlgo["HeaderStripping"] = 3] = "HeaderStripping";
})(ContentCompAlgo || (ContentCompAlgo = {}));
const METADATA_ELEMENTS = [
{ id: EBMLId.SeekHead, flag: 'seekHeadSeen' },
{ id: EBMLId.Info, flag: 'infoSeen' },
{ id: EBMLId.Tracks, flag: 'tracksSeen' },
{ id: EBMLId.Cues, flag: 'cuesSeen' },
];
const MAX_RESYNC_LENGTH = 10 * 2 ** 20; // 10 MiB
export class MatroskaDemuxer extends Demuxer {
constructor(input) {
super(input);
this.readMetadataPromise = null;
this.segments = [];
this.currentSegment = null;
this.currentTrack = null;
this.currentCluster = null;
this.currentBlock = null;
this.currentBlockAdditional = null;
this.currentCueTime = null;
this.currentDecodingInstruction = null;
this.currentTagTargetIsMovie = true;
this.currentSimpleTagName = null;
this.currentAttachedFile = null;
this.isWebM = false;
this.reader = input._reader;
}
async getTrackBackings() {
await this.readMetadata();
return this.segments.flatMap(segment => segment.tracks.map(track => track.trackBacking));
}
async getMimeType() {
await this.readMetadata();
const backings = await this.getTrackBackings();
const codecStrings = await Promise.all(backings.map(x => x.getDecoderConfig().then(c => c?.codec ?? null)));
return buildMatroskaMimeType({
isWebM: this.isWebM,
hasVideo: this.segments.some(segment => segment.tracks.some(x => x.info?.type === 'video')),
hasAudio: this.segments.some(segment => segment.tracks.some(x => x.info?.type === 'audio')),
codecStrings: codecStrings.filter(Boolean),
});
}
async getMetadataTags() {
await this.readMetadata();
// Load metadata tags from each segment lazily (only once)
for (const segment of this.segments) {
if (!segment.metadataTagsCollected) {
if (this.reader.fileSize !== null) {
await this.loadSegmentMetadata(segment);
}
else {
// The seeking would be too crazy, let's not
}
segment.metadataTagsCollected = true;
}
}
// This is kinda handwavy, and how we handle multiple segments isn't suuuuper well-defined anyway; so we just
// shallow-merge metadata tags from all (usually just one) segments.
let metadataTags = {};
for (const segment of this.segments) {
metadataTags = { ...metadataTags, ...segment.metadataTags };
}
return metadataTags;
}
readMetadata() {
return this.readMetadataPromise ??= (async () => {
let currentPos = 0;
// Loop over all top-level elements in the file
while (true) {
let slice = this.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (slice instanceof Promise)
slice = await slice;
if (!slice)
break;
const header = readElementHeader(slice);
if (!header) {
break; // Zero padding at the end of the file triggers this, for example
}
const id = header.id;
let size = header.size;
const dataStartPos = slice.filePos;
if (id === EBMLId.EBML) {
assertDefinedSize(size);
let slice = this.reader.requestSlice(dataStartPos, size);
if (slice instanceof Promise)
slice = await slice;
if (!slice)
break;
this.readContiguousElements(slice);
}
else if (id === EBMLId.Segment) { // Segment found!
await this.readSegment(dataStartPos, size);
if (size === undefined) {
// Segment sizes can be undefined (common in livestreamed files), so assume this is the last
// and only segment
break;
}
if (this.reader.fileSize === null) {
break; // Stop at the first segment
}
}
else if (id === EBMLId.Cluster) {
if (this.reader.fileSize === null) {
break; // Shouldn't be reached anyway, since we stop at the first segment
}
// Clusters are not a top-level element in Matroska, but some files contain a Segment whose size
// doesn't contain any of the clusters that follow it. In the case, we apply the following logic: if
// we find a top-level cluster, attribute it to the previous segment.
if (size === undefined) {
// Just in case this is one of those weird sizeless clusters, let's do our best and still try to
// determine its size.
const nextElementPos = await searchForNextElementId(this.reader, dataStartPos, LEVEL_0_AND_1_EBML_IDS, this.reader.fileSize);
size = nextElementPos.pos - dataStartPos;
}
const lastSegment = last(this.segments);
if (lastSegment) {
// Extend the previous segment's size
lastSegment.elementEndPos = dataStartPos + size;
}
}
assertDefinedSize(size);
currentPos = dataStartPos + size;
}
})();
}
async readSegment(segmentDataStart, dataSize) {
this.currentSegment = {
seekHeadSeen: false,
infoSeen: false,
tracksSeen: false,
cuesSeen: false,
tagsSeen: false,
attachmentsSeen: false,
timestampScale: -1,
timestampFactor: -1,
duration: -1,
seekEntries: [],
tracks: [],
cuePoints: [],
dataStartPos: segmentDataStart,
elementEndPos: dataSize === undefined
? null // Assume it goes until the end of the file
: segmentDataStart + dataSize,
clusterSeekStartPos: segmentDataStart,
lastReadCluster: null,
metadataTags: {},
metadataTagsCollected: false,
};
this.segments.push(this.currentSegment);
let currentPos = segmentDataStart;
while (this.currentSegment.elementEndPos === null || currentPos < this.currentSegment.elementEndPos) {
let slice = this.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (slice instanceof Promise)
slice = await slice;
if (!slice)
break;
const elementStartPos = currentPos;
const header = readElementHeader(slice);
if (!header || (!LEVEL_1_EBML_IDS.includes(header.id) && header.id !== EBMLId.Void)) {
// Potential junk. Let's try to resync
const nextPos = await resync(this.reader, elementStartPos, LEVEL_1_EBML_IDS, Math.min(this.currentSegment.elementEndPos ?? Infinity, elementStartPos + MAX_RESYNC_LENGTH));
if (nextPos) {
currentPos = nextPos;
continue;
}
else {
break; // Resync failed
}
}
const { id, size } = header;
const dataStartPos = slice.filePos;
const metadataElementIndex = METADATA_ELEMENTS.findIndex(x => x.id === id);
if (metadataElementIndex !== -1) {
const field = METADATA_ELEMENTS[metadataElementIndex].flag;
this.currentSegment[field] = true;
assertDefinedSize(size);
let slice = this.reader.requestSlice(dataStartPos, size);
if (slice instanceof Promise)
slice = await slice;
if (slice) {
this.readContiguousElements(slice);
}
}
else if (id === EBMLId.Tags || id === EBMLId.Attachments) {
// Metadata found at the beginning of the segment, great, let's parse it
if (id === EBMLId.Tags) {
this.currentSegment.tagsSeen = true;
}
else {
this.currentSegment.attachmentsSeen = true;
}
assertDefinedSize(size);
let slice = this.reader.requestSlice(dataStartPos, size);
if (slice instanceof Promise)
slice = await slice;
if (slice) {
this.readContiguousElements(slice);
}
}
else if (id === EBMLId.Cluster) {
this.currentSegment.clusterSeekStartPos = elementStartPos;
break; // Stop at the first cluster
}
if (size === undefined) {
break;
}
else {
currentPos = dataStartPos + size;
}
}
// Sort the seek entries by file position so reading them exhibits a sequential pattern
this.currentSegment.seekEntries.sort((a, b) => a.segmentPosition - b.segmentPosition);
if (this.reader.fileSize !== null) {
// Use the seek head to read missing metadata elements
for (const seekEntry of this.currentSegment.seekEntries) {
const target = METADATA_ELEMENTS.find(x => x.id === seekEntry.id);
if (!target) {
continue;
}
if (this.currentSegment[target.flag])
continue;
let slice = this.reader.requestSliceRange(segmentDataStart + seekEntry.segmentPosition, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (slice instanceof Promise)
slice = await slice;
if (!slice)
continue;
const header = readElementHeader(slice);
if (!header)
continue;
const { id, size } = header;
if (id !== target.id)
continue;
assertDefinedSize(size);
this.currentSegment[target.flag] = true;
let dataSlice = this.reader.requestSlice(slice.filePos, size);
if (dataSlice instanceof Promise)
dataSlice = await dataSlice;
if (!dataSlice)
continue;
this.readContiguousElements(dataSlice);
}
}
if (this.currentSegment.timestampScale === -1) {
// TimestampScale element is missing. Technically an invalid file, but let's default to the typical value,
// which is 1e6.
this.currentSegment.timestampScale = 1e6;
this.currentSegment.timestampFactor = 1e9 / 1e6;
}
// Compute default duration for all tracks now that we have the timestamp factor
for (const track of this.currentSegment.tracks) {
if (track.defaultDurationNs !== null) {
track.defaultDuration = (this.currentSegment.timestampFactor * track.defaultDurationNs) / 1e9;
}
}
// Now, let's distribute the cue points to the tracks
const idToTrack = new Map(this.currentSegment.tracks.map(x => [x.id, x]));
// Assign cue points to their respective tracks
for (const cuePoint of this.currentSegment.cuePoints) {
const track = idToTrack.get(cuePoint.trackId);
if (track) {
track.cuePoints.push(cuePoint);
}
}
for (const track of this.currentSegment.tracks) {
// Sort cue points by time
track.cuePoints.sort((a, b) => a.time - b.time);
// Remove multiple cue points for the same time
for (let i = 0; i < track.cuePoints.length - 1; i++) {
const cuePoint1 = track.cuePoints[i];
const cuePoint2 = track.cuePoints[i + 1];
if (cuePoint1.time === cuePoint2.time) {
track.cuePoints.splice(i + 1, 1);
i--;
}
}
}
let trackWithMostCuePoints = null;
let maxCuePointCount = -Infinity;
for (const track of this.currentSegment.tracks) {
if (track.cuePoints.length > maxCuePointCount) {
maxCuePointCount = track.cuePoints.length;
trackWithMostCuePoints = track;
}
}
// For every track that has received 0 cue points (can happen, often only the video track receives cue points),
// we still want to have better seeking. Therefore, let's give it the cue points of the track with the most cue
// points, which should provide us with the most fine-grained seeking.
for (const track of this.currentSegment.tracks) {
if (track.cuePoints.length === 0) {
track.cuePoints = trackWithMostCuePoints.cuePoints;
}
}
this.currentSegment = null;
}
async readCluster(startPos, segment) {
if (segment.lastReadCluster?.elementStartPos === startPos) {
return segment.lastReadCluster;
}
let headerSlice = this.reader.requestSliceRange(startPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (headerSlice instanceof Promise)
headerSlice = await headerSlice;
assert(headerSlice);
const elementStartPos = startPos;
const elementHeader = readElementHeader(headerSlice);
assert(elementHeader);
const id = elementHeader.id;
assert(id === EBMLId.Cluster);
let size = elementHeader.size;
const dataStartPos = headerSlice.filePos;
if (size === undefined) {
// The cluster's size is undefined (can happen in livestreamed files). We'd still like to know the size of
// it, so we have no other choice but to iterate over the EBML structure until we find an element at level
// 0 or 1, indicating the end of the cluster (all elements inside the cluster are at level 2).
const nextElementPos = await searchForNextElementId(this.reader, dataStartPos, LEVEL_0_AND_1_EBML_IDS, segment.elementEndPos);
size = nextElementPos.pos - dataStartPos;
}
// Load the entire cluster
let dataSlice = this.reader.requestSlice(dataStartPos, size);
if (dataSlice instanceof Promise)
dataSlice = await dataSlice;
const cluster = {
segment,
elementStartPos,
elementEndPos: dataStartPos + size,
dataStartPos,
timestamp: -1,
trackData: new Map(),
};
this.currentCluster = cluster;
if (dataSlice) {
// Read the children of the cluster, stopping early at level 0 or 1 EBML elements. We do this because some
// clusters have incorrect sizes that are too large
const endPos = this.readContiguousElements(dataSlice, LEVEL_0_AND_1_EBML_IDS);
cluster.elementEndPos = endPos;
}
for (const [, trackData] of cluster.trackData) {
const track = trackData.track;
// This must hold, as track datas only get created if a block for that track is encountered
assert(trackData.blocks.length > 0);
let hasLacedBlocks = false;
for (let i = 0; i < trackData.blocks.length; i++) {
const block = trackData.blocks[i];
block.timestamp += cluster.timestamp;
hasLacedBlocks ||= block.lacing !== BlockLacing.None;
}
trackData.presentationTimestamps = trackData.blocks
.map((block, i) => ({ timestamp: block.timestamp, blockIndex: i }))
.sort((a, b) => a.timestamp - b.timestamp);
for (let i = 0; i < trackData.presentationTimestamps.length; i++) {
const currentEntry = trackData.presentationTimestamps[i];
const currentBlock = trackData.blocks[currentEntry.blockIndex];
if (trackData.firstKeyFrameTimestamp === null && currentBlock.isKeyFrame) {
trackData.firstKeyFrameTimestamp = currentBlock.timestamp;
}
if (i < trackData.presentationTimestamps.length - 1) {
// Update block durations based on presentation order
const nextEntry = trackData.presentationTimestamps[i + 1];
currentBlock.duration = nextEntry.timestamp - currentBlock.timestamp;
}
else if (currentBlock.duration === 0) {
if (track.defaultDuration != null) {
if (currentBlock.lacing === BlockLacing.None) {
currentBlock.duration = track.defaultDuration;
}
else {
// Handled by the lace resolution code
}
}
}
}
if (hasLacedBlocks) {
// Perform lace resolution. Here, we expand each laced block into multiple blocks where each contains
// one frame of the lace. We do this after determining block timestamps so we can properly distribute
// the block's duration across the laced frames.
this.expandLacedBlocks(trackData.blocks, track);
// Recompute since blocks have changed
trackData.presentationTimestamps = trackData.blocks
.map((block, i) => ({ timestamp: block.timestamp, blockIndex: i }))
.sort((a, b) => a.timestamp - b.timestamp);
}
const firstBlock = trackData.blocks[trackData.presentationTimestamps[0].blockIndex];
const lastBlock = trackData.blocks[last(trackData.presentationTimestamps).blockIndex];
trackData.startTimestamp = firstBlock.timestamp;
trackData.endTimestamp = lastBlock.timestamp + lastBlock.duration;
// Let's remember that a cluster with a given timestamp is here, speeding up future lookups if no cues exist
const insertionIndex = binarySearchLessOrEqual(track.clusterPositionCache, trackData.startTimestamp, x => x.startTimestamp);
if (insertionIndex === -1
|| track.clusterPositionCache[insertionIndex].elementStartPos !== elementStartPos) {
track.clusterPositionCache.splice(insertionIndex + 1, 0, {
elementStartPos: cluster.elementStartPos,
startTimestamp: trackData.startTimestamp,
});
}
}
segment.lastReadCluster = cluster;
return cluster;
}
getTrackDataInCluster(cluster, trackNumber) {
let trackData = cluster.trackData.get(trackNumber);
if (!trackData) {
const track = cluster.segment.tracks.find(x => x.id === trackNumber);
if (!track) {
return null;
}
trackData = {
track,
startTimestamp: 0,
endTimestamp: 0,
firstKeyFrameTimestamp: null,
blocks: [],
presentationTimestamps: [],
};
cluster.trackData.set(trackNumber, trackData);
}
return trackData;
}
expandLacedBlocks(blocks, track) {
// https://www.matroska.org/technical/notes.html#block-lacing
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
const originalBlock = blocks[blockIndex];
if (originalBlock.lacing === BlockLacing.None) {
continue;
}
// Decode the block data if it hasn't been decoded yet (needed for lacing expansion)
if (!originalBlock.decoded) {
originalBlock.data = this.decodeBlockData(track, originalBlock.data);
originalBlock.decoded = true;
}
const slice = FileSlice.tempFromBytes(originalBlock.data);
const frameSizes = [];
const frameCount = readU8(slice) + 1;
switch (originalBlock.lacing) {
case BlockLacing.Xiph:
{
let totalUsedSize = 0;
// Xiph lacing, just like in Ogg
for (let i = 0; i < frameCount - 1; i++) {
let frameSize = 0;
while (slice.bufferPos < slice.length) {
const value = readU8(slice);
frameSize += value;
if (value < 255) {
frameSizes.push(frameSize);
totalUsedSize += frameSize;
break;
}
}
}
// Compute the last frame's size from whatever's left
frameSizes.push(slice.length - (slice.bufferPos + totalUsedSize));
}
;
break;
case BlockLacing.FixedSize:
{
// Fixed size lacing: all frames have same size
const totalDataSize = slice.length - 1; // Minus the frame count byte
const frameSize = Math.floor(totalDataSize / frameCount);
for (let i = 0; i < frameCount; i++) {
frameSizes.push(frameSize);
}
}
;
break;
case BlockLacing.Ebml:
{
// EBML lacing: first size absolute, subsequent ones are coded as signed differences from the last
const firstResult = readVarInt(slice);
assert(firstResult !== null); // Assume it's not an invalid VINT
let currentSize = firstResult;
frameSizes.push(currentSize);
let totalUsedSize = currentSize;
for (let i = 1; i < frameCount - 1; i++) {
const startPos = slice.bufferPos;
const diffResult = readVarInt(slice);
assert(diffResult !== null);
const unsignedDiff = diffResult;
const width = slice.bufferPos - startPos;
const bias = (1 << (width * 7 - 1)) - 1; // Typo-corrected version of 2^((7*n)-1)^-1
const diff = unsignedDiff - bias;
currentSize += diff;
frameSizes.push(currentSize);
totalUsedSize += currentSize;
}
// Compute the last frame's size from whatever's left
frameSizes.push(slice.length - (slice.bufferPos + totalUsedSize));
}
;
break;
default: assert(false);
}
assert(frameSizes.length === frameCount);
blocks.splice(blockIndex, 1); // Remove the original block
const blockDuration = originalBlock.duration || frameCount * (track.defaultDuration ?? 0);
// Now, let's insert each frame as its own block
for (let i = 0; i < frameCount; i++) {
const frameSize = frameSizes[i];
const frameData = readBytes(slice, frameSize);
// Distribute timestamps evenly across the block duration
const frameTimestamp = originalBlock.timestamp + (blockDuration * i / frameCount);
const frameDuration = blockDuration / frameCount;
blocks.splice(blockIndex + i, 0, {
timestamp: frameTimestamp,
duration: frameDuration,
isKeyFrame: originalBlock.isKeyFrame,
data: frameData,
lacing: BlockLacing.None,
decoded: true,
mainAdditional: originalBlock.mainAdditional,
});
}
blockIndex += frameCount; // Skip the blocks we just added
blockIndex--;
}
}
async loadSegmentMetadata(segment) {
for (const seekEntry of segment.seekEntries) {
if (seekEntry.id === EBMLId.Tags && !segment.tagsSeen) {
// We need to load the tags
}
else if (seekEntry.id === EBMLId.Attachments && !segment.attachmentsSeen) {
// We need to load the attachments
}
else {
continue;
}
let slice = this.reader.requestSliceRange(segment.dataStartPos + seekEntry.segmentPosition, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (slice instanceof Promise)
slice = await slice;
if (!slice)
continue;
const header = readElementHeader(slice);
if (!header || header.id !== seekEntry.id)
continue;
const { size } = header;
assertDefinedSize(size);
assert(!this.currentSegment);
this.currentSegment = segment;
let dataSlice = this.reader.requestSlice(slice.filePos, size);
if (dataSlice instanceof Promise)
dataSlice = await dataSlice;
if (dataSlice) {
this.readContiguousElements(dataSlice);
}
this.currentSegment = null;
// Mark as seen
if (seekEntry.id === EBMLId.Tags) {
segment.tagsSeen = true;
}
else if (seekEntry.id === EBMLId.Attachments) {
segment.attachmentsSeen = true;
}
}
}
readContiguousElements(slice, stopIds) {
while (slice.remainingLength >= MIN_HEADER_SIZE) {
const startPos = slice.filePos;
const foundElement = this.traverseElement(slice, stopIds);
if (!foundElement) {
return startPos;
}
}
return slice.filePos;
}
traverseElement(slice, stopIds) {
const header = readElementHeader(slice);
if (!header) {
return false;
}
if (stopIds && stopIds.includes(header.id)) {
return false;
}
const { id, size } = header;
const dataStartPos = slice.filePos;
assertDefinedSize(size);
switch (id) {
case EBMLId.DocType:
{
this.isWebM = readAsciiString(slice, size) === 'webm';
}
;
break;
case EBMLId.Seek:
{
if (!this.currentSegment)
break;
const seekEntry = { id: -1, segmentPosition: -1 };
this.currentSegment.seekEntries.push(seekEntry);
this.readContiguousElements(slice.slice(dataStartPos, size));
if (seekEntry.id === -1 || seekEntry.segmentPosition === -1) {
this.currentSegment.seekEntries.pop();
}
}
;
break;
case EBMLId.SeekID:
{
const lastSeekEntry = this.currentSegment?.seekEntries[this.currentSegment.seekEntries.length - 1];
if (!lastSeekEntry)
break;
lastSeekEntry.id = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.SeekPosition:
{
const lastSeekEntry = this.currentSegment?.seekEntries[this.currentSegment.seekEntries.length - 1];
if (!lastSeekEntry)
break;
lastSeekEntry.segmentPosition = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.TimestampScale:
{
if (!this.currentSegment)
break;
this.currentSegment.timestampScale = readUnsignedInt(slice, size);
this.currentSegment.timestampFactor = 1e9 / this.currentSegment.timestampScale;
}
;
break;
case EBMLId.Duration:
{
if (!this.currentSegment)
break;
this.currentSegment.duration = readFloat(slice, size);
}
;
break;
case EBMLId.TrackEntry:
{
if (!this.currentSegment)
break;
this.currentTrack = {
id: -1,
segment: this.currentSegment,
demuxer: this,
clusterPositionCache: [],
cuePoints: [],
disposition: {
...DEFAULT_TRACK_DISPOSITION,
primary: false,
},
trackBacking: null,
codecId: null,
codecPrivate: null,
defaultDuration: null,
defaultDurationNs: null,
name: null,
languageCode: 'eng', // The default in Matroska
hasLanguageBcp47: false,
decodingInstructions: [],
info: null,
};
this.readContiguousElements(slice.slice(dataStartPos, size));
// Check if track was disabled during parsing (e.g., by FlagEnabled being 0)
if (!this.currentTrack) {
break;
}
if (this.currentTrack.decodingInstructions.some((instruction) => {
return instruction.data?.type !== 'decompress'
|| instruction.scope !== ContentEncodingScope.Block
|| instruction.data.algorithm !== ContentCompAlgo.HeaderStripping;
})) {
console.warn(`Track #${this.currentTrack.id} has an unsupported content encoding; dropping.`);
this.currentTrack = null;
}
if (this.currentTrack
&& this.currentTrack.id !== -1
&& this.currentTrack.codecId
&& this.currentTrack.info) {
const slashIndex = this.currentTrack.codecId.indexOf('/');
const codecIdWithoutSuffix = slashIndex === -1
? this.currentTrack.codecId
: this.currentTrack.codecId.slice(0, slashIndex);
if (this.currentTrack.info.type === 'video'
&& this.currentTrack.info.width !== -1
&& this.currentTrack.info.height !== -1) {
this.currentTrack.info.squarePixelWidth = this.currentTrack.info.width;
this.currentTrack.info.squarePixelHeight = this.currentTrack.info.height;
if (this.currentTrack.info.displayWidth !== null
&& this.currentTrack.info.displayHeight !== null) {
const num = this.currentTrack.info.displayWidth * this.currentTrack.info.height;
const den = this.currentTrack.info.displayHeight * this.currentTrack.info.width;
if (num > 0 && den > 0) {
if (num > den) {
this.currentTrack.info.squarePixelWidth = Math.round(this.currentTrack.info.width * num / den);
}
else {
this.currentTrack.info.squarePixelHeight = Math.round(this.currentTrack.info.height * den / num);
}
}
}
if (this.currentTrack.codecId === CODEC_STRING_MAP.avc) {
this.currentTrack.info.codec = 'avc';
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
}
else if (this.currentTrack.codecId === CODEC_STRING_MAP.hevc) {
this.currentTrack.info.codec = 'hevc';
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
}
else if (codecIdWithoutSuffix === CODEC_STRING_MAP.vp8) {
this.currentTrack.info.codec = 'vp8';
}
else if (codecIdWithoutSuffix === CODEC_STRING_MAP.vp9) {
this.currentTrack.info.codec = 'vp9';
}
else if (codecIdWithoutSuffix === CODEC_STRING_MAP.av1) {
this.currentTrack.info.codec = 'av1';
}
const videoTrack = this.currentTrack;
this.currentTrack.trackBacking = new MatroskaVideoTrackBacking(videoTrack);
this.currentSegment.tracks.push(this.currentTrack);
}
else if (this.currentTrack.info.type === 'audio') {
if (codecIdWithoutSuffix === CODEC_STRING_MAP.aac) {
this.currentTrack.info.codec = 'aac';
this.currentTrack.info.aacCodecInfo = {
isMpeg2: this.currentTrack.codecId.includes('MPEG2'),
objectType: null,
};
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
}
else if (this.currentTrack.codecId === CODEC_STRING_MAP.mp3) {
this.currentTrack.info.codec = 'mp3';
}
else if (codecIdWithoutSuffix === CODEC_STRING_MAP.opus) {
this.currentTrack.info.codec = 'opus';
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
this.currentTrack.info.sampleRate = OPUS_SAMPLE_RATE; // Always the same
}
else if (codecIdWithoutSuffix === CODEC_STRING_MAP.vorbis) {
this.currentTrack.info.codec = 'vorbis';
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
}
else if (codecIdWithoutSuffix === CODEC_STRING_MAP.flac) {
this.currentTrack.info.codec = 'flac';
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
}
else if (codecIdWithoutSuffix === CODEC_STRING_MAP.ac3) {
this.currentTrack.info.codec = 'ac3';
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
}
else if (codecIdWithoutSuffix === CODEC_STRING_MAP.eac3) {
this.currentTrack.info.codec = 'eac3';
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
}
else if (this.currentTrack.codecId === 'A_PCM/INT/LIT') {
if (this.currentTrack.info.bitDepth === 8) {
this.currentTrack.info.codec = 'pcm-u8';
}
else if (this.currentTrack.info.bitDepth === 16) {
this.currentTrack.info.codec = 'pcm-s16';
}
else if (this.currentTrack.info.bitDepth === 24) {
this.currentTrack.info.codec = 'pcm-s24';
}
else if (this.currentTrack.info.bitDepth === 32) {
this.currentTrack.info.codec = 'pcm-s32';
}
}
else if (this.currentTrack.codecId === 'A_PCM/INT/BIG') {
if (this.currentTrack.info.bitDepth === 8) {
this.currentTrack.info.codec = 'pcm-u8';
}
else if (this.currentTrack.info.bitDepth === 16) {
this.currentTrack.info.codec = 'pcm-s16be';
}
else if (this.currentTrack.info.bitDepth === 24) {
this.currentTrack.info.codec = 'pcm-s24be';
}
else if (this.currentTrack.info.bitDepth === 32) {
this.currentTrack.info.codec = 'pcm-s32be';
}
}
else if (this.currentTrack.codecId === 'A_PCM/FLOAT/IEEE') {
if (this.currentTrack.info.bitDepth === 32) {
this.currentTrack.info.codec = 'pcm-f32';
}
else if (this.currentTrack.info.bitDepth === 64) {
this.currentTrack.info.codec = 'pcm-f64';
}
}
const audioTrack = this.currentTrack;
this.currentTrack.trackBacking = new MatroskaAudioTrackBacking(audioTrack);
this.currentSegment.tracks.push(this.currentTrack);
}
}
this.currentTrack = null;
}
;
break;
case EBMLId.TrackNumber:
{
if (!this.currentTrack)
break;
this.currentTrack.id = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.TrackType:
{
if (!this.currentTrack)
break;
const type = readUnsignedInt(slice, size);
if (type === 1) {
this.currentTrack.info = {
type: 'video',
width: -1,
height: -1,
displayWidth: null,
displayHeight: null,
displayUnit: null,
squarePixelWidth: -1,
squarePixelHeight: -1,
rotation: 0,
codec: null,
codecDescription: null,
colorSpace: null,
alphaMode: false,
};
}
else if (type === 2) {
this.currentTrack.info = {
type: 'audio',
numberOfChannels: 1, // Default value
sampleRate: 8000, // Default value
bitDepth: -1,
codec: null,
codecDescription: null,
aacCodecInfo: null,
};
}
}
;
break;
case EBMLId.FlagEnabled:
{
if (!this.currentTrack)
break;
const enabled = readUnsignedInt(slice, size);
if (!enabled) {
this.currentTrack = null;
}
}
;
break;
case EBMLId.FlagDefault:
{
if (!this.currentTrack)
break;
this.currentTrack.disposition.default = !!readUnsignedInt(slice, size);
}
;
break;
case EBMLId.FlagForced:
{
if (!this.currentTrack)
break;
this.currentTrack.disposition.forced = !!readUnsignedInt(slice, size);
}
;
break;
case EBMLId.FlagOriginal:
{
if (!this.currentTrack)
break;
this.currentTrack.disposition.original = !!readUnsignedInt(slice, size);
}
;
break;
case EBMLId.FlagHearingImpaired:
{
if (!this.currentTrack)
break;
this.currentTrack.disposition.hearingImpaired = !!readUnsignedInt(slice, size);
}
;
break;
case EBMLId.FlagVisualImpaired:
{
if (!this.currentTrack)
break;
this.currentTrack.disposition.visuallyImpaired = !!readUnsignedInt(slice, size);
}
;
break;
case EBMLId.FlagCommentary:
{
if (!this.currentTrack)
break;
this.currentTrack.disposition.commentary = !!readUnsignedInt(slice, size);
}
;
break;
case EBMLId.CodecID:
{
if (!this.currentTrack)
break;
this.currentTrack.codecId = readAsciiString(slice, size);
}
;
break;
case EBMLId.CodecPrivate:
{
if (!this.currentTrack)
break;
this.currentTrack.codecPrivate = readBytes(slice, size);
}
;
break;
case EBMLId.DefaultDuration:
{
if (!this.currentTrack)
break;
this.currentTrack.defaultDurationNs = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.Name:
{
if (!this.currentTrack)
break;
this.currentTrack.name = readUnicodeString(slice, size);
}
;
break;
case EBMLId.Language:
{
if (!this.currentTrack)
break;
if (this.currentTrack.hasLanguageBcp47) {
// LanguageBCP47 was present, which takes precedence
break;
}
this.currentTrack.languageCode = readAsciiString(slice, size);
if (!isIso639Dash2LanguageCode(this.currentTrack.languageCode)) {
this.currentTrack.languageCode = UNDETERMINED_LANGUAGE;
}
}
;
break;
case EBMLId.LanguageBCP47:
{
if (!this.currentTrack)
break;
const bcp47 = readAsciiString(slice, size);
const languageSubtag = bcp47.split('-')[0];
if (languageSubtag) {
// Technically invalid, for now: The language subtag might be a language code from ISO 639-1,
// ISO 639-2, ISO 639-3, ISO 639-5 or some other thing (source: Wikipedia). But, `languageCode` is
// documented as ISO 639-2. Changing the definition would be a breaking change. This will get
// cleaned up in the future by defining languageCode to be BCP 47 instead.
this.currentTrack.languageCode = languageSubtag;
}
else {
this.currentTrack.languageCode = UNDETERMINED_LANGUAGE;
}
this.currentTrack.hasLanguageBcp47 = true;
}
;
break;
case EBMLId.Video:
{
if (this.currentTrack?.info?.type !== 'video')
break;
this.readContiguousElements(slice.slice(dataStartPos, size));
}
;
break;
case EBMLId.PixelWidth:
{
if (this.currentTrack?.info?.type !== 'video')
break;
this.currentTrack.info.width = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.PixelHeight:
{
if (this.currentTrack?.info?.type !== 'video')
break;
this.currentTrack.info.height = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.DisplayWidth:
{
if (this.currentTrack?.info?.type !== 'video')
break;
this.currentTrack.info.displayWidth = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.DisplayHeight:
{
if (this.currentTrack?.info?.type !== 'video')
break;
this.currentTrack.info.displayHeight = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.DisplayUnit:
{
if (this.currentTrack?.info?.type !== 'video')
break;
this.currentTrack.info.displayUnit = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.AlphaMode:
{
if (this.currentTrack?.info?.type !== 'video')
break;
this.currentTrack.info.alphaMode = readUnsignedInt(slice, size) === 1;
}
;
break;
case EBMLId.Colour:
{
if (this.currentTrack?.info?.type !== 'video')
break;
this.currentTrack.info.colorSpace = {};
this.readContiguousElements(slice.slice(dataStartPos, size));
}
;
break;
case EBMLId.MatrixCoefficients:
{
if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace)
break;
const matrixCoefficients = readUnsignedInt(slice, size);
const mapped = MATRIX_COEFFICIENTS_MAP_INVERSE[matrixCoefficients] ?? null;
this.currentTrack.info.colorSpace.matrix = mapped;
}
;
break;
case EBMLId.Range:
{
if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace)
break;
this.currentTrack.info.colorSpace.fullRange = readUnsignedInt(slice, size) === 2;
}
;
break;
case EBMLId.TransferCharacteristics:
{
if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace)
break;
const transferCharacteristics = readUnsignedInt(slice, size);
const mapped = TRANSFER_CHARACTERISTICS_MAP_INVERSE[transferCharacteristics] ?? null;
this.currentTrack.info.colorSpace.transfer = mapped;
}
;
break;
case EBMLId.Primaries:
{
if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace)
break;
const primaries = readUnsignedInt(slice, size);
const mapped = COLOR_PRIMARIES_MAP_INVERSE[primaries] ?? null;
this.currentTrack.info.colorSpace.primaries = mapped;
}
;
break;
case EBMLId.Projection:
{
if (this.currentTrack?.info?.type !== 'video')
break;
this.readContiguousElements(slice.slice(dataStartPos, size));
}
;
break;
case EBMLId.ProjectionPoseRoll:
{
if (this.currentTrack?.info?.type !== 'video')
break;
const rotation = readFloat(slice, size);
const flippedRotation = -rotation; // Convert counter-clockwise to clockwise
try {
this.currentTrack.info.rotation = normalizeRotation(flippedRotation);
}
catch {
// It wasn't a valid rotation
}
}
;
break;
case EBMLId.Audio:
{
if (this.currentTrack?.info?.type !== 'audio')
break;
this.readContiguousElements(slice.slice(dataStartPos, size));
}
;
break;
case EBMLId.SamplingFrequency:
{
if (this.currentTrack?.info?.type !== 'audio')
break;
this.currentTrack.info.sampleRate = readFloat(slice, size);
}
;
break;
case EBMLId.Channels:
{
if (this.currentTrack?.info?.type !== 'audio')
break;
this.currentTrack.info.numberOfChannels = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.BitDepth:
{
if (this.currentTrack?.info?.type !== 'audio')
break;
this.currentTrack.info.bitDepth = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.CuePoint:
{
if (!this.currentSegment)
break;
this.readContiguousElements(slice.slice(dataStartPos, size));
this.currentCueTime = null;
}
;
break;
case EBMLId.CueTime:
{
this.currentCueTime = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.CueTrackPositions:
{
if (this.currentCueTime === null)
break;
assert(this.currentSegment);
const cuePoint = { time: this.currentCueTime, trackId: -1, clusterPosition: -1 };
this.currentSegment.cuePoints.push(cuePoint);
this.readContiguousElements(slice.slice(dataStartPos, size));
if (cuePoint.trackId === -1 || cuePoint.clusterPosition === -1) {
this.currentSegment.cuePoints.pop();
}
}
;
break;
case EBMLId.CueTrack:
{
const lastCuePoint = this.currentSegment?.cuePoints[this.currentSegment.cuePoints.length - 1];
if (!lastCuePoint)
break;
lastCuePoint.trackId = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.CueClusterPosition:
{
const lastCuePoint = this.currentSegment?.cuePoints[this.currentSegment.cuePoints.length - 1];
if (!lastCuePoint)
break;
assert(this.currentSegment);
lastCuePoint.clusterPosition = this.currentSegment.dataStartPos + readUnsignedInt(slice, size);
}
;
break;
case EBMLId.Timestamp:
{
if (!this.currentCluster)
break;
this.currentCluster.timestamp = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.SimpleBlock:
{
if (!this.currentCluster)
break;
const trackNumber = readVarInt(slice);
if (trackNumber === null)
break;
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
if (!trackData)
break; // Not a track we care about
const relativeTimestamp = readI16Be(slice);
const flags = readU8(slice);
const lacing = (flags >> 1) & 0x3; // If the block is laced, we'll expand it later
let isKeyFrame = !!(flags & 0x80);
if (trackData.track.info?.type === 'audio' && trackData.track.info.codec) {
// Some files don't mark their audio packets as key packets (I'm looking at you, Firefox). But, we
// can fix this in most cases: if we recognize the codec of the track, then we know every packet is
// necessarily a key packet, no matter what the container says.
// https://github.com/Vanilagy/mediabunny/issues/192
isKeyFrame = true;
}
const blockData = readBytes(slice, size - (slice.filePos - dataStartPos));
const hasDecodingInstructions = trackData.track.decodingInstructions.length > 0;
trackData.blocks.push({
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
duration: 0, // Will set later
isKeyFrame,
data: blockData,
lacing,
decoded: !hasDecodingInstructions,
mainAdditional: null,
});
}
;
break;
case EBMLId.BlockGroup:
{
if (!this.currentCluster)
break;
this.readContiguousElements(slice.slice(dataStartPos, size));
this.currentBlock = null;
}
;
break;
case EBMLId.Block:
{
if (!this.currentCluster)
break;
const trackNumber = readVarInt(slice);
if (trackNumber === null)
break;
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
if (!trackData)
break;
const relativeTimestamp = readI16Be(slice);
const flags = readU8(slice);
const lacing = (flags >> 1) & 0x3; // If the block is laced, we'll expand it later
const blockData = readBytes(slice, size - (slice.filePos - dataStartPos));
const hasDecodingInstructions = trackData.track.decodingInstructions.length > 0;
this.currentBlock = {
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
duration: 0, // Will set later
isKeyFrame: true,
data: blockData,
lacing,
decoded: !hasDecodingInstructions,
mainAdditional: null,
};
trackData.blocks.push(this.currentBlock);
}
;
break;
case EBMLId.BlockAdditions:
{
this.readContiguousElements(slice.slice(dataStartPos, size));
}
;
break;
case EBMLId.BlockMore:
{
if (!this.currentBlock)
break;
this.currentBlockAdditional = {
addId: 1,
data: null,
};
this.readContiguousElements(slice.slice(dataStartPos, size));
if (this.currentBlockAdditional.data && this.currentBlockAdditional.addId === 1) {
this.currentBlock.mainAdditional = this.currentBlockAdditional.data;
}
this.currentBlockAdditional = null;
}
;
break;
case EBMLId.BlockAdditional:
{
if (!this.currentBlockAdditional)
break;
this.currentBlockAdditional.data = readBytes(slice, size);
}
;
break;
case EBMLId.BlockAddID:
{
if (!this.currentBlockAdditional)
break;
this.currentBlockAdditional.addId = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.BlockDuration:
{
if (!this.currentBlock)
break;
this.currentBlock.duration = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.ReferenceBlock:
{
if (!this.currentBlock)
break;
this.currentBlock.isKeyFrame = false;
// We ignore the actual value here, we just use the reference as an indicator for "not a key frame".
// This is in line with FFmpeg's behavior.
}
;
break;
case EBMLId.Tag:
{
this.currentTagTargetIsMovie = true;
this.readContiguousElements(slice.slice(dataStartPos, size));
}
;
break;
case EBMLId.Targets:
{
this.readContiguousElements(slice.slice(dataStartPos, size));
}
;
break;
case EBMLId.TargetTypeValue:
{
const targetTypeValue = readUnsignedInt(slice, size);
if (targetTypeValue !== 50) {
this.currentTagTargetIsMovie = false;
}
}
;
break;
case EBMLId.TagTrackUID:
case EBMLId.TagEditionUID:
case EBMLId.TagChapterUID:
case EBMLId.TagAttachmentUID:
{
this.currentTagTargetIsMovie = false;
}
;
break;
case EBMLId.SimpleTag:
{
if (!this.currentTagTargetIsMovie)
break;
this.currentSimpleTagName = null;
this.readContiguousElements(slice.slice(dataStartPos, size));
}
;
break;
case EBMLId.TagName:
{
this.currentSimpleTagName = readUnicodeString(slice, size);
}
;
break;
case EBMLId.TagString:
{
if (!this.currentSimpleTagName)
break;
const value = readUnicodeString(slice, size);
this.processTagValue(this.currentSimpleTagName, value);
}
;
break;
case EBMLId.TagBinary:
{
if (!this.currentSimpleTagName)
break;
const value = readBytes(slice, size);
this.processTagValue(this.currentSimpleTagName, value);
}
;
break;
case EBMLId.AttachedFile:
{
if (!this.currentSegment)
break;
this.currentAttachedFile = {
fileUid: null,
fileName: null,
fileMediaType: null,
fileData: null,
fileDescription: null,
};
this.readContiguousElements(slice.slice(dataStartPos, size));
const tags = this.currentSegment.metadataTags;
if (this.currentAttachedFile.fileUid && this.currentAttachedFile.fileData) {
// All attached files get surfaced in the `raw` metadata tags
tags.raw ??= {};
tags.raw[this.currentAttachedFile.fileUid.toString()] = new AttachedFile(this.currentAttachedFile.fileData, this.currentAttachedFile.fileMediaType ?? undefined, this.currentAttachedFile.fileName ?? undefined, this.currentAttachedFile.fileDescription ?? undefined);
}
// Only process image attachments
if (this.currentAttachedFile.fileMediaType?.startsWith('image/') && this.currentAttachedFile.fileData) {
const fileName = this.currentAttachedFile.fileName;
let kind = 'unknown';
if (fileName) {
const lowerName = fileName.toLowerCase();
if (lowerName.startsWith('cover.')) {
kind = 'coverFront';
}
else if (lowerName.startsWith('back.')) {
kind = 'coverBack';
}
}
tags.images ??= [];
tags.images.push({
data: this.currentAttachedFile.fileData,
mimeType: this.currentAttachedFile.fileMediaType,
kind,
name: this.currentAttachedFile.fileName ?? undefined,
description: this.currentAttachedFile.fileDescription ?? undefined,
});
}
this.currentAttachedFile = null;
}
;
break;
case EBMLId.FileUID:
{
if (!this.currentAttachedFile)
break;
this.currentAttachedFile.fileUid = readUnsignedBigInt(slice, size);
}
;
break;
case EBMLId.FileName:
{
if (!this.currentAttachedFile)
break;
this.currentAttachedFile.fileName = readUnicodeString(slice, size);
}
;
break;
case EBMLId.FileMediaType:
{
if (!this.currentAttachedFile)
break;
this.currentAttachedFile.fileMediaType = readAsciiString(slice, size);
}
;
break;
case EBMLId.FileData:
{
if (!this.currentAttachedFile)
break;
this.currentAttachedFile.fileData = readBytes(slice, size);
}
;
break;
case EBMLId.FileDescription:
{
if (!this.currentAttachedFile)
break;
this.currentAttachedFile.fileDescription = readUnicodeString(slice, size);
}
;
break;
case EBMLId.ContentEncodings:
{
if (!this.currentTrack)
break;
this.readContiguousElements(slice.slice(dataStartPos, size));
// "**MUST** start with the `ContentEncoding` with the highest `ContentEncodingOrder`"
this.currentTrack.decodingInstructions.sort((a, b) => b.order - a.order);
}
;
break;
case EBMLId.ContentEncoding:
{
this.currentDecodingInstruction = {
order: 0,
scope: ContentEncodingScope.Block,
data: null,
};
this.readContiguousElements(slice.slice(dataStartPos, size));
if (this.currentDecodingInstruction.data) {
this.currentTrack.decodingInstructions.push(this.currentDecodingInstruction);
}
this.currentDecodingInstruction = null;
}
;
break;
case EBMLId.ContentEncodingOrder:
{
if (!this.currentDecodingInstruction)
break;
this.currentDecodingInstruction.order = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.ContentEncodingScope:
{
if (!this.currentDecodingInstruction)
break;
this.currentDecodingInstruction.scope = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.ContentCompression:
{
if (!this.currentDecodingInstruction)
break;
this.currentDecodingInstruction.data = {
type: 'decompress',
algorithm: ContentCompAlgo.Zlib,
settings: null,
};
this.readContiguousElements(slice.slice(dataStartPos, size));
}
;
break;
case EBMLId.ContentCompAlgo:
{
if (this.currentDecodingInstruction?.data?.type !== 'decompress')
break;
this.currentDecodingInstruction.data.algorithm = readUnsignedInt(slice, size);
}
;
break;
case EBMLId.ContentCompSettings:
{
if (this.currentDecodingInstruction?.data?.type !== 'decompress')
break;
this.currentDecodingInstruction.data.settings = readBytes(slice, size);
}
;
break;
case EBMLId.ContentEncryption:
{
if (!this.currentDecodingInstruction)
break;
this.currentDecodingInstruction.data = {
type: 'decrypt',
};
}
;
break;
}
slice.filePos = dataStartPos + size;
return true;
}
decodeBlockData(track, rawData) {
assert(track.decodingInstructions.length > 0); // This method shouldn't be called otherwise
let currentData = rawData;
for (const instruction of track.decodingInstructions) {
assert(instruction.data);
switch (instruction.data.type) {
case 'decompress':
{
switch (instruction.data.algorithm) {
case ContentCompAlgo.HeaderStripping:
{
if (instruction.data.settings && instruction.data.settings.length > 0) {
const prefix = instruction.data.settings;
const newData = new Uint8Array(prefix.length + currentData.length);
newData.set(prefix, 0);
newData.set(currentData, prefix.length);
currentData = newData;
}
}
;
break;
default:
{
// Unhandled
}
;
}
}
;
break;
default:
{
// Unhandled
}
;
}
}
return currentData;
}
processTagValue(name, value) {
if (!this.currentSegment?.metadataTags)
return;
const metadataTags = this.currentSegment.metadataTags;
metadataTags.raw ??= {};
metadataTags.raw[name] ??= value;
if (typeof value === 'string') {
switch (name.toLowerCase()) {
case 'title':
{
metadataTags.title ??= value;
}
;
break;
case 'description':
{
metadataTags.description ??= value;
}
;
break;
case 'artist':
{
metadataTags.artist ??= value;
}
;
break;
case 'album':
{
metadataTags.album ??= value;
}
;
break;
case 'album_artist':
{
metadataTags.albumArtist ??= value;
}
;
break;
case 'genre':
{
metadataTags.genre ??= value;
}
;
break;
case 'comment':
{
metadataTags.comment ??= value;
}
;
break;
case 'lyrics':
{
metadataTags.lyrics ??= value;
}
;
break;
case 'date':
{
const date = new Date(value);
if (!Number.isNaN(date.getTime())) {
metadataTags.date ??= date;
}
}
;
break;
case 'track_number':
case 'part_number':
{
const parts = value.split('/');
const trackNum = Number.parseInt(parts[0], 10);
const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);
if (Number.isInteger(trackNum) && trackNum > 0) {
metadataTags.trackNumber ??= trackNum;
}
if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {
metadataTags.tracksTotal ??= tracksTotal;
}
}
;
break;
case 'disc_number':
case 'disc':
{
const discParts = value.split('/');
const discNum = Number.parseInt(discParts[0], 10);
const discsTotal = discParts[1] && Number.parseInt(discParts[1], 10);
if (Number.isInteger(discNum) && discNum > 0) {
metadataTags.discNumber ??= discNum;
}
if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) {
metadataTags.discsTotal ??= discsTotal;
}
}
;
break;
}
}
}
}
class MatroskaTrackBacking {
constructor(internalTrack) {
this.internalTrack = internalTrack;
this.packetToClusterLocation = new WeakMap();
}
getId() {
return this.internalTrack.id;
}
getNumber() {
const demuxer = this.internalTrack.demuxer;
const trackType = this.internalTrack.trackBacking.getType();
let number = 0;
for (const segment of demuxer.segments) {
for (const track of segment.tracks) {
if (track.trackBacking.getType() === trackType) {
number++;
}
if (track === this.internalTrack) {
break;
}
}
}
return number;
}
getCodec() {
throw new Error('Not implemented on base class.');
}
getInternalCodecId() {
return this.internalTrack.codecId;
}
getName() {
return this.internalTrack.name;
}
getLanguageCode() {
return this.internalTrack.languageCode;
}
getTimeResolution() {
return this.internalTrack.segment.timestampFactor;
}
isRelativeToUnixEpoch() {
return false;
}
getDisposition() {
return this.internalTrack.disposition;
}
getPairingMask() {
return 1n;
}
getBitrate() {
return null;
}
getAverageBitrate() {
return null;
}
async getDurationFromMetadata() {
const segment = this.internalTrack.segment;
if (segment.duration <= 0) {
return null;
}
let endTimestamp = segment.duration / segment.timestampFactor;
const firstPacket = await this.getFirstPacket({ metadataOnly: true });
endTimestamp += firstPacket?.timestamp ?? 0;
return endTimestamp;
}
async getLiveRefreshInterval() {
return null;
}
async getFirstPacket(options) {
return this.performClusterLookup(null, (cluster) => {
const trackData = cluster.trackData.get(this.internalTrack.id);
if (trackData) {
return {
blockIndex: 0,
correctBlockFound: true,
};
}
return {
blockIndex: -1,
correctBlockFound: false,
};
}, -Infinity, // Use -Infinity as a search timestamp to avoid using the cues
Infinity, options);
}
intoTimescale(timestamp) {
// Do a little rounding to catch cases where the result is very close to an integer. If it is, it's likely
// that the number was originally an integer divided by the timescale. For stability, it's best
// to return the integer in this case.
return roundIfAlmostInteger(timestamp * this.internalTrack.segment.timestampFactor);
}
async getPacket(timestamp, options) {
const timestampInTimescale = this.intoTimescale(timestamp);
return this.performClusterLookup(null, (cluster) => {
const trackData = cluster.trackData.get(this.internalTrack.id);
if (!trackData) {
return { blockIndex: -1, correctBlockFound: false };
}
const index = binarySearchLessOrEqual(trackData.presentationTimestamps, timestampInTimescale, x => x.timestamp);
const blockIndex = index !== -1 ? trackData.presentationTimestamps[index].blockIndex : -1;
const correctBlockFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
return { blockIndex, correctBlockFound };
}, timestampInTimescale, timestampInTimescale, options);
}
async getNextPacket(packet, options) {
const locationInCluster = this.packetToClusterLocation.get(packet);
if (locationInCluster === undefined) {
throw new Error('Packet was not created from this track.');
}
return this.performClusterLookup(locationInCluster.cluster, (cluster) => {
if (cluster === locationInCluster.cluster) {
const trackData = cluster.trackData.get(this.internalTrack.id);
if (locationInCluster.blockIndex + 1 < trackData.blocks.length) {
// We can simply take the next block in the cluster
return {
blockIndex: locationInCluster.blockIndex + 1,
correctBlockFound: true,
};
}
}
else {
const trackData = cluster.trackData.get(this.internalTrack.id);
if (trackData) {
return {
blockIndex: 0,
correctBlockFound: true,
};
}
}
return {
blockIndex: -1,
correctBlockFound: false,
};
}, -Infinity, // Use -Infinity as a search timestamp to avoid using the cues
Infinity, options);
}
async getKeyPacket(timestamp, options) {
const timestampInTimescale = this.intoTimescale(timestamp);
return this.performClusterLookup(null, (cluster) => {
const trackData = cluster.trackData.get(this.internalTrack.id);
if (!trackData) {
return { blockIndex: -1, correctBlockFound: false };
}
const index = findLastIndex(trackData.presentationTimestamps, (x) => {
const block = trackData.blocks[x.blockIndex];
return block.isKeyFrame && x.timestamp <= timestampInTimescale;
});
const blockIndex = index !== -1 ? trackData.presentationTimestamps[index].blockIndex : -1;
const correctBlockFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
return { blockIndex, correctBlockFound };
}, timestampInTimescale, timestampInTimescale, options);
}
async getNextKeyPacket(packet, options) {
const locationInCluster = this.packetToClusterLocation.get(packet);
if (locationInCluster === undefined) {
throw new Error('Packet was not created from this track.');
}
return this.performClusterLookup(locationInCluster.cluster, (cluster) => {
if (cluster === locationInCluster.cluster) {
const trackData = cluster.trackData.get(this.internalTrack.id);
const nextKeyFrameIndex = trackData.blocks.findIndex((x, i) => x.isKeyFrame && i > locationInCluster.blockIndex);
if (nextKeyFrameIndex !== -1) {
// We can simply take the next key frame in the cluster
return {
blockIndex: nextKeyFrameIndex,
correctBlockFound: true,
};
}
}
else {
const trackData = cluster.trackData.get(this.internalTrack.id);
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
const keyFrameIndex = trackData.blocks.findIndex(x => x.isKeyFrame);
assert(keyFrameIndex !== -1); // There must be one
return {
blockIndex: keyFrameIndex,
correctBlockFound: true,
};
}
}
return {
blockIndex: -1,
correctBlockFound: false,
};
}, -Infinity, // Use -Infinity as a search timestamp to avoid using the cues
Infinity, options);
}
async fetchPacketInCluster(cluster, blockIndex, options) {
if (blockIndex === -1) {
return null;
}
const trackData = cluster.trackData.get(this.internalTrack.id);
const block = trackData.blocks[blockIndex];
assert(block);
// Perform lazy decoding if needed
if (!block.decoded) {
block.data = this.internalTrack.demuxer.decodeBlockData(this.internalTrack, block.data);
block.decoded = true;
}
const data = options.metadataOnly ? PLACEHOLDER_DATA : block.data;
const timestamp = block.timestamp / this.internalTrack.segment.timestampFactor;
const duration = block.duration / this.internalTrack.segment.timestampFactor;
const sideData = {};
if (block.mainAdditional && this.internalTrack.info?.type === 'video' && this.internalTrack.info.alphaMode) {
sideData.alpha = options.metadataOnly ? PLACEHOLDER_DATA : block.mainAdditional;
sideData.alphaByteLength = block.mainAdditional.byteLength;
}
const packet = new EncodedPacket(data, block.isKeyFrame ? 'key' : 'delta', timestamp, duration, cluster.dataStartPos + blockIndex, block.data.byteLength, sideData);
this.packetToClusterLocation.set(packet, { cluster, blockIndex });
return packet;
}
/** Looks for a packet in the clusters while trying to load as few clusters as possible to retrieve it. */
async performClusterLookup(
// The cluster where we start looking
startCluster,
// This function returns the best-matching block in a given cluster
getMatchInCluster,
// The timestamp with which we can search the lookup table
searchTimestamp,
// The timestamp for which we know the correct block will not come after it
latestTimestamp, options) {
const { demuxer, segment } = this.internalTrack;
let currentCluster = null;
let bestCluster = null;
let bestBlockIndex = -1;
if (startCluster) {
const { blockIndex, correctBlockFound } = getMatchInCluster(startCluster);
if (correctBlockFound) {
return this.fetchPacketInCluster(startCluster, blockIndex, options);
}
if (blockIndex !== -1) {
bestCluster = startCluster;
bestBlockIndex = blockIndex;
}
}
// Search for a cue point; this way, we won't need to start searching from the start of the file
// but can jump right into the correct cluster (or at least nearby).
const cuePointIndex = binarySearchLessOrEqual(this.internalTrack.cuePoints, searchTimestamp, x => x.time);
const cuePoint = cuePointIndex !== -1
? this.internalTrack.cuePoints[cuePointIndex]
: null;
// Also check the position cache
const positionCacheIndex = binarySearchLessOrEqual(this.internalTrack.clusterPositionCache, searchTimestamp, x => x.startTimestamp);
const positionCacheEntry = positionCacheIndex !== -1
? this.internalTrack.clusterPositionCache[positionCacheIndex]
: null;
const lookupEntryPosition = Math.max(cuePoint?.clusterPosition ?? 0, positionCacheEntry?.elementStartPos ?? 0) || null;
let currentPos;
if (!startCluster) {
currentPos = lookupEntryPosition ?? segment.clusterSeekStartPos;
}
else {
if (lookupEntryPosition === null || startCluster.elementStartPos >= lookupEntryPosition) {
currentPos = startCluster.elementEndPos;
currentCluster = startCluster;
}
else {
// Use the lookup entry
currentPos = lookupEntryPosition;
}
}
while (segment.elementEndPos === null || currentPos <= segment.elementEndPos - MIN_HEADER_SIZE) {
if (currentCluster) {
const trackData = currentCluster.trackData.get(this.internalTrack.id);
if (trackData && trackData.startTimestamp > latestTimestamp) {
// We're already past the upper bound, no need to keep searching
break;
}
}
// Load the header
let slice = demuxer.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (slice instanceof Promise)
slice = await slice;
if (!slice)
break;
const elementStartPos = currentPos;
const elementHeader = readElementHeader(slice);
if (!elementHeader
|| (!LEVEL_1_EBML_IDS.includes(elementHeader.id) && elementHeader.id !== EBMLId.Void)) {
// There's an element here that shouldn't be here. Might be garbage. In this case, let's
// try and resync to the next valid element.
const nextPos = await resync(demuxer.reader, elementStartPos, LEVEL_1_EBML_IDS, Math.min(segment.elementEndPos ?? Infinity, elementStartPos + MAX_RESYNC_LENGTH));
if (nextPos) {
currentPos = nextPos;
continue;
}
else {
break; // Resync failed
}
}
const id = elementHeader.id;
let size = elementHeader.size;
const dataStartPos = slice.filePos;
if (id === EBMLId.Cluster) {
currentCluster = await demuxer.readCluster(elementStartPos, segment);
// readCluster computes the proper size even if it's undefined in the header, so let's use that instead
size = currentCluster.elementEndPos - dataStartPos;
const { blockIndex, correctBlockFound } = getMatchInCluster(currentCluster);
if (correctBlockFound) {
return this.fetchPacketInCluster(currentCluster, blockIndex, options);
}
if (blockIndex !== -1) {
bestCluster = currentCluster;
bestBlockIndex = blockIndex;
}
}
if (size === undefined) {
// Undefined element size (can happen in livestreamed files). In this case, we need to do some
// searching to determine the actual size of the element.
assert(id !== EBMLId.Cluster); // Undefined cluster sizes are fixed further up
// Search for the next element at level 0 or 1
const nextElementPos = await searchForNextElementId(demuxer.reader, dataStartPos, LEVEL_0_AND_1_EBML_IDS, segment.elementEndPos);
size = nextElementPos.pos - dataStartPos;
}
const endPos = dataStartPos + size;
if (segment.elementEndPos === null) {
// Check the next element. If it's a new segment, we know this segment ends here. The new
// segment is just ignored, since we're likely in a livestreamed file and thus only care about
// the first segment.
let slice = demuxer.reader.requestSliceRange(endPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (slice instanceof Promise)
slice = await slice;
if (!slice)
break;
const elementId = readElementId(slice);
if (elementId === EBMLId.Segment) {
segment.elementEndPos = endPos; // We now know the segment's size
break;
}
}
currentPos = endPos;
}
// Catch faulty cue points
if (cuePoint && (!bestCluster || bestCluster.elementStartPos < cuePoint.clusterPosition)) {
// The cue point lied to us! We found a cue point but no cluster there that satisfied the match. In this
// case, let's search again but using the cue point before that.
const previousCuePoint = this.internalTrack.cuePoints[cuePointIndex - 1];
assert(!previousCuePoint || previousCuePoint.time < cuePoint.time);
const newSearchTimestamp = previousCuePoint?.time ?? -Infinity;
return this.performClusterLookup(null, getMatchInCluster, newSearchTimestamp, latestTimestamp, options);
}
if (bestCluster) {
// If we finished looping but didn't find a perfect match, still return the best match we found
return this.fetchPacketInCluster(bestCluster, bestBlockIndex, options);
}
return null;
}
}
class MatroskaVideoTrackBacking extends MatroskaTrackBacking {
constructor(internalTrack) {
super(internalTrack);
this.decoderConfigPromise = null;
this.internalTrack = internalTrack;
}
getType() {
return 'video';
}
getCodec() {
return this.internalTrack.info.codec;
}
getCodedWidth() {
return this.internalTrack.info.width;
}
getCodedHeight() {
return this.internalTrack.info.height;
}
getSquarePixelWidth() {
return this.internalTrack.info.squarePixelWidth;
}
getSquarePixelHeight() {
return this.internalTrack.info.squarePixelHeight;
}
getRotation() {
return this.internalTrack.info.rotation;
}
async getColorSpace() {
return {
primaries: this.internalTrack.info.colorSpace?.primaries,
transfer: this.internalTrack.info.colorSpace?.transfer,
matrix: this.internalTrack.info.colorSpace?.matrix,
fullRange: this.internalTrack.info.colorSpace?.fullRange,
};
}
async canBeTransparent() {
return this.internalTrack.info.alphaMode;
}
async getDecoderConfig() {
if (!this.internalTrack.info.codec) {
return null;
}
return this.decoderConfigPromise ??= (async () => {
let firstPacket = null;
const needsPacketForAdditionalInfo = this.internalTrack.info.codec === 'vp9'
|| this.internalTrack.info.codec === 'av1'
// Packets are in Annex B format:
|| (this.internalTrack.info.codec === 'avc' && !this.internalTrack.info.codecDescription)
// Packets are in Annex B format:
|| (this.internalTrack.info.codec === 'hevc' && !this.internalTrack.info.codecDescription);
if (needsPacketForAdditionalInfo) {
firstPacket = await this.getFirstPacket({});
}
const config = {
codec: extractVideoCodecString({
width: this.internalTrack.info.width,
height: this.internalTrack.info.height,
codec: this.internalTrack.info.codec,
codecDescription: this.internalTrack.info.codecDescription,
colorSpace: this.internalTrack.info.colorSpace,
avcType: 1, // We don't know better (or do we?) so just assume 'avc1'
avcCodecInfo: this.internalTrack.info.codec === 'avc' && firstPacket
? extractAvcDecoderConfigurationRecord(firstPacket.data)
: null,
hevcCodecInfo: this.internalTrack.info.codec === 'hevc' && firstPacket
? extractHevcDecoderConfigurationRecord(firstPacket.data)
: null,
vp9CodecInfo: this.internalTrack.info.codec === 'vp9' && firstPacket
? extractVp9CodecInfoFromPacket(firstPacket.data)
: null,
av1CodecInfo: this.internalTrack.info.codec === 'av1' && firstPacket
? extractAv1CodecInfoFromPacket(firstPacket.data)
: null,
}),
codedWidth: this.internalTrack.info.width,
codedHeight: this.internalTrack.info.height,
description: this.internalTrack.info.codecDescription ?? undefined,
colorSpace: this.internalTrack.info.colorSpace ?? undefined,
};
if (this.internalTrack.info.width !== this.internalTrack.info.squarePixelWidth
|| this.internalTrack.info.height !== this.internalTrack.info.squarePixelHeight) {
config.displayAspectWidth = this.internalTrack.info.squarePixelWidth;
config.displayAspectHeight = this.internalTrack.info.squarePixelHeight;
}
return config;
})();
}
}
class MatroskaAudioTrackBacking extends MatroskaTrackBacking {
constructor(internalTrack) {
super(internalTrack);
this.decoderConfig = null;
this.internalTrack = internalTrack;
}
getType() {
return 'audio';
}
getCodec() {
return this.internalTrack.info.codec;
}
getNumberOfChannels() {
return this.internalTrack.info.numberOfChannels;
}
getSampleRate() {
return this.internalTrack.info.sampleRate;
}
async getDecoderConfig() {
if (!this.internalTrack.info.codec) {
return null;
}
return this.decoderConfig ??= {
codec: extractAudioCodecString({
codec: this.internalTrack.info.codec,
codecDescription: this.internalTrack.info.codecDescription,
aacCodecInfo: this.internalTrack.info.aacCodecInfo,
}),
numberOfChannels: this.internalTrack.info.numberOfChannels,
sampleRate: this.internalTrack.info.sampleRate,
description: this.internalTrack.info.codecDescription ?? undefined,
};
}
}