mediabunny
Version:
Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.
933 lines (932 loc) • 157 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 { parseAacAudioSpecificConfig } from '../../shared/aac-misc.js';
import { extractAudioCodecString, extractVideoCodecString, OPUS_SAMPLE_RATE, parsePcmCodec, PCM_AUDIO_CODECS, } from '../codec.js';
import { extractAv1CodecInfoFromPacket, extractVp9CodecInfoFromPacket, FlacBlockType, parseEac3Config, getEac3SampleRate, getEac3ChannelCount, AC3_ACMOD_CHANNEL_COUNTS, } from '../codec-data.js';
import { Demuxer } from '../demuxer.js';
import { assert, binarySearchExact, binarySearchLessOrEqual, bytesToHexString, COLOR_PRIMARIES_MAP_INVERSE, findLastIndex, isIso639Dash2LanguageCode, last, MATRIX_COEFFICIENTS_MAP_INVERSE, normalizeRotation, roundToMultiple, textDecoder, TRANSFER_CHARACTERISTICS_MAP_INVERSE, UNDETERMINED_LANGUAGE, toDataView, roundIfAlmostInteger, hexStringToBytes, HEX_STRING_REGEX, } from '../misc.js';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet.js';
import { buildIsobmffMimeType, parsePsshBoxContents, psshBoxesAreEqual } from './isobmff-misc.js';
import { MAX_BOX_HEADER_SIZE, MIN_BOX_HEADER_SIZE, readBoxHeader, readDataBox, readFixed_16_16, readFixed_2_30, readIsomVariableInteger, readMetadataStringShort, } from './isobmff-reader.js';
import { readBytes, readF64Be, readI16Be, readI32Be, readI64Be, readU16Be, readU24Be, readU32Be, readU64Be, readU8, readAscii, } from '../reader.js';
import { DEFAULT_TRACK_DISPOSITION, RichImageData } from '../metadata.js';
import { AC3_SAMPLE_RATES } from '../../shared/ac3-misc.js';
import { Bitstream } from '../../shared/bitstream.js';
import { Aes128CbcContext } from '../aes.js';
export class IsobmffDemuxer extends Demuxer {
constructor(input) {
super(input);
this.moovSlice = null;
this.currentTrack = null;
this.tracks = [];
this.metadataPromise = null;
this.movieTimescale = -1;
this.movieDurationInTimescale = -1;
this.isQuickTime = false;
this.metadataTags = {};
this.currentMetadataKeys = null;
this.isFragmented = false;
this.fragmentTrackDefaults = [];
this.psshBoxes = [];
this.currentFragment = null;
/**
* Caches the last fragment that was read. Based on the assumption that there will be multiple reads to the
* same fragment in quick succession.
*/
this.lastReadFragment = null;
this.decryptionKeyCache = new Map();
this.reader = input._reader;
}
async getTrackBackings() {
await this.readMetadata();
return this.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 buildIsobmffMimeType({
isQuickTime: this.isQuickTime,
hasVideo: this.tracks.some(x => x.info?.type === 'video'),
hasAudio: this.tracks.some(x => x.info?.type === 'audio'),
codecStrings: codecStrings.filter(Boolean),
});
}
async getMetadataTags() {
await this.readMetadata();
return this.metadataTags;
}
readMetadata() {
return this.metadataPromise ??= (async () => {
let currentPos = 0;
let lookForMfraBox = false;
while (true) {
let slice = this.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
if (slice instanceof Promise)
slice = await slice;
if (!slice)
break;
const startPos = currentPos;
const boxInfo = readBoxHeader(slice);
if (!boxInfo) {
break;
}
if (boxInfo.name === 'ftyp' || boxInfo.name === 'styp') {
const majorBrand = readAscii(slice, 4);
this.isQuickTime = majorBrand === 'qt ';
}
else if (boxInfo.name === 'moov') {
// Found moov, load it
let moovSlice = this.reader.requestSlice(slice.filePos, boxInfo.contentSize);
if (moovSlice instanceof Promise)
moovSlice = await moovSlice;
if (!moovSlice)
break;
this.moovSlice = moovSlice;
this.readContiguousBoxes(this.moovSlice);
for (const track of this.tracks) {
// Modify the edit list offset based on the previous segment durations. They are in different
// timescales, so we first convert to seconds and then into the track timescale.
const previousSegmentDurationsInSeconds = track.editListPreviousSegmentDurations / this.movieTimescale;
track.editListOffset -= Math.round(previousSegmentDurationsInSeconds * track.timescale);
}
lookForMfraBox = this.isFragmented
&& this.reader.fileSize !== null
&& this.reader.fileSize > startPos + boxInfo.totalSize; // There's more after the moov box
break;
}
else if (boxInfo.name === 'moof') {
if (!this.input._initInput) {
throw new Error('"moof" box encountered with no "moov" box present; this file is likely a Segment as'
+ ' described in ISO/IEC 14496-12 Section 8.16. A separate init file that contains a "moov"'
+ ' box is required to read this file, please provide it using InputOptions.initInput.');
}
const initDemuxer = (await this.input._initInput._getDemuxer());
if (initDemuxer.constructor !== IsobmffDemuxer) {
throw new Error('Init input must match the input\'s format.');
}
await initDemuxer.readMetadata();
this.movieTimescale = initDemuxer.movieTimescale;
this.movieDurationInTimescale = initDemuxer.movieDurationInTimescale;
this.metadataTags = initDemuxer.metadataTags;
this.isFragmented = true;
this.fragmentTrackDefaults = initDemuxer.fragmentTrackDefaults;
this.psshBoxes = initDemuxer.psshBoxes;
// Create tracks from the init input's tracks
for (const foreignTrack of initDemuxer.tracks) {
const track = {
id: foreignTrack.id,
demuxer: this,
trackBacking: null,
disposition: foreignTrack.disposition,
timescale: foreignTrack.timescale,
durationInMediaTimescale: foreignTrack.durationInMediaTimescale,
durationInMovieTimescale: foreignTrack.durationInMovieTimescale,
rotation: foreignTrack.rotation,
internalCodecId: foreignTrack.internalCodecId,
name: foreignTrack.name,
languageCode: foreignTrack.languageCode,
sampleTableByteOffset: null,
sampleTable: null,
fragmentLookupTable: [],
currentFragmentState: null,
fragmentPositionCache: [],
editListPreviousSegmentDurations: foreignTrack.editListPreviousSegmentDurations,
editListOffset: foreignTrack.editListOffset,
encryptionInfo: foreignTrack.encryptionInfo,
encryptionAuxInfo: null,
frmaCodecString: null,
info: foreignTrack.info,
};
if (foreignTrack.trackBacking) {
assert(track.info);
if (track.info.type === 'video' && track.info.width !== -1) {
const videoTrack = track;
track.trackBacking = new IsobmffVideoTrackBacking(videoTrack);
this.tracks.push(track);
}
else if (track.info.type === 'audio' && track.info.numberOfChannels !== -1) {
const audioTrack = track;
track.trackBacking = new IsobmffAudioTrackBacking(audioTrack);
this.tracks.push(track);
}
}
else {
// The track didn't have enough info to warrant a backing
}
}
lookForMfraBox = false; // No point in doing it for segment files
break;
}
currentPos = startPos + boxInfo.totalSize;
}
if (lookForMfraBox) {
assert(this.reader.fileSize !== null);
// The last 4 bytes may contain the size of the mfra box at the end of the file
let lastWordSlice = this.reader.requestSlice(this.reader.fileSize - 4, 4);
if (lastWordSlice instanceof Promise)
lastWordSlice = await lastWordSlice;
assert(lastWordSlice);
const lastWord = readU32Be(lastWordSlice);
const potentialMfraPos = this.reader.fileSize - lastWord;
if (potentialMfraPos >= 0 && potentialMfraPos <= this.reader.fileSize - MAX_BOX_HEADER_SIZE) {
let mfraHeaderSlice = this.reader.requestSliceRange(potentialMfraPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
if (mfraHeaderSlice instanceof Promise)
mfraHeaderSlice = await mfraHeaderSlice;
if (mfraHeaderSlice) {
const boxInfo = readBoxHeader(mfraHeaderSlice);
if (boxInfo && boxInfo.name === 'mfra') {
// We found the mfra box, allowing for much better random access. Let's parse it.
let mfraSlice = this.reader.requestSlice(mfraHeaderSlice.filePos, boxInfo.contentSize);
if (mfraSlice instanceof Promise)
mfraSlice = await mfraSlice;
if (mfraSlice) {
this.readContiguousBoxes(mfraSlice);
}
}
}
}
}
})();
}
getSampleTableForTrack(internalTrack) {
if (internalTrack.sampleTable) {
return internalTrack.sampleTable;
}
const sampleTable = {
sampleTimingEntries: [],
sampleCompositionTimeOffsets: [],
sampleSizes: [],
keySampleIndices: null,
chunkOffsets: [],
sampleToChunk: [],
presentationTimestamps: null,
presentationTimestampIndexMap: null,
};
internalTrack.sampleTable = sampleTable;
if (internalTrack.sampleTableByteOffset === null) {
// There's no sample table to read, it's in another file (happens with segments)
return sampleTable;
}
assert(this.moovSlice);
const stblContainerSlice = this.moovSlice.slice(internalTrack.sampleTableByteOffset);
this.currentTrack = internalTrack;
this.traverseBox(stblContainerSlice);
this.currentTrack = null;
const isPcmCodec = internalTrack.info?.type === 'audio'
&& internalTrack.info.codec
&& PCM_AUDIO_CODECS.includes(internalTrack.info.codec);
if (isPcmCodec && sampleTable.sampleCompositionTimeOffsets.length === 0) {
// If the audio has PCM samples, the way the samples are defined in the sample table is somewhat
// suboptimal: Each individual audio sample is its own sample, meaning we can have 48000 samples per second.
// Because we treat each sample as its own atomic unit that can be decoded, this would lead to a huge
// amount of very short samples for PCM audio. So instead, we make a transformation: If the audio is in PCM,
// we say that each chunk (that normally holds many samples) now is one big sample. We can this because
// the samples in the chunk are contiguous and the format is PCM, so the entire chunk as one thing still
// encodes valid audio information.
assert(internalTrack.info?.type === 'audio');
const pcmInfo = parsePcmCodec(internalTrack.info.codec);
const newSampleTimingEntries = [];
const newSampleSizes = [];
for (let i = 0; i < sampleTable.sampleToChunk.length; i++) {
const chunkEntry = sampleTable.sampleToChunk[i];
const nextEntry = sampleTable.sampleToChunk[i + 1];
const chunkCount = (nextEntry ? nextEntry.startChunkIndex : sampleTable.chunkOffsets.length)
- chunkEntry.startChunkIndex;
for (let j = 0; j < chunkCount; j++) {
const startSampleIndex = chunkEntry.startSampleIndex + j * chunkEntry.samplesPerChunk;
const endSampleIndex = startSampleIndex + chunkEntry.samplesPerChunk; // Exclusive, outside of chunk
const startTimingEntryIndex = binarySearchLessOrEqual(sampleTable.sampleTimingEntries, startSampleIndex, x => x.startIndex);
const startTimingEntry = sampleTable.sampleTimingEntries[startTimingEntryIndex];
const endTimingEntryIndex = binarySearchLessOrEqual(sampleTable.sampleTimingEntries, endSampleIndex, x => x.startIndex);
const endTimingEntry = sampleTable.sampleTimingEntries[endTimingEntryIndex];
const firstSampleTimestamp = startTimingEntry.startDecodeTimestamp
+ (startSampleIndex - startTimingEntry.startIndex) * startTimingEntry.delta;
const lastSampleTimestamp = endTimingEntry.startDecodeTimestamp
+ (endSampleIndex - endTimingEntry.startIndex) * endTimingEntry.delta;
const delta = lastSampleTimestamp - firstSampleTimestamp;
const lastSampleTimingEntry = last(newSampleTimingEntries);
if (lastSampleTimingEntry && lastSampleTimingEntry.delta === delta) {
lastSampleTimingEntry.count++;
}
else {
// One sample for the entire chunk
newSampleTimingEntries.push({
startIndex: chunkEntry.startChunkIndex + j,
startDecodeTimestamp: firstSampleTimestamp,
count: 1,
delta,
});
}
// Instead of determining the chunk's size by looping over the samples sizes in the sample table, we
// can directly compute it as we know how many PCM frames are in this chunk, and the size of each
// PCM frame. This also improves compatibility with some files which fail to write proper sample
// size values into their sample tables in the PCM case.
const chunkSize = chunkEntry.samplesPerChunk
* pcmInfo.sampleSize
* internalTrack.info.numberOfChannels;
newSampleSizes.push(chunkSize);
}
chunkEntry.startSampleIndex = chunkEntry.startChunkIndex;
chunkEntry.samplesPerChunk = 1;
}
sampleTable.sampleTimingEntries = newSampleTimingEntries;
sampleTable.sampleSizes = newSampleSizes;
}
if (sampleTable.sampleCompositionTimeOffsets.length > 0) {
// If composition time offsets are defined, we must build a list of all presentation timestamps and then
// sort them
sampleTable.presentationTimestamps = [];
for (const entry of sampleTable.sampleTimingEntries) {
for (let i = 0; i < entry.count; i++) {
sampleTable.presentationTimestamps.push({
presentationTimestamp: entry.startDecodeTimestamp + i * entry.delta,
sampleIndex: entry.startIndex + i,
});
}
}
for (const entry of sampleTable.sampleCompositionTimeOffsets) {
for (let i = 0; i < entry.count; i++) {
const sampleIndex = entry.startIndex + i;
const sample = sampleTable.presentationTimestamps[sampleIndex];
if (!sample) {
continue;
}
sample.presentationTimestamp += entry.offset;
}
}
sampleTable.presentationTimestamps.sort((a, b) => a.presentationTimestamp - b.presentationTimestamp);
sampleTable.presentationTimestampIndexMap = Array(sampleTable.presentationTimestamps.length).fill(-1);
for (let i = 0; i < sampleTable.presentationTimestamps.length; i++) {
sampleTable.presentationTimestampIndexMap[sampleTable.presentationTimestamps[i].sampleIndex] = i;
}
}
else {
// If they're not defined, we can simply use the decode timestamps as presentation timestamps
}
return sampleTable;
}
async readFragment(startPos) {
if (this.lastReadFragment?.moofOffset === startPos) {
return this.lastReadFragment;
}
let headerSlice = this.reader.requestSliceRange(startPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
if (headerSlice instanceof Promise)
headerSlice = await headerSlice;
assert(headerSlice);
const moofBoxInfo = readBoxHeader(headerSlice);
assert(moofBoxInfo?.name === 'moof');
let entireSlice = this.reader.requestSlice(startPos, moofBoxInfo.totalSize);
if (entireSlice instanceof Promise)
entireSlice = await entireSlice;
assert(entireSlice);
this.traverseBox(entireSlice);
const fragment = this.lastReadFragment;
assert(fragment && fragment.moofOffset === startPos);
for (const [, trackData] of fragment.trackData) {
const track = trackData.track;
const { fragmentPositionCache } = track;
if (!trackData.startTimestampIsFinal) {
// It may be that some tracks don't define the base decode time, i.e. when the fragment begins. This
// we'll need to figure out the start timestamp another way. We'll compute the timestamp by accessing
// the lookup entries and fragment cache, which works out nicely with the lookup algorithm: If these
// exist, then the lookup will automatically start at the furthest possible point. If they don't, the
// lookup starts sequentially from the start, incrementally summing up all fragment durations. It's sort
// of implicit, but it ends up working nicely.
const lookupEntry = track.fragmentLookupTable.find(x => x.moofOffset === fragment.moofOffset);
if (lookupEntry) {
// There's a lookup entry, let's use its timestamp
offsetFragmentTrackDataByTimestamp(trackData, lookupEntry.timestamp);
}
else {
const lastCacheIndex = binarySearchLessOrEqual(fragmentPositionCache, fragment.moofOffset - 1, x => x.moofOffset);
if (lastCacheIndex !== -1) {
// Let's use the timestamp of the previous fragment in the cache
const lastCache = fragmentPositionCache[lastCacheIndex];
offsetFragmentTrackDataByTimestamp(trackData, lastCache.endTimestamp);
}
else {
// We're the first fragment I guess, "offset by 0"
}
}
trackData.startTimestampIsFinal = true;
}
// Let's remember that a fragment with a given timestamp is here, speeding up future lookups if no
// lookup table exists
const insertionIndex = binarySearchLessOrEqual(fragmentPositionCache, trackData.startTimestamp, x => x.startTimestamp);
if (insertionIndex === -1
|| fragmentPositionCache[insertionIndex].moofOffset !== fragment.moofOffset) {
fragmentPositionCache.splice(insertionIndex + 1, 0, {
moofOffset: fragment.moofOffset,
startTimestamp: trackData.startTimestamp,
endTimestamp: trackData.endTimestamp,
});
}
// If senc wasn't parsed but saiz+saio were, fetch the aux info now and stamp each sample with it
if (trackData.encryptionAuxInfo && track.encryptionInfo) {
const entries = await resolveEncryptionAuxInfo(this.reader, track.encryptionInfo, trackData.encryptionAuxInfo);
for (let i = 0; i < Math.min(trackData.samples.length, entries.length); i++) {
const entry = entries[i];
trackData.samples[i].encryption = entry;
}
}
}
return fragment;
}
readContiguousBoxes(slice) {
const startIndex = slice.filePos;
while (slice.filePos - startIndex <= slice.length - MIN_BOX_HEADER_SIZE) {
const foundBox = this.traverseBox(slice);
if (!foundBox) {
break;
}
}
}
// eslint-disable-next-line @stylistic/generator-star-spacing
*iterateContiguousBoxes(slice) {
const startIndex = slice.filePos;
while (slice.filePos - startIndex <= slice.length - MIN_BOX_HEADER_SIZE) {
const startPos = slice.filePos;
const boxInfo = readBoxHeader(slice);
if (!boxInfo) {
break;
}
yield { boxInfo, slice };
slice.filePos = startPos + boxInfo.totalSize;
}
}
traverseBox(slice) {
const startPos = slice.filePos;
const boxInfo = readBoxHeader(slice);
if (!boxInfo) {
return false;
}
const contentStartPos = slice.filePos;
const boxEndPos = startPos + boxInfo.totalSize;
switch (boxInfo.name) {
case 'mdia':
case 'minf':
case 'dinf':
case 'mfra':
case 'edts':
case 'sinf':
case 'schi':
{
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
}
;
break;
case 'mvhd':
{
const version = readU8(slice);
slice.skip(3); // Flags
if (version === 1) {
slice.skip(8 + 8);
this.movieTimescale = readU32Be(slice);
this.movieDurationInTimescale = readU64Be(slice);
}
else {
slice.skip(4 + 4);
this.movieTimescale = readU32Be(slice);
this.movieDurationInTimescale = readU32Be(slice);
}
}
;
break;
case 'trak':
{
const track = {
id: -1,
demuxer: this,
trackBacking: null,
disposition: {
...DEFAULT_TRACK_DISPOSITION,
primary: false,
},
info: null,
timescale: -1,
durationInMovieTimescale: -1,
durationInMediaTimescale: -1,
rotation: 0,
internalCodecId: null,
name: null,
languageCode: UNDETERMINED_LANGUAGE,
sampleTableByteOffset: -1,
sampleTable: null,
fragmentLookupTable: [],
currentFragmentState: null,
fragmentPositionCache: [],
editListPreviousSegmentDurations: 0,
editListOffset: 0,
encryptionInfo: null,
encryptionAuxInfo: null,
frmaCodecString: null,
};
this.currentTrack = track;
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
if (track.id !== -1 && track.timescale !== -1 && track.info !== null) {
if (track.info.type === 'video' && track.info.width !== -1) {
const videoTrack = track;
track.trackBacking = new IsobmffVideoTrackBacking(videoTrack);
this.tracks.push(track);
}
else if (track.info.type === 'audio' && track.info.numberOfChannels !== -1) {
const audioTrack = track;
track.trackBacking = new IsobmffAudioTrackBacking(audioTrack);
this.tracks.push(track);
}
}
this.currentTrack = null;
}
;
break;
case 'tkhd':
{
const track = this.currentTrack;
if (!track) {
break;
}
const version = readU8(slice);
const flags = readU24Be(slice);
// Spec says disabled tracks are to be treated like they don't exist, but in practice, they are treated
// more like non-default tracks.
const trackEnabled = !!(flags & 0x1);
track.disposition.default = trackEnabled;
// Skip over creation & modification time to reach the track ID
if (version === 0) {
slice.skip(8);
track.id = readU32Be(slice);
slice.skip(4);
track.durationInMovieTimescale = readU32Be(slice);
}
else if (version === 1) {
slice.skip(16);
track.id = readU32Be(slice);
slice.skip(4);
track.durationInMovieTimescale = readU64Be(slice);
}
else {
throw new Error(`Incorrect track header version ${version}.`);
}
slice.skip(2 * 4 + 2 + 2 + 2 + 2);
const matrix = [
readFixed_16_16(slice),
readFixed_16_16(slice),
readFixed_2_30(slice),
readFixed_16_16(slice),
readFixed_16_16(slice),
readFixed_2_30(slice),
readFixed_16_16(slice),
readFixed_16_16(slice),
readFixed_2_30(slice),
];
const rotation = normalizeRotation(roundToMultiple(extractRotationFromMatrix(matrix), 90));
assert(rotation === 0 || rotation === 90 || rotation === 180 || rotation === 270);
track.rotation = rotation;
}
;
break;
case 'elst':
{
const track = this.currentTrack;
if (!track) {
break;
}
const version = readU8(slice);
slice.skip(3); // Flags
let relevantEntryFound = false;
let previousSegmentDurations = 0;
const entryCount = readU32Be(slice);
for (let i = 0; i < entryCount; i++) {
const segmentDuration = version === 1
? readU64Be(slice)
: readU32Be(slice);
const mediaTime = version === 1
? readI64Be(slice)
: readI32Be(slice);
const mediaRate = readFixed_16_16(slice);
if (segmentDuration === 0) {
// Don't care
continue;
}
if (relevantEntryFound) {
console.warn('Unsupported edit list: multiple edits are not currently supported. Only using first edit.');
break;
}
if (mediaTime === -1) {
previousSegmentDurations += segmentDuration;
continue;
}
if (mediaRate !== 1) {
console.warn('Unsupported edit list entry: media rate must be 1.');
break;
}
track.editListPreviousSegmentDurations = previousSegmentDurations;
track.editListOffset = mediaTime;
relevantEntryFound = true;
}
}
;
break;
case 'mdhd':
{
const track = this.currentTrack;
if (!track) {
break;
}
const version = readU8(slice);
slice.skip(3); // Flags
if (version === 0) {
slice.skip(8);
track.timescale = readU32Be(slice);
track.durationInMediaTimescale = readU32Be(slice);
}
else if (version === 1) {
slice.skip(16);
track.timescale = readU32Be(slice);
track.durationInMediaTimescale = readU64Be(slice);
}
let language = readU16Be(slice);
if (language > 0) {
track.languageCode = '';
for (let i = 0; i < 3; i++) {
track.languageCode = String.fromCharCode(0x60 + (language & 0b11111)) + track.languageCode;
language >>= 5;
}
if (!isIso639Dash2LanguageCode(track.languageCode)) {
// Sometimes the bytes are garbage
track.languageCode = UNDETERMINED_LANGUAGE;
}
}
}
;
break;
case 'hdlr':
{
const track = this.currentTrack;
if (!track) {
break;
}
slice.skip(8); // Version + flags + pre-defined
const handlerType = readAscii(slice, 4);
if (handlerType === 'vide') {
track.info = {
type: 'video',
width: -1,
height: -1,
squarePixelWidth: -1,
squarePixelHeight: -1,
codec: null,
codecDescription: null,
colorSpace: null,
avcType: null,
avcCodecInfo: null,
hevcCodecInfo: null,
vp9CodecInfo: null,
av1CodecInfo: null,
};
}
else if (handlerType === 'soun') {
track.info = {
type: 'audio',
numberOfChannels: -1,
sampleRate: -1,
codec: null,
codecDescription: null,
aacCodecInfo: null,
pcmLittleEndian: false,
pcmSampleSize: null,
};
}
}
;
break;
case 'stbl':
{
const track = this.currentTrack;
if (!track) {
break;
}
track.sampleTableByteOffset = startPos;
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
}
;
break;
case 'stsd':
{
const track = this.currentTrack;
if (!track) {
break;
}
if (track.info === null || track.sampleTable) {
break;
}
const stsdVersion = readU8(slice);
slice.skip(3); // Flags
const entries = readU32Be(slice);
for (let i = 0; i < entries; i++) {
const sampleBoxStartPos = slice.filePos;
const sampleBoxInfo = readBoxHeader(slice);
if (!sampleBoxInfo) {
break;
}
track.internalCodecId = sampleBoxInfo.name;
const lowercaseBoxName = sampleBoxInfo.name.toLowerCase();
if (track.info.type === 'video') {
slice.skip(6 * 1 + 2 + 2 + 2 + 3 * 4);
track.info.width = readU16Be(slice);
track.info.height = readU16Be(slice);
track.info.squarePixelWidth = track.info.width;
track.info.squarePixelHeight = track.info.height;
slice.skip(4 + 4 + 4 + 2 + 32 + 2 + 2);
track.frmaCodecString = null;
this.readContiguousBoxes(slice.slice(slice.filePos, (sampleBoxStartPos + sampleBoxInfo.totalSize) - slice.filePos));
const codecName = lowercaseBoxName === 'encv'
? track.frmaCodecString
: lowercaseBoxName;
track.frmaCodecString = null;
if (codecName === 'avc1' || codecName === 'avc3') {
track.info.codec = 'avc';
track.info.avcType = codecName === 'avc1' ? 1 : 3;
}
else if (codecName === 'hvc1' || codecName === 'hev1') {
track.info.codec = 'hevc';
}
else if (codecName === 'vp08') {
track.info.codec = 'vp8';
}
else if (codecName === 'vp09') {
track.info.codec = 'vp9';
}
else if (codecName === 'av01') {
track.info.codec = 'av1';
}
else if (codecName === null) {
console.warn(`Unknown encrypted video codec due to missing frma box.`);
}
else {
console.warn(`Unsupported video codec (sample entry type '${sampleBoxInfo.name}').`);
}
}
else {
slice.skip(6 * 1 + 2);
const version = readU16Be(slice);
slice.skip(3 * 2);
let channelCount = readU16Be(slice);
let sampleSize = readU16Be(slice);
slice.skip(2 * 2);
// Can't use fixed16_16 as that's signed
let sampleRate = readU32Be(slice) / 0x10000;
let lpcmFlags = null;
if (stsdVersion === 0 && version > 0) {
// Additional QuickTime fields
if (version === 1) {
slice.skip(4);
sampleSize = 8 * readU32Be(slice);
slice.skip(2 * 4);
}
else if (version === 2) {
slice.skip(4);
sampleRate = readF64Be(slice);
channelCount = readU32Be(slice);
slice.skip(4); // Always 0x7f000000
sampleSize = readU32Be(slice);
lpcmFlags = readU32Be(slice);
slice.skip(2 * 4);
}
}
track.info.numberOfChannels = channelCount;
track.info.sampleRate = sampleRate;
track.frmaCodecString = null;
this.readContiguousBoxes(slice.slice(slice.filePos, (sampleBoxStartPos + sampleBoxInfo.totalSize) - slice.filePos));
const codecName = lowercaseBoxName === 'enca'
? track.frmaCodecString
: lowercaseBoxName;
track.frmaCodecString = null;
// developer.apple.com/documentation/quicktime-file-format/sound_sample_descriptions/
if (codecName === 'mp4a') {
// The codec is set by the esds box
}
else if (codecName === 'opus') {
track.info.codec = 'opus';
track.info.sampleRate = OPUS_SAMPLE_RATE; // Always the same
}
else if (codecName === 'flac') {
track.info.codec = 'flac';
}
else if (codecName === 'ulaw') {
track.info.codec = 'ulaw';
}
else if (codecName === 'alaw') {
track.info.codec = 'alaw';
}
else if (codecName === 'ac-3') {
track.info.codec = 'ac3';
}
else if (codecName === 'ec-3') {
track.info.codec = 'eac3';
}
else if (codecName === 'twos') {
if (sampleSize === 8) {
track.info.codec = 'pcm-s8';
}
else if (sampleSize === 16) {
track.info.codec = track.info.pcmLittleEndian ? 'pcm-s16' : 'pcm-s16be';
}
else {
console.warn(`Unsupported sample size ${sampleSize} for codec 'twos'.`);
track.info.codec = null;
}
}
else if (codecName === 'sowt') {
if (sampleSize === 8) {
track.info.codec = 'pcm-s8';
}
else if (sampleSize === 16) {
track.info.codec = 'pcm-s16';
}
else {
console.warn(`Unsupported sample size ${sampleSize} for codec 'sowt'.`);
track.info.codec = null;
}
}
else if (codecName === 'raw ') {
track.info.codec = 'pcm-u8';
}
else if (codecName === 'in24') {
track.info.codec = track.info.pcmLittleEndian ? 'pcm-s24' : 'pcm-s24be';
}
else if (codecName === 'in32') {
track.info.codec = track.info.pcmLittleEndian ? 'pcm-s32' : 'pcm-s32be';
}
else if (codecName === 'fl32') {
track.info.codec = track.info.pcmLittleEndian ? 'pcm-f32' : 'pcm-f32be';
}
else if (codecName === 'fl64') {
track.info.codec = track.info.pcmLittleEndian ? 'pcm-f64' : 'pcm-f64be';
}
else if (codecName === 'ipcm') {
const pcmSampleSize = track.info.pcmSampleSize;
if (track.info.pcmLittleEndian) {
if (pcmSampleSize === 16) {
track.info.codec = 'pcm-s16';
}
else if (pcmSampleSize === 24) {
track.info.codec = 'pcm-s24';
}
else if (pcmSampleSize === 32) {
track.info.codec = 'pcm-s32';
}
else {
console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
}
}
else {
if (pcmSampleSize === 16) {
track.info.codec = 'pcm-s16be';
}
else if (pcmSampleSize === 24) {
track.info.codec = 'pcm-s24be';
}
else if (pcmSampleSize === 32) {
track.info.codec = 'pcm-s32be';
}
else {
console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
}
}
}
else if (codecName === 'fpcm') {
const pcmSampleSize = track.info.pcmSampleSize;
if (track.info.pcmLittleEndian) {
if (pcmSampleSize === 32) {
track.info.codec = 'pcm-f32';
}
else if (pcmSampleSize === 64) {
track.info.codec = 'pcm-f64';
}
else {
console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
}
}
else {
if (pcmSampleSize === 32) {
track.info.codec = 'pcm-f32be';
}
else if (pcmSampleSize === 64) {
track.info.codec = 'pcm-f64be';
}
else {
console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
}
}
}
else if (codecName === 'lpcm' && lpcmFlags !== null) {
const bytesPerSample = (sampleSize + 7) >> 3;
const isFloat = Boolean(lpcmFlags & 1);
const isBigEndian = Boolean(lpcmFlags & 2);
const sFlags = lpcmFlags & 4 ? -1 : 0; // I guess it means "signed flags" or something?
if (sampleSize > 0 && sampleSize <= 64) {
if (isFloat) {
if (sampleSize === 32) {
track.info.codec = isBigEndian ? 'pcm-f32be' : 'pcm-f32';
}
}
else {
if (sFlags & (1 << (bytesPerSample - 1))) {
if (bytesPerSample === 1) {
track.info.codec = 'pcm-s8';
}
else if (bytesPerSample === 2) {
track.info.codec = isBigEndian ? 'pcm-s16be' : 'pcm-s16';
}
else if (bytesPerSample === 3) {
track.info.codec = isBigEndian ? 'pcm-s24be' : 'pcm-s24';
}
else if (bytesPerSample === 4) {
track.info.codec = isBigEndian ? 'pcm-s32be' : 'pcm-s32';
}
}
else {
if (bytesPerSample === 1) {
track.info.codec = 'pcm-u8';
}
}
}
}
if (track.info.codec === null) {
console.warn('Unsupported PCM format.');
}
}
else if (codecName === null) {
console.warn(`Unknown encrypted audio codec due to missing frma box.`);
}
else {
console.warn(`Unsupported audio codec (sample entry type '${sampleBoxInfo.name}').`);
}
}
slice.filePos = sampleBoxStartPos + sampleBoxInfo.totalSize;
}
}
;
break;
case 'frma':
{
const track = this.currentTrack;
if (!track) {
break;
}
const format = readAscii(slice, 4);
const lowercase = format.toLowerCase();
// Tells us what codec the encrypted track actually uses
track.frmaCodecString = lowercase;
}