mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-28 11:23:45 +02:00
2450 lines
70 KiB
TypeScript
2450 lines
70 KiB
TypeScript
/*!
|
|
* 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 { TrackType } from '../output';
|
|
import { SAMPLES_PER_AAC_FRAME } from '../adts/adts-demuxer';
|
|
import { MAX_ADTS_FRAME_HEADER_SIZE, readAdtsFrameHeader } from '../adts/adts-reader';
|
|
import { aacChannelMap, aacFrequencyTable } from '../../shared/aac-misc';
|
|
import {
|
|
AacCodecInfo,
|
|
AudioCodec,
|
|
extractAudioCodecString,
|
|
extractVideoCodecString,
|
|
MediaCodec,
|
|
VideoCodec,
|
|
} from '../codec';
|
|
import {
|
|
AC3_ACMOD_CHANNEL_COUNTS,
|
|
AC3_SAMPLES_PER_FRAME,
|
|
AvcDecoderConfigurationRecord,
|
|
AvcNalUnitType,
|
|
determineVideoPacketType,
|
|
extractAvcDecoderConfigurationRecord,
|
|
extractHevcDecoderConfigurationRecord,
|
|
extractNalUnitTypeForAvc,
|
|
extractNalUnitTypeForHevc,
|
|
EAC3_NUMBLKS_TABLE,
|
|
getEac3ChannelCount,
|
|
getEac3SampleRate,
|
|
HevcDecoderConfigurationRecord,
|
|
HevcNalUnitType,
|
|
parseAc3SyncFrame,
|
|
parseAvcSps,
|
|
parseEac3SyncFrame,
|
|
parseHevcSps,
|
|
AC3_FRAME_SIZES,
|
|
} from '../codec-data';
|
|
import { Demuxer } from '../demuxer';
|
|
import { Input } from '../input';
|
|
import {
|
|
InputAudioTrackBacking,
|
|
InputTrackBacking,
|
|
InputVideoTrackBacking,
|
|
} from '../input-track';
|
|
import { PacketRetrievalOptions } from '../media-sink';
|
|
import { DEFAULT_TRACK_DISPOSITION, MetadataTags, TrackDisposition } from '../metadata';
|
|
import {
|
|
assert,
|
|
binarySearchExact,
|
|
binarySearchLessOrEqual,
|
|
COLOR_PRIMARIES_MAP_INVERSE,
|
|
findLastIndex,
|
|
floorToMultiple,
|
|
last,
|
|
MATRIX_COEFFICIENTS_MAP_INVERSE,
|
|
Rotation,
|
|
roundIfAlmostInteger,
|
|
toDataView,
|
|
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
|
|
UNDETERMINED_LANGUAGE,
|
|
} from '../misc';
|
|
import {
|
|
MP3_FRAME_HEADER_SIZE,
|
|
getMp3ChannelCount,
|
|
readMp3FrameHeader,
|
|
} from '../../shared/mp3-misc';
|
|
import { EncodedPacket, PacketType, PLACEHOLDER_DATA } from '../packet';
|
|
import { FileSlice, readBytes, Reader, readU16Be, readU32Be, readU8 } from '../reader';
|
|
import { buildMpegTsMimeType, MpegTsStreamType, TIMESCALE, TS_PACKET_SIZE } from './mpeg-ts-misc';
|
|
import { AC3_SAMPLE_RATES } from '../../shared/ac3-misc';
|
|
import { Bitstream } from '../../shared/bitstream';
|
|
|
|
// Resources:
|
|
// ISO/IEC 13818-1
|
|
|
|
const MISSING_PTS_ERROR_MESSAGE = 'PES packet is missing PTS where it was expected. PES packets without PTS are not'
|
|
+ ' currently supported. If you think this file should be supported, please report it.';
|
|
|
|
type ElementaryStream = {
|
|
demuxer: MpegTsDemuxer;
|
|
pid: number;
|
|
streamType: number;
|
|
initialized: boolean;
|
|
firstSection: Section | null;
|
|
/**
|
|
* Some muxers suck ass and don't correctly label key frames, meaning we'll need to use our skill to
|
|
* compensate for another programmer's skill issue.
|
|
*/
|
|
canBeTrustedWithKeyPackets: boolean;
|
|
info: {
|
|
type: 'video';
|
|
codec: VideoCodec;
|
|
decoderConfig: VideoDecoderConfig | null;
|
|
avcCodecInfo: AvcDecoderConfigurationRecord | null;
|
|
hevcCodecInfo: HevcDecoderConfigurationRecord | null;
|
|
colorSpace: VideoColorSpaceInit;
|
|
width: number;
|
|
height: number;
|
|
squarePixelWidth: number;
|
|
squarePixelHeight: number;
|
|
reorderSize: number;
|
|
} | {
|
|
type: 'audio';
|
|
codec: AudioCodec;
|
|
decoderConfig: AudioDecoderConfig | null;
|
|
aacCodecInfo: AacCodecInfo | null;
|
|
numberOfChannels: number;
|
|
sampleRate: number;
|
|
};
|
|
/**
|
|
* Reference PES packets, spread throughout the file, to be used to speed up repeated random access. Sorted by both
|
|
* byte offset and PTS.
|
|
*/
|
|
referencePesPackets: TimestampedPesPacketHeader[];
|
|
};
|
|
|
|
type ElementaryVideoStream = ElementaryStream & { info: { type: 'video' } };
|
|
type ElementaryAudioStream = ElementaryStream & { info: { type: 'audio' } };
|
|
|
|
type TsPacketHeader = {
|
|
payloadUnitStartIndicator: number;
|
|
pid: number;
|
|
adaptationFieldControl: number;
|
|
};
|
|
|
|
type TsPacket = TsPacketHeader & {
|
|
body: Uint8Array<ArrayBufferLike>;
|
|
};
|
|
|
|
type Section = {
|
|
startPos: number;
|
|
endPos: number | null; // null if the section was not read fully
|
|
pid: number;
|
|
payload: Uint8Array<ArrayBufferLike>;
|
|
randomAccessIndicator: number;
|
|
};
|
|
|
|
// Remember them so the warning doesn't get spammed
|
|
const ignoredStreamTypes = new Set<number>();
|
|
|
|
export class MpegTsDemuxer extends Demuxer {
|
|
reader: Reader;
|
|
|
|
metadataPromise: Promise<void> | null = null;
|
|
elementaryStreams: ElementaryStream[] = [];
|
|
trackBackingEntries: InputTrackBacking[] = [];
|
|
packetOffset = 0;
|
|
packetStride = -1;
|
|
sectionEndPositions: number[] = [];
|
|
seekChunkSize = 5 * 1024 * 1024; // 5 MiB, picked because most HLS segments are below this size
|
|
minReferencePointByteDistance = -1;
|
|
|
|
constructor(input: Input) {
|
|
super(input);
|
|
|
|
this.reader = input._reader;
|
|
}
|
|
|
|
async readMetadata() {
|
|
return this.metadataPromise ??= (async () => {
|
|
const lengthToCheck = TS_PACKET_SIZE + 16 + 1;
|
|
let startingSlice = this.reader.requestSlice(0, lengthToCheck);
|
|
if (startingSlice instanceof Promise) startingSlice = await startingSlice;
|
|
assert(startingSlice);
|
|
|
|
const startingBytes = readBytes(startingSlice, lengthToCheck);
|
|
|
|
if (startingBytes[0] === 0x47 && startingBytes[TS_PACKET_SIZE] === 0x47) {
|
|
// Regular MPEG-TS
|
|
this.packetOffset = 0;
|
|
this.packetStride = TS_PACKET_SIZE;
|
|
} else if (startingBytes[0] === 0x47 && startingBytes[TS_PACKET_SIZE + 16] === 0x47) {
|
|
// MPEG-TS with Forward Error Correction
|
|
this.packetOffset = 0;
|
|
this.packetStride = TS_PACKET_SIZE + 16;
|
|
} else if (startingBytes[4] === 0x47 && startingBytes[4 + TS_PACKET_SIZE + 4] === 0x47) {
|
|
// MPEG-2-TS (DVHS)
|
|
this.packetOffset = 4;
|
|
this.packetStride = TS_PACKET_SIZE + 4;
|
|
} else {
|
|
throw new Error('Unreachable.');
|
|
}
|
|
|
|
const MIN_REFERENCE_POINT_PACKET_DISTANCE = 256;
|
|
this.minReferencePointByteDistance = MIN_REFERENCE_POINT_PACKET_DISTANCE * this.packetStride;
|
|
|
|
let currentPos = this.packetOffset;
|
|
let programMapPid: number | null = null;
|
|
// Some files contain these multiple times, but we only care about their first appearance
|
|
let hasProgramAssociationTable = false;
|
|
let hasProgramMap = false;
|
|
|
|
while (true) {
|
|
const packetHeader = await this.readPacketHeader(currentPos);
|
|
if (!packetHeader) {
|
|
break;
|
|
}
|
|
|
|
if (packetHeader.payloadUnitStartIndicator === 0) {
|
|
// Not the start of a section
|
|
currentPos += this.packetStride;
|
|
continue;
|
|
}
|
|
|
|
const section = await this.readSection(
|
|
currentPos,
|
|
true,
|
|
!hasProgramMap, // Expect contiguous sections as long as we don't have the PMT
|
|
);
|
|
if (!section) {
|
|
break;
|
|
}
|
|
|
|
const BYTES_BEFORE_SECTION_LENGTH = 3;
|
|
const BITS_IN_CRC_32 = 32; // Duh
|
|
|
|
// Some streams don't contain a PAT for some reason, so we must do some guesswork to figure out where
|
|
// the PMT is.
|
|
let isProbablyProgramMap = false;
|
|
if (!hasProgramMap && section.pid !== 0) {
|
|
const isPesPacket
|
|
= section.payload[0] === 0x00 && section.payload[1] === 0x00 && section.payload[2] === 0x01;
|
|
|
|
if (!isPesPacket) {
|
|
// Assume it's a PSI
|
|
|
|
const bitstream = new Bitstream(section.payload);
|
|
const pointerField = bitstream.readAlignedByte();
|
|
|
|
bitstream.skipBits(8 * pointerField);
|
|
|
|
const tableId = bitstream.readBits(8);
|
|
isProbablyProgramMap = tableId === 0x02; // 0x02 == TS_program_map_section
|
|
}
|
|
}
|
|
|
|
if (section.pid === 0 && !hasProgramAssociationTable) {
|
|
const bitstream = new Bitstream(section.payload);
|
|
const pointerField = bitstream.readAlignedByte();
|
|
|
|
bitstream.skipBits(8 * pointerField);
|
|
|
|
bitstream.skipBits(14);
|
|
const sectionLength = bitstream.readBits(10);
|
|
|
|
bitstream.skipBits(40);
|
|
|
|
while (8 * (sectionLength + BYTES_BEFORE_SECTION_LENGTH) - bitstream.pos > BITS_IN_CRC_32) {
|
|
const programNumber = bitstream.readBits(16);
|
|
bitstream.skipBits(3); // Reserved
|
|
const id = bitstream.readBits(13);
|
|
|
|
if (programNumber !== 0) {
|
|
if (programMapPid !== null) {
|
|
throw new Error('Only files with a single program are supported.');
|
|
} else {
|
|
programMapPid = id;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (programMapPid === null) {
|
|
throw new Error('Program Association Table must link to a Program Map Table.');
|
|
}
|
|
|
|
hasProgramAssociationTable = true;
|
|
} else if ((section.pid === programMapPid || isProbablyProgramMap) && !hasProgramMap) {
|
|
const bitstream = new Bitstream(section.payload);
|
|
const pointerField = bitstream.readAlignedByte();
|
|
|
|
bitstream.skipBits(8 * pointerField);
|
|
|
|
bitstream.skipBits(12);
|
|
const sectionLength = bitstream.readBits(12);
|
|
|
|
bitstream.skipBits(43);
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const pcrPid = bitstream.readBits(13);
|
|
|
|
bitstream.skipBits(6);
|
|
|
|
// "The remaining 10 bits specify the number of bytes of the descriptors immediately following the
|
|
// program_info_length field"
|
|
const programInfoLength = bitstream.readBits(10);
|
|
bitstream.skipBits(8 * programInfoLength);
|
|
|
|
while (8 * (sectionLength + BYTES_BEFORE_SECTION_LENGTH) - bitstream.pos > BITS_IN_CRC_32) {
|
|
const streamType = bitstream.readBits(8);
|
|
bitstream.skipBits(3);
|
|
const elementaryPid = bitstream.readBits(13);
|
|
|
|
bitstream.skipBits(6);
|
|
const esInfoLength = bitstream.readBits(10);
|
|
|
|
// Check ES descriptors to detect AC-3/E-AC-3 in System B
|
|
const esInfoEndPos = bitstream.pos + 8 * esInfoLength;
|
|
let hasAc3Descriptor = false;
|
|
let hasEac3Descriptor = false;
|
|
while (bitstream.pos < esInfoEndPos) {
|
|
const descriptorTag = bitstream.readBits(8);
|
|
const descriptorLength = bitstream.readBits(8);
|
|
if (descriptorTag === 0x6a) {
|
|
hasAc3Descriptor = true;
|
|
} else if (descriptorTag === 0x7a || descriptorTag === 0xcc) {
|
|
hasEac3Descriptor = true;
|
|
}
|
|
bitstream.skipBits(8 * descriptorLength);
|
|
}
|
|
|
|
let info: ElementaryStream['info'] | null = null;
|
|
|
|
switch (streamType) {
|
|
case MpegTsStreamType.AVC:
|
|
case MpegTsStreamType.HEVC: {
|
|
const codec = streamType === MpegTsStreamType.AVC ? 'avc' : 'hevc';
|
|
|
|
info = {
|
|
type: 'video',
|
|
codec,
|
|
decoderConfig: null,
|
|
avcCodecInfo: null,
|
|
hevcCodecInfo: null,
|
|
colorSpace: {
|
|
primaries: null,
|
|
transfer: null,
|
|
matrix: null,
|
|
fullRange: null,
|
|
},
|
|
width: -1,
|
|
height: -1,
|
|
squarePixelWidth: -1,
|
|
squarePixelHeight: -1,
|
|
reorderSize: -1,
|
|
};
|
|
}; break;
|
|
|
|
case MpegTsStreamType.MP3_MPEG1:
|
|
case MpegTsStreamType.MP3_MPEG2:
|
|
case MpegTsStreamType.AAC:
|
|
case MpegTsStreamType.AC3_SYSTEM_A:
|
|
case MpegTsStreamType.EAC3_SYSTEM_A: {
|
|
let codec: AudioCodec;
|
|
if (
|
|
streamType === MpegTsStreamType.MP3_MPEG1
|
|
|| streamType === MpegTsStreamType.MP3_MPEG2
|
|
) {
|
|
codec = 'mp3';
|
|
} else if (streamType === MpegTsStreamType.AAC) {
|
|
codec = 'aac';
|
|
} else if (streamType === MpegTsStreamType.AC3_SYSTEM_A) {
|
|
codec = 'ac3';
|
|
} else if (streamType === MpegTsStreamType.EAC3_SYSTEM_A) {
|
|
codec = 'eac3';
|
|
} else {
|
|
throw new Error('Unreachable.');
|
|
}
|
|
|
|
info = {
|
|
type: 'audio',
|
|
codec,
|
|
decoderConfig: null,
|
|
aacCodecInfo: null,
|
|
numberOfChannels: -1,
|
|
sampleRate: -1,
|
|
};
|
|
}; break;
|
|
|
|
case MpegTsStreamType.PRIVATE_DATA: {
|
|
if (hasEac3Descriptor) {
|
|
info = {
|
|
type: 'audio',
|
|
codec: 'eac3',
|
|
decoderConfig: null,
|
|
aacCodecInfo: null,
|
|
numberOfChannels: -1,
|
|
sampleRate: -1,
|
|
};
|
|
} else if (hasAc3Descriptor) {
|
|
info = {
|
|
type: 'audio',
|
|
codec: 'ac3',
|
|
decoderConfig: null,
|
|
aacCodecInfo: null,
|
|
numberOfChannels: -1,
|
|
sampleRate: -1,
|
|
};
|
|
}
|
|
}; break;
|
|
|
|
default: {
|
|
// If we don't recognize the codec, we don't surface the track at all. This is because
|
|
// we can't determine its metadata and also have no idea how to packetize its data.
|
|
|
|
if (!ignoredStreamTypes.has(streamType)) {
|
|
console.warn(
|
|
`Note: MPEG-TS streams with stream_type 0x${streamType.toString(16)} are not`
|
|
+ ` currently supported.`,
|
|
);
|
|
ignoredStreamTypes.add(streamType);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (info) {
|
|
this.elementaryStreams.push({
|
|
demuxer: this,
|
|
pid: elementaryPid,
|
|
streamType,
|
|
initialized: false,
|
|
firstSection: null,
|
|
canBeTrustedWithKeyPackets: false,
|
|
info,
|
|
referencePesPackets: [],
|
|
});
|
|
}
|
|
}
|
|
|
|
hasProgramMap = true;
|
|
} else {
|
|
const elementaryStream = this.elementaryStreams.find(x => x.pid === section.pid);
|
|
outer:
|
|
if (elementaryStream && !elementaryStream.initialized) {
|
|
const pesPacket = readPesPacket(section, true);
|
|
if (!pesPacket) {
|
|
throw new Error(
|
|
`Couldn't read first PES packet for Elementary Stream with PID ${elementaryStream.pid}`,
|
|
);
|
|
}
|
|
|
|
elementaryStream.firstSection = section;
|
|
elementaryStream.canBeTrustedWithKeyPackets = section.randomAccessIndicator === 1;
|
|
|
|
if (this.input._initInput) {
|
|
const initDemuxer = (await this.input._initInput._getDemuxer()) as MpegTsDemuxer;
|
|
const matchingStream = initDemuxer.elementaryStreams.find(x => (
|
|
x.pid === section.pid && x.info.codec === elementaryStream.info.codec
|
|
));
|
|
|
|
if (matchingStream) {
|
|
elementaryStream.info = matchingStream.info;
|
|
elementaryStream.initialized = true;
|
|
|
|
break outer; // We have the stream info, we're done
|
|
}
|
|
}
|
|
|
|
const context = new PacketReadingContext(elementaryStream, pesPacket);
|
|
|
|
if (elementaryStream.info.type === 'video') {
|
|
// We loop because in some files, the video parameters are not in the first packet
|
|
while (true) {
|
|
const contextAlias = context; // TyyyyypeScript 😩
|
|
contextAlias.suppliedPacket = null;
|
|
await context.markNextPacket();
|
|
|
|
if (elementaryStream.info.codec === 'avc') {
|
|
if (!context.suppliedPacket) {
|
|
throw new Error(
|
|
'Invalid AVC video stream; could not extract AVCDecoderConfigurationRecord'
|
|
+ ' from any packet.',
|
|
);
|
|
}
|
|
|
|
elementaryStream.info.avcCodecInfo
|
|
= extractAvcDecoderConfigurationRecord(context.suppliedPacket.data);
|
|
|
|
if (!elementaryStream.info.avcCodecInfo) {
|
|
continue; // Search the next packet for it
|
|
}
|
|
|
|
const spsUnit = elementaryStream.info.avcCodecInfo.sequenceParameterSets[0];
|
|
assert(spsUnit);
|
|
const spsInfo = parseAvcSps(spsUnit)!;
|
|
|
|
elementaryStream.info.width = spsInfo.displayWidth;
|
|
elementaryStream.info.height = spsInfo.displayHeight;
|
|
|
|
const num = spsInfo.pixelAspectRatio.num;
|
|
const den = spsInfo.pixelAspectRatio.den;
|
|
|
|
if (num > 0 && den > 0) {
|
|
if (num > den) {
|
|
elementaryStream.info.squarePixelWidth = Math.round(
|
|
elementaryStream.info.width * num / den,
|
|
);
|
|
elementaryStream.info.squarePixelHeight = elementaryStream.info.height;
|
|
} else {
|
|
elementaryStream.info.squarePixelWidth = elementaryStream.info.width;
|
|
elementaryStream.info.squarePixelHeight = Math.round(
|
|
elementaryStream.info.height * den / num,
|
|
);
|
|
}
|
|
}
|
|
|
|
elementaryStream.info.colorSpace = {
|
|
primaries:
|
|
COLOR_PRIMARIES_MAP_INVERSE[spsInfo.colourPrimaries] as
|
|
VideoColorPrimaries | undefined,
|
|
transfer:
|
|
TRANSFER_CHARACTERISTICS_MAP_INVERSE[spsInfo.transferCharacteristics] as
|
|
VideoTransferCharacteristics | undefined,
|
|
matrix:
|
|
MATRIX_COEFFICIENTS_MAP_INVERSE[spsInfo.matrixCoefficients] as
|
|
VideoMatrixCoefficients | undefined,
|
|
fullRange: !!spsInfo.fullRangeFlag,
|
|
};
|
|
elementaryStream.info.reorderSize = spsInfo.maxDecFrameBuffering;
|
|
|
|
break;
|
|
} else if (elementaryStream.info.codec === 'hevc') {
|
|
if (!context.suppliedPacket) {
|
|
throw new Error(
|
|
'Invalid HEVC video stream; could not extract HVCDecoderConfigurationRecord'
|
|
+ ' from first packet.',
|
|
);
|
|
}
|
|
|
|
elementaryStream.info.hevcCodecInfo
|
|
= extractHevcDecoderConfigurationRecord(context.suppliedPacket.data);
|
|
|
|
if (!elementaryStream.info.hevcCodecInfo) {
|
|
continue; // Search the next packet for it
|
|
}
|
|
|
|
const spsArray = elementaryStream.info.hevcCodecInfo.arrays.find(
|
|
a => a.nalUnitType === HevcNalUnitType.SPS_NUT,
|
|
)!;
|
|
const spsUnit = spsArray.nalUnits[0];
|
|
assert(spsUnit);
|
|
const spsInfo = parseHevcSps(spsUnit)!;
|
|
|
|
elementaryStream.info.width = spsInfo.displayWidth;
|
|
elementaryStream.info.height = spsInfo.displayHeight;
|
|
if (spsInfo.pixelAspectRatio.num > spsInfo.pixelAspectRatio.den) {
|
|
elementaryStream.info.squarePixelWidth = Math.round(
|
|
elementaryStream.info.width
|
|
* spsInfo.pixelAspectRatio.num / spsInfo.pixelAspectRatio.den,
|
|
);
|
|
elementaryStream.info.squarePixelHeight = elementaryStream.info.height;
|
|
} else {
|
|
elementaryStream.info.squarePixelWidth = elementaryStream.info.width;
|
|
elementaryStream.info.squarePixelHeight = Math.round(
|
|
elementaryStream.info.height
|
|
* spsInfo.pixelAspectRatio.den / spsInfo.pixelAspectRatio.num,
|
|
);
|
|
}
|
|
|
|
elementaryStream.info.colorSpace = {
|
|
primaries:
|
|
COLOR_PRIMARIES_MAP_INVERSE[spsInfo.colourPrimaries] as
|
|
VideoColorPrimaries | undefined,
|
|
transfer:
|
|
TRANSFER_CHARACTERISTICS_MAP_INVERSE[spsInfo.transferCharacteristics] as
|
|
VideoTransferCharacteristics | undefined,
|
|
matrix:
|
|
MATRIX_COEFFICIENTS_MAP_INVERSE[spsInfo.matrixCoefficients] as
|
|
VideoMatrixCoefficients | undefined,
|
|
fullRange: !!spsInfo.fullRangeFlag,
|
|
};
|
|
elementaryStream.info.reorderSize = spsInfo.maxDecFrameBuffering;
|
|
|
|
break;
|
|
} else {
|
|
throw new Error('Unhandled.');
|
|
}
|
|
}
|
|
|
|
elementaryStream.info.decoderConfig = {
|
|
codec: extractVideoCodecString({
|
|
width: elementaryStream.info.width,
|
|
height: elementaryStream.info.height,
|
|
codec: elementaryStream.info.codec,
|
|
codecDescription: null,
|
|
colorSpace: elementaryStream.info.colorSpace,
|
|
avcType: 1,
|
|
avcCodecInfo: elementaryStream.info.avcCodecInfo,
|
|
hevcCodecInfo: elementaryStream.info.hevcCodecInfo,
|
|
vp9CodecInfo: null,
|
|
av1CodecInfo: null,
|
|
}),
|
|
codedWidth: elementaryStream.info.width,
|
|
codedHeight: elementaryStream.info.height,
|
|
colorSpace: elementaryStream.info.colorSpace,
|
|
};
|
|
|
|
if (
|
|
elementaryStream.info.width !== elementaryStream.info.squarePixelWidth
|
|
|| elementaryStream.info.height !== elementaryStream.info.squarePixelHeight
|
|
) {
|
|
elementaryStream.info.decoderConfig.displayAspectWidth
|
|
= elementaryStream.info.squarePixelWidth;
|
|
elementaryStream.info.decoderConfig.displayAspectHeight
|
|
= elementaryStream.info.squarePixelHeight;
|
|
}
|
|
|
|
elementaryStream.initialized = true;
|
|
} else {
|
|
await context.markNextPacket();
|
|
if (!context.suppliedPacket) {
|
|
throw new Error(
|
|
`Couldn't parse first media packet for Elementary Stream with`
|
|
+ ` PID ${elementaryStream.pid}`,
|
|
);
|
|
}
|
|
|
|
if (elementaryStream.info.codec === 'aac') {
|
|
const slice = FileSlice.tempFromBytes(context.suppliedPacket.data);
|
|
const header = readAdtsFrameHeader(slice);
|
|
if (!header) {
|
|
throw new Error(
|
|
'Invalid AAC audio stream; could not read ADTS frame header from first packet.',
|
|
);
|
|
}
|
|
|
|
elementaryStream.info.aacCodecInfo = {
|
|
isMpeg2: false,
|
|
objectType: header.objectType,
|
|
};
|
|
elementaryStream.info.numberOfChannels
|
|
= aacChannelMap[header.channelConfiguration]!;
|
|
elementaryStream.info.sampleRate
|
|
= aacFrequencyTable[header.samplingFrequencyIndex]!;
|
|
} else if (elementaryStream.info.codec === 'mp3') {
|
|
const word = readU32Be(FileSlice.tempFromBytes(context.suppliedPacket.data));
|
|
const result = readMp3FrameHeader(word, context.suppliedPacket.data.byteLength);
|
|
if (!result.header) {
|
|
throw new Error(
|
|
'Invalid MP3 audio stream; could not read frame header from first packet.',
|
|
);
|
|
}
|
|
|
|
elementaryStream.info.numberOfChannels = getMp3ChannelCount(result.header.channel);
|
|
elementaryStream.info.sampleRate = result.header.sampleRate;
|
|
} else if (elementaryStream.info.codec === 'ac3') {
|
|
const frameInfo = parseAc3SyncFrame(context.suppliedPacket.data);
|
|
if (!frameInfo) {
|
|
throw new Error(
|
|
'Invalid AC-3 audio stream; could not read sync frame from first packet.',
|
|
);
|
|
}
|
|
|
|
if (frameInfo.fscod === 3) {
|
|
throw new Error(
|
|
'Invalid AC-3 audio stream; reserved sample rate code found in first packet.',
|
|
);
|
|
}
|
|
|
|
elementaryStream.info.numberOfChannels
|
|
= AC3_ACMOD_CHANNEL_COUNTS[frameInfo.acmod]! + frameInfo.lfeon;
|
|
elementaryStream.info.sampleRate = AC3_SAMPLE_RATES[frameInfo.fscod]!;
|
|
} else if (elementaryStream.info.codec === 'eac3') {
|
|
const frameInfo = parseEac3SyncFrame(context.suppliedPacket.data);
|
|
if (!frameInfo) {
|
|
throw new Error(
|
|
'Invalid E-AC-3 audio stream; could not read sync frame from first packet.',
|
|
);
|
|
}
|
|
|
|
const sampleRate = getEac3SampleRate(frameInfo);
|
|
if (sampleRate === null) {
|
|
throw new Error(
|
|
'Invalid E-AC-3 audio stream; reserved sample rate code found in first packet.',
|
|
);
|
|
}
|
|
|
|
elementaryStream.info.numberOfChannels = getEac3ChannelCount(frameInfo);
|
|
elementaryStream.info.sampleRate = sampleRate;
|
|
} else {
|
|
throw new Error('Unhandled.');
|
|
}
|
|
|
|
elementaryStream.info.decoderConfig = {
|
|
codec: extractAudioCodecString({
|
|
codec: elementaryStream.info.codec,
|
|
codecDescription: null,
|
|
aacCodecInfo: elementaryStream.info.aacCodecInfo,
|
|
}),
|
|
numberOfChannels: elementaryStream.info.numberOfChannels,
|
|
sampleRate: elementaryStream.info.sampleRate,
|
|
};
|
|
elementaryStream.initialized = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
const isDone = hasProgramMap && this.elementaryStreams.every(x => x.initialized);
|
|
if (isDone) {
|
|
break;
|
|
}
|
|
|
|
currentPos += this.packetStride;
|
|
}
|
|
|
|
if (!hasProgramMap) {
|
|
if (!hasProgramAssociationTable) {
|
|
throw new Error('No Program Association Table found in the file.');
|
|
}
|
|
|
|
throw new Error('No Program Map Table found in the file.');
|
|
}
|
|
|
|
for (const stream of this.elementaryStreams) {
|
|
if (stream.info.type === 'video') {
|
|
this.trackBackingEntries.push(
|
|
new MpegTsVideoTrackBacking(stream as ElementaryVideoStream),
|
|
);
|
|
} else {
|
|
this.trackBackingEntries.push(
|
|
new MpegTsAudioTrackBacking(stream as ElementaryAudioStream),
|
|
);
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
|
|
async getTrackBackings() {
|
|
await this.readMetadata();
|
|
return this.trackBackingEntries;
|
|
}
|
|
|
|
async getMetadataTags(): Promise<MetadataTags> {
|
|
return {}; // Nothing for now
|
|
}
|
|
|
|
async getMimeType(): Promise<string> {
|
|
await this.readMetadata();
|
|
|
|
const codecStrings = await Promise.all(this.trackBackingEntries.map(
|
|
x => x.getDecoderConfig().then(c => c?.codec ?? null),
|
|
));
|
|
|
|
return buildMpegTsMimeType(codecStrings);
|
|
}
|
|
|
|
async readSection(startPos: number, full: boolean, contiguous = false): Promise<Section | null> {
|
|
let endPos = startPos;
|
|
let currentPos = startPos;
|
|
const chunks: Uint8Array[] = [];
|
|
let chunksByteLength = 0;
|
|
let firstPacket: TsPacket | null = null;
|
|
let mustAddSectionEnd = true;
|
|
let randomAccessIndicator = 0;
|
|
|
|
while (true) {
|
|
const packet = await this.readPacket(currentPos);
|
|
currentPos += this.packetStride;
|
|
|
|
if (!packet) {
|
|
break;
|
|
}
|
|
|
|
if (!firstPacket) {
|
|
if (packet.payloadUnitStartIndicator === 0) {
|
|
break;
|
|
}
|
|
|
|
firstPacket = packet;
|
|
} else {
|
|
if (packet.pid !== firstPacket.pid) {
|
|
if (contiguous) {
|
|
break; // End of section
|
|
} else {
|
|
continue; // Ignore this packet
|
|
}
|
|
}
|
|
|
|
if (packet.payloadUnitStartIndicator === 1) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const hasAdaptationField = !!(packet.adaptationFieldControl & 0b10);
|
|
const hasPayload = !!(packet.adaptationFieldControl & 0b01);
|
|
|
|
let adaptationFieldLength = 0;
|
|
if (hasAdaptationField) {
|
|
adaptationFieldLength = 1 + packet.body[0]!;
|
|
|
|
// Extract random_access_indicator from first packet's adaptation field
|
|
if (packet === firstPacket && adaptationFieldLength > 1) {
|
|
randomAccessIndicator = (packet.body[1]! >> 6) & 1;
|
|
}
|
|
}
|
|
|
|
if (hasPayload) {
|
|
if (adaptationFieldLength === 0) {
|
|
chunks.push(packet.body);
|
|
chunksByteLength += packet.body.byteLength;
|
|
} else {
|
|
chunks.push(packet.body.subarray(adaptationFieldLength));
|
|
chunksByteLength += packet.body.byteLength - adaptationFieldLength;
|
|
}
|
|
}
|
|
|
|
endPos = currentPos;
|
|
|
|
// 64 is just "a bit of data", enough for the PES packet header
|
|
if (!full && chunksByteLength >= 64) {
|
|
mustAddSectionEnd = false; // Not the actual section end
|
|
break;
|
|
}
|
|
|
|
// Check if we already know this is a section end
|
|
const isKnownSectionEnd = binarySearchExact(this.sectionEndPositions, endPos, x => x) !== -1;
|
|
if (isKnownSectionEnd) {
|
|
mustAddSectionEnd = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (mustAddSectionEnd) {
|
|
const index = binarySearchLessOrEqual(this.sectionEndPositions, endPos, x => x);
|
|
this.sectionEndPositions.splice(index + 1, 0, endPos);
|
|
}
|
|
|
|
if (!firstPacket) {
|
|
return null;
|
|
}
|
|
|
|
let merged: Uint8Array;
|
|
if (chunks.length === 1) {
|
|
merged = chunks[0]!;
|
|
} else {
|
|
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
merged = new Uint8Array(totalLength);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
merged.set(chunk, offset);
|
|
offset += chunk.length;
|
|
}
|
|
}
|
|
|
|
return {
|
|
startPos,
|
|
endPos: full ? endPos : null,
|
|
pid: firstPacket.pid,
|
|
payload: merged,
|
|
randomAccessIndicator,
|
|
};
|
|
}
|
|
|
|
async readPacketHeader(pos: number): Promise<TsPacketHeader | null> {
|
|
let slice = this.reader.requestSlice(pos, 4);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
|
|
if (!slice) {
|
|
return null;
|
|
}
|
|
|
|
const syncByte = readU8(slice);
|
|
if (syncByte !== 0x47) {
|
|
throw new Error('Invalid TS packet sync byte. Likely an internal bug, please report this file.');
|
|
}
|
|
|
|
const nextTwoBytes = readU16Be(slice);
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const transportErrorIndicator = nextTwoBytes >> 15;
|
|
const payloadUnitStartIndicator = (nextTwoBytes >> 14) & 0x1;
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const transportPriority = (nextTwoBytes >> 13) & 0x1;
|
|
const pid = nextTwoBytes & 0x1FFF;
|
|
|
|
const nextByte = readU8(slice);
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const transportScramblingControl = nextByte >> 6;
|
|
const adaptationFieldControl = (nextByte >> 4) & 0x3;
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const continuityCounter = nextByte & 0xF;
|
|
|
|
return {
|
|
payloadUnitStartIndicator,
|
|
pid,
|
|
adaptationFieldControl,
|
|
};
|
|
}
|
|
|
|
async readPacket(pos: number): Promise<TsPacket | null> {
|
|
// Code in here is duplicated from readPacketHeader for performance reasons
|
|
let slice = this.reader.requestSlice(pos, TS_PACKET_SIZE);
|
|
if (slice instanceof Promise) slice = await slice;
|
|
|
|
if (!slice) {
|
|
return null;
|
|
}
|
|
|
|
const bytes = readBytes(slice, TS_PACKET_SIZE);
|
|
|
|
const syncByte = bytes[0]!;
|
|
if (syncByte !== 0x47) {
|
|
throw new Error('Invalid TS packet sync byte. Likely an internal bug, please report this file.');
|
|
}
|
|
|
|
const nextTwoBytes = (bytes[1]! << 8) + bytes[2]!;
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const transportErrorIndicator = nextTwoBytes >> 15;
|
|
const payloadUnitStartIndicator = (nextTwoBytes >> 14) & 0x1;
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const transportPriority = (nextTwoBytes >> 13) & 0x1;
|
|
const pid = nextTwoBytes & 0x1FFF;
|
|
|
|
const nextByte = bytes[3]!;
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const transportScramblingControl = nextByte >> 6;
|
|
const adaptationFieldControl = (nextByte >> 4) & 0x3;
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const continuityCounter = nextByte & 0xF;
|
|
|
|
return {
|
|
payloadUnitStartIndicator,
|
|
pid,
|
|
adaptationFieldControl,
|
|
body: bytes.subarray(4),
|
|
};
|
|
}
|
|
}
|
|
|
|
type PesPacketHeader = {
|
|
sectionStartPos: number;
|
|
sectionEndPos: number | null; // null if the section wasn't read fully
|
|
pts: number | null;
|
|
randomAccessIndicator: number;
|
|
};
|
|
|
|
type TimestampedPesPacketHeader = PesPacketHeader & {
|
|
pts: number;
|
|
};
|
|
|
|
type PesPacket = PesPacketHeader & {
|
|
data: Uint8Array<ArrayBufferLike>;
|
|
};
|
|
|
|
type TimestampedPesPacket = PesPacket & {
|
|
pts: number;
|
|
};
|
|
|
|
const readPesPacketHeader = <T extends boolean>(
|
|
section: Section,
|
|
expectPts: T,
|
|
): (T extends true ? TimestampedPesPacketHeader : PesPacketHeader) | null => {
|
|
if (section.payload.byteLength < 3) {
|
|
return null;
|
|
}
|
|
|
|
const bitstream = new Bitstream(section.payload);
|
|
|
|
const startCodePrefix = bitstream.readBits(24);
|
|
if (startCodePrefix !== 0x000001) {
|
|
return null;
|
|
}
|
|
|
|
const streamId = bitstream.readBits(8);
|
|
bitstream.skipBits(16);
|
|
|
|
if (
|
|
streamId === 0b10111100 // program_stream_map
|
|
|| streamId === 0b10111110 // padding_stream
|
|
|| streamId === 0b10111111 // private_stream_2
|
|
|| streamId === 0b11110000 // ECM
|
|
|| streamId === 0b11110001 // EMM
|
|
|| streamId === 0b11111111 // program_stream_directory
|
|
|| streamId === 0b11110010 // DSMCC_stream
|
|
|| streamId === 0b11111000 // ITU-T Rec. H.222.1 type E stream
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
bitstream.skipBits(8);
|
|
|
|
const ptsDtsFlags = bitstream.readBits(2);
|
|
|
|
bitstream.skipBits(14);
|
|
|
|
let pts: number | null = null;
|
|
if (ptsDtsFlags === 0b10 || ptsDtsFlags === 0b11) {
|
|
pts = 0;
|
|
|
|
bitstream.skipBits(4);
|
|
pts += bitstream.readBits(3) * (1 << 30);
|
|
bitstream.skipBits(1);
|
|
pts += bitstream.readBits(15) * (1 << 15);
|
|
bitstream.skipBits(1);
|
|
pts += bitstream.readBits(15);
|
|
} else {
|
|
if (expectPts) {
|
|
throw new Error(MISSING_PTS_ERROR_MESSAGE);
|
|
}
|
|
}
|
|
|
|
return {
|
|
sectionStartPos: section.startPos,
|
|
sectionEndPos: section.endPos,
|
|
pts,
|
|
randomAccessIndicator: section.randomAccessIndicator,
|
|
} as T extends true ? TimestampedPesPacketHeader : PesPacketHeader;
|
|
};
|
|
|
|
const readPesPacket = <T extends boolean>(
|
|
section: Section,
|
|
expectPts: T,
|
|
): (T extends true ? TimestampedPesPacket : PesPacket) | null => {
|
|
assert(section.endPos !== null); // Can only read full PES packets from fully read sections
|
|
|
|
const header = readPesPacketHeader(section, expectPts);
|
|
if (!header) {
|
|
return null;
|
|
}
|
|
|
|
const bitstream = new Bitstream(section.payload);
|
|
bitstream.skipBits(32);
|
|
const pesPacketLength = bitstream.readBits(16);
|
|
const BYTES_UNTIL_END_OF_PES_PACKET_LENGTH = 6;
|
|
|
|
bitstream.skipBits(16);
|
|
const pesHeaderDataLength = bitstream.readBits(8);
|
|
const pesHeaderEndPos = bitstream.pos + 8 * pesHeaderDataLength;
|
|
|
|
bitstream.pos = pesHeaderEndPos;
|
|
|
|
const bytePos = pesHeaderEndPos / 8;
|
|
assert(Number.isInteger(bytePos));
|
|
|
|
const data = section.payload.subarray(
|
|
bytePos,
|
|
// "A value of 0 indicates that the PES packet length is neither specified nor bounded and is allowed only in
|
|
// PES packets whose payload consists of bytes from a video elementary stream contained in
|
|
// transport stream packets."
|
|
pesPacketLength > 0
|
|
? BYTES_UNTIL_END_OF_PES_PACKET_LENGTH + pesPacketLength
|
|
: section.payload.byteLength,
|
|
);
|
|
|
|
return {
|
|
...header,
|
|
data,
|
|
} as T extends true ? TimestampedPesPacket : PesPacket;
|
|
};
|
|
|
|
abstract class MpegTsTrackBacking implements InputTrackBacking {
|
|
packetBuffers = new WeakMap<EncodedPacket, PacketBuffer>();
|
|
/** Used for recreating PacketBuffers if necessary. */
|
|
packetSectionStarts = new WeakMap<EncodedPacket, number>();
|
|
|
|
constructor(public elementaryStream: ElementaryStream) {}
|
|
|
|
abstract getType(): TrackType;
|
|
abstract getDecoderConfig(): Promise<VideoDecoderConfig | AudioDecoderConfig | null>;
|
|
|
|
getId() {
|
|
return this.elementaryStream.pid;
|
|
}
|
|
|
|
getNumber() {
|
|
const demuxer = this.elementaryStream.demuxer;
|
|
const trackType = this.elementaryStream.info.type;
|
|
|
|
let number = 0;
|
|
for (const backing of demuxer.trackBackingEntries) {
|
|
if (backing.getType() === trackType) {
|
|
number++;
|
|
}
|
|
|
|
assert(backing instanceof MpegTsTrackBacking);
|
|
if (backing.elementaryStream === this.elementaryStream) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return number;
|
|
}
|
|
|
|
getCodec(): MediaCodec | null {
|
|
throw new Error('Not implemented on base class.');
|
|
}
|
|
|
|
getInternalCodecId() {
|
|
return this.elementaryStream.streamType;
|
|
}
|
|
|
|
getName() {
|
|
return null;
|
|
}
|
|
|
|
getLanguageCode() {
|
|
return UNDETERMINED_LANGUAGE;
|
|
}
|
|
|
|
getDisposition(): TrackDisposition {
|
|
return {
|
|
...DEFAULT_TRACK_DISPOSITION,
|
|
primary: false,
|
|
};
|
|
}
|
|
|
|
getTimeResolution() {
|
|
return TIMESCALE;
|
|
}
|
|
|
|
isRelativeToUnixEpoch() {
|
|
return false;
|
|
}
|
|
|
|
getUnixTimeForTimestamp() {
|
|
return null;
|
|
}
|
|
|
|
getPairingMask() {
|
|
return 1n;
|
|
}
|
|
|
|
getBitrate() {
|
|
return null;
|
|
}
|
|
|
|
getAverageBitrate() {
|
|
return null;
|
|
}
|
|
|
|
async getDurationFromMetadata() {
|
|
return null;
|
|
}
|
|
|
|
async getLiveRefreshInterval() {
|
|
return null;
|
|
}
|
|
|
|
abstract allPacketsAreKeyPackets(): boolean;
|
|
abstract getReorderSize(): number;
|
|
|
|
createEncodedPacket(
|
|
suppliedPacket: SuppliedPacket,
|
|
duration: number,
|
|
options: PacketRetrievalOptions,
|
|
) {
|
|
let packetType: PacketType;
|
|
|
|
if (this.allPacketsAreKeyPackets()) {
|
|
packetType = 'key';
|
|
} else {
|
|
packetType = suppliedPacket.randomAccessIndicator === 1
|
|
? 'key'
|
|
: 'delta';
|
|
}
|
|
|
|
return new EncodedPacket(
|
|
options.metadataOnly ? PLACEHOLDER_DATA : suppliedPacket.data,
|
|
packetType,
|
|
suppliedPacket.pts / TIMESCALE,
|
|
Math.max(duration / TIMESCALE, 0),
|
|
suppliedPacket.sequenceNumber,
|
|
suppliedPacket.data.byteLength,
|
|
);
|
|
}
|
|
|
|
async getFirstPacket(options: PacketRetrievalOptions): Promise<EncodedPacket | null> {
|
|
const section = this.elementaryStream.firstSection;
|
|
assert(section);
|
|
|
|
const pesPacket = readPesPacket(section, true);
|
|
assert(pesPacket);
|
|
|
|
const context = new PacketReadingContext(this.elementaryStream, pesPacket);
|
|
const buffer = new PacketBuffer(this, context);
|
|
|
|
const result = await buffer.readNext();
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
const packet = this.createEncodedPacket(result.packet, result.duration, options);
|
|
this.packetBuffers.set(packet, buffer);
|
|
this.packetSectionStarts.set(packet, result.packet.sectionStartPos);
|
|
|
|
return packet;
|
|
}
|
|
|
|
async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise<EncodedPacket | null> {
|
|
let buffer = this.packetBuffers.get(packet);
|
|
|
|
if (buffer) {
|
|
// Fast path
|
|
const result = await buffer.readNext();
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
// Remove PacketBuffer access from the old packet, it belongs to the next packet now
|
|
this.packetBuffers.delete(packet);
|
|
|
|
const newPacket = this.createEncodedPacket(result.packet, result.duration, options);
|
|
this.packetBuffers.set(newPacket, buffer);
|
|
this.packetSectionStarts.set(newPacket, result.packet.sectionStartPos);
|
|
|
|
return newPacket;
|
|
}
|
|
|
|
// No buffer, we gotta do some rereading
|
|
const sectionStartPos = this.packetSectionStarts.get(packet);
|
|
if (sectionStartPos === undefined) {
|
|
throw new Error('Packet was not created from this track.');
|
|
}
|
|
|
|
const demuxer = this.elementaryStream.demuxer;
|
|
const section = await demuxer.readSection(sectionStartPos, true);
|
|
assert(section);
|
|
|
|
const pesPacket = readPesPacket(section, true);
|
|
assert(pesPacket);
|
|
|
|
const context = new PacketReadingContext(this.elementaryStream, pesPacket);
|
|
buffer = new PacketBuffer(this, context);
|
|
|
|
// Advance until we pass the current packet's sequence number
|
|
const targetSequenceNumber = packet.sequenceNumber;
|
|
while (true) {
|
|
const result = await buffer.readNext();
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
if (result.packet.sequenceNumber > targetSequenceNumber) {
|
|
// We found the next packet!
|
|
const newPacket = this.createEncodedPacket(result.packet, result.duration, options);
|
|
this.packetBuffers.set(newPacket, buffer);
|
|
this.packetSectionStarts.set(newPacket, result.packet.sectionStartPos);
|
|
return newPacket;
|
|
}
|
|
}
|
|
}
|
|
|
|
async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise<EncodedPacket | null> {
|
|
let currentPacket: EncodedPacket | null = packet;
|
|
|
|
// Just loop until we hit one
|
|
while (true) {
|
|
currentPacket = await this.getNextPacket(currentPacket, options);
|
|
|
|
if (!currentPacket) {
|
|
return null;
|
|
}
|
|
|
|
if (currentPacket.type === 'key') {
|
|
return currentPacket;
|
|
}
|
|
}
|
|
}
|
|
|
|
getPacket(timestamp: number, options: PacketRetrievalOptions): Promise<EncodedPacket | null> {
|
|
return this.doPacketLookup(timestamp, false, options);
|
|
}
|
|
|
|
getKeyPacket(timestamp: number, options: PacketRetrievalOptions): Promise<EncodedPacket | null> {
|
|
return this.doPacketLookup(timestamp, true, options);
|
|
}
|
|
|
|
/**
|
|
* Searches for the packet with the largest timestamp not larger than `timestamp` in the file, using a combination
|
|
* of chunk-based binary search and linear refinement. The reason the coarse search is done in large chunks is to
|
|
* make it more performant for small files and over high-latency readers such as the network.
|
|
*/
|
|
async doPacketLookup(
|
|
timestamp: number,
|
|
keyframesOnly: boolean,
|
|
options: PacketRetrievalOptions,
|
|
): Promise<EncodedPacket | null> {
|
|
const searchPts = roundIfAlmostInteger(timestamp * TIMESCALE);
|
|
|
|
const demuxer = this.elementaryStream.demuxer;
|
|
const { reader, seekChunkSize } = demuxer;
|
|
const pid = this.elementaryStream.pid;
|
|
|
|
const findFirstPesPacketHeaderInChunk = async (
|
|
startPos: number,
|
|
endPos: number,
|
|
readSectionInFull: boolean,
|
|
) => {
|
|
let currentPos = startPos;
|
|
|
|
while (currentPos < endPos) {
|
|
const packetHeader = await demuxer.readPacketHeader(currentPos);
|
|
if (!packetHeader) {
|
|
return null;
|
|
}
|
|
|
|
if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {
|
|
const section = await demuxer.readSection(currentPos, readSectionInFull);
|
|
if (!section) {
|
|
return null;
|
|
}
|
|
|
|
const pesPacketHeader = readPesPacketHeader(section, false);
|
|
if (pesPacketHeader && pesPacketHeader.pts !== null) {
|
|
return {
|
|
pesPacketHeader: pesPacketHeader as TimestampedPesPacketHeader,
|
|
section,
|
|
};
|
|
}
|
|
}
|
|
|
|
currentPos += demuxer.packetStride;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Get the first PES packet of the track
|
|
const firstSection = this.elementaryStream.firstSection;
|
|
assert(firstSection);
|
|
const firstPesPacketHeader = readPesPacketHeader(firstSection, true);
|
|
assert(firstPesPacketHeader);
|
|
|
|
if (searchPts < firstPesPacketHeader.pts) {
|
|
// We're before the first packet, definitely nothing here
|
|
return null;
|
|
}
|
|
|
|
let scanStartPos: number;
|
|
|
|
const referencePesPackets = this.elementaryStream.referencePesPackets;
|
|
const referencePointIndex = binarySearchLessOrEqual(referencePesPackets, searchPts, x => x.pts);
|
|
const referencePoint = referencePointIndex !== -1 ? referencePesPackets[referencePointIndex]! : null;
|
|
if (referencePoint && searchPts - referencePoint.pts < TIMESCALE / 2) {
|
|
// Reference point ain't too far away, prefer it over the chunk search
|
|
scanStartPos = referencePoint.sectionStartPos;
|
|
} else {
|
|
let startChunkIndex = 0;
|
|
|
|
if (reader.fileSize !== null) {
|
|
const numChunks = Math.ceil(reader.fileSize / seekChunkSize);
|
|
|
|
if (numChunks > 1) {
|
|
// Binary search to find the chunk with highest index whose first PES has pts <= searchPts
|
|
let low = 0;
|
|
let high = numChunks - 1;
|
|
startChunkIndex = low;
|
|
|
|
while (low <= high) {
|
|
const mid = Math.floor((low + high) / 2);
|
|
const chunkStartPos = floorToMultiple(mid * seekChunkSize, demuxer.packetStride)
|
|
+ firstPesPacketHeader.sectionStartPos;
|
|
const chunkEndPos = chunkStartPos + seekChunkSize;
|
|
|
|
const result = await findFirstPesPacketHeaderInChunk(chunkStartPos, chunkEndPos, false);
|
|
|
|
if (!result) {
|
|
// No PES packet found in this chunk, search left
|
|
high = mid - 1;
|
|
continue;
|
|
}
|
|
|
|
if (result.pesPacketHeader.pts <= searchPts) {
|
|
// This chunk's first PES is <= searchPts, it's a candidate
|
|
startChunkIndex = mid;
|
|
low = mid + 1; // Search right
|
|
} else {
|
|
// Search left
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
scanStartPos = floorToMultiple(
|
|
startChunkIndex * seekChunkSize,
|
|
demuxer.packetStride,
|
|
) + firstPesPacketHeader.sectionStartPos;
|
|
}
|
|
|
|
// Find the first PES packet at or after scanStartPos
|
|
const result = await findFirstPesPacketHeaderInChunk(
|
|
scanStartPos,
|
|
reader.fileSize ?? Infinity,
|
|
false,
|
|
);
|
|
|
|
let currentPesHeader = result?.pesPacketHeader ?? null;
|
|
if (!currentPesHeader) {
|
|
// Fall back to first packet
|
|
currentPesHeader = firstPesPacketHeader;
|
|
}
|
|
|
|
const reorderSize = this.getReorderSize();
|
|
|
|
const retrieveEncodedPacket = async (
|
|
sectionStartPos: number,
|
|
predicate: (packet: SuppliedPacket) => boolean,
|
|
) => {
|
|
// Load the relevant section in full
|
|
const section = await demuxer.readSection(sectionStartPos, true);
|
|
assert(section);
|
|
|
|
const pesPacket = readPesPacket(section, true);
|
|
assert(pesPacket);
|
|
|
|
const context = new PacketReadingContext(this.elementaryStream, pesPacket);
|
|
const buffer = new PacketBuffer(this, context);
|
|
|
|
// Advance until the top-most presentation timestamp crosses or equals searchPts
|
|
while (true) {
|
|
const topPts = last(buffer.presentationOrderPackets)?.pts ?? -Infinity;
|
|
if (topPts >= searchPts) {
|
|
break;
|
|
}
|
|
|
|
const didRead = await buffer.readNextPacket();
|
|
if (!didRead) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const targetIndex = findLastIndex(buffer.presentationOrderPackets, predicate);
|
|
if (targetIndex === -1) {
|
|
return null;
|
|
}
|
|
|
|
const targetPacket = buffer.presentationOrderPackets[targetIndex]!;
|
|
const lastDuration = targetIndex === 0
|
|
? 0
|
|
: targetPacket.pts - buffer.presentationOrderPackets[targetIndex - 1]!.pts;
|
|
|
|
// Pop packets in decode order until we hit the target packet
|
|
while (buffer.decodeOrderPackets[0] !== targetPacket) {
|
|
buffer.decodeOrderPackets.shift();
|
|
}
|
|
buffer.lastDuration = lastDuration; // Kinda ugly but necessary fix
|
|
|
|
const result = await buffer.readNext();
|
|
assert(result);
|
|
|
|
const packet = this.createEncodedPacket(result.packet, result.duration, options);
|
|
this.packetBuffers.set(packet, buffer);
|
|
this.packetSectionStarts.set(packet, result.packet.sectionStartPos);
|
|
|
|
return packet;
|
|
};
|
|
|
|
if (!keyframesOnly || this.allPacketsAreKeyPackets()) {
|
|
// Normat packet lookup case. Slightly easier since we just need to search (mostly) forward to find the
|
|
// packet.
|
|
|
|
// Linear scan to find the PES packet with largest pts <= searchPts. This will be used as the "midpoint"
|
|
// of the next refinement step (which is needed because of B-frames).
|
|
outer:
|
|
while (true) {
|
|
let currentPos = currentPesHeader.sectionStartPos + demuxer.packetStride;
|
|
|
|
while (true) {
|
|
const packetHeader = await demuxer.readPacketHeader(currentPos);
|
|
if (!packetHeader) {
|
|
break outer; // End of file
|
|
}
|
|
|
|
if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {
|
|
const section = await demuxer.readSection(currentPos, false);
|
|
if (section) {
|
|
const nextPesHeader = readPesPacketHeader(section, false);
|
|
if (nextPesHeader && nextPesHeader.pts !== null) {
|
|
if (nextPesHeader.pts > searchPts) {
|
|
break outer;
|
|
}
|
|
|
|
currentPesHeader = nextPesHeader as TimestampedPesPacketHeader;
|
|
maybeInsertReferencePacket(this.elementaryStream, currentPesHeader);
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
currentPos += demuxer.packetStride;
|
|
}
|
|
}
|
|
|
|
// Rewind by reorderSize + 1 PES packets (even for audio! To ensure proper durations)
|
|
outer:
|
|
for (let i = 0; i < reorderSize + 1; i++) {
|
|
let pos = currentPesHeader.sectionStartPos - demuxer.packetStride;
|
|
|
|
while (pos >= demuxer.packetOffset) {
|
|
const packetHeader = await demuxer.readPacketHeader(pos);
|
|
if (!packetHeader) {
|
|
break outer;
|
|
}
|
|
|
|
if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {
|
|
const section = await demuxer.readSection(pos, false);
|
|
if (section) {
|
|
const header = readPesPacketHeader(section, false);
|
|
if (header && header.pts !== null) {
|
|
currentPesHeader = header as TimestampedPesPacketHeader;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
pos -= demuxer.packetStride;
|
|
}
|
|
}
|
|
|
|
return retrieveEncodedPacket(currentPesHeader.sectionStartPos, p => p.pts <= searchPts);
|
|
} else {
|
|
// Key packet lookup case. Slightly harder since the starting chunk may not have a key packet at all, which
|
|
// means we might need to search the previous chunks until we find something.
|
|
|
|
let currentChunkStartPos = scanStartPos;
|
|
let nextChunkStartPos: number | null = null; // "next" as in later in the file, even tho we scan backwards
|
|
|
|
const readSectionsInFull = !this.elementaryStream.canBeTrustedWithKeyPackets;
|
|
|
|
while (true) {
|
|
let bestKeyPesHeader: TimestampedPesPacketHeader | null = null;
|
|
|
|
const isFirstChunk = currentChunkStartPos <= firstPesPacketHeader.sectionStartPos;
|
|
|
|
let pesHeader: TimestampedPesPacketHeader | null;
|
|
let pesHeaderSection: Section | null = null;
|
|
|
|
if (isFirstChunk) {
|
|
pesHeader = firstPesPacketHeader;
|
|
pesHeaderSection = firstSection;
|
|
} else {
|
|
const result = await findFirstPesPacketHeaderInChunk(
|
|
currentChunkStartPos,
|
|
reader.fileSize ?? Infinity,
|
|
readSectionsInFull,
|
|
);
|
|
|
|
pesHeader = result?.pesPacketHeader ?? null;
|
|
pesHeaderSection = result?.section ?? null;
|
|
}
|
|
|
|
let passedSearchPts = false;
|
|
let lookaheadCount = 0;
|
|
|
|
outer:
|
|
while (pesHeader) {
|
|
if (nextChunkStartPos !== null && pesHeader.sectionStartPos >= nextChunkStartPos) {
|
|
// Stop at the next chunk boundary
|
|
break;
|
|
}
|
|
|
|
if (pesHeader.pts <= searchPts) {
|
|
let isKeyPacket: boolean;
|
|
if (this.elementaryStream.canBeTrustedWithKeyPackets) {
|
|
isKeyPacket = pesHeader.randomAccessIndicator === 1;
|
|
} else {
|
|
assert(pesHeaderSection);
|
|
const pesPacket = readPesPacket(pesHeaderSection, true);
|
|
assert(pesPacket);
|
|
|
|
const context = new PacketReadingContext(this.elementaryStream, pesPacket);
|
|
await context.markNextPacket();
|
|
|
|
isKeyPacket = context.suppliedPacket?.randomAccessIndicator === 1;
|
|
}
|
|
|
|
if (isKeyPacket) {
|
|
bestKeyPesHeader = pesHeader;
|
|
}
|
|
}
|
|
|
|
if (pesHeader.pts > searchPts) {
|
|
passedSearchPts = true;
|
|
}
|
|
|
|
// If we've passed searchPts, do lookahead for reorderSize more packets just to be sure
|
|
if (passedSearchPts) {
|
|
lookaheadCount++;
|
|
|
|
if (lookaheadCount > reorderSize) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Find next PES packet
|
|
let currentPos = pesHeader.sectionStartPos + demuxer.packetStride;
|
|
|
|
while (true) {
|
|
const packetHeader = await demuxer.readPacketHeader(currentPos);
|
|
if (!packetHeader) {
|
|
break outer; // End of file
|
|
}
|
|
|
|
if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {
|
|
const section = await demuxer.readSection(currentPos, readSectionsInFull);
|
|
if (section) {
|
|
const nextPesHeader = readPesPacketHeader(section, false);
|
|
|
|
if (nextPesHeader && nextPesHeader.pts !== null) {
|
|
pesHeader = nextPesHeader as TimestampedPesPacketHeader;
|
|
pesHeaderSection = section;
|
|
maybeInsertReferencePacket(this.elementaryStream, pesHeader);
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
currentPos += demuxer.packetStride;
|
|
}
|
|
}
|
|
|
|
if (bestKeyPesHeader) {
|
|
let startPesHeader = bestKeyPesHeader;
|
|
|
|
if (lookaheadCount === 0) {
|
|
// Packet is at the end of stream, let's rewind a little to obtain the correct packet duration
|
|
outer:
|
|
for (let i = 0; i < reorderSize; i++) {
|
|
let pos = startPesHeader.sectionStartPos - demuxer.packetStride;
|
|
|
|
while (pos >= demuxer.packetOffset) {
|
|
const packetHeader = await demuxer.readPacketHeader(pos);
|
|
if (!packetHeader) {
|
|
break outer;
|
|
}
|
|
|
|
if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {
|
|
const section = await demuxer.readSection(pos, readSectionsInFull);
|
|
if (section) {
|
|
const header = readPesPacketHeader(section, false);
|
|
if (header && header.pts !== null) {
|
|
startPesHeader = header as TimestampedPesPacketHeader;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
pos -= demuxer.packetStride;
|
|
}
|
|
}
|
|
}
|
|
|
|
const encodedPacket = await retrieveEncodedPacket(
|
|
startPesHeader.sectionStartPos,
|
|
p => p.pts <= searchPts && p.randomAccessIndicator === 1,
|
|
);
|
|
assert(encodedPacket); // There must be one
|
|
|
|
return encodedPacket;
|
|
}
|
|
|
|
if (isFirstChunk) {
|
|
return null;
|
|
}
|
|
|
|
// No key frame found in this chunk, move one chunk to the left
|
|
nextChunkStartPos = currentChunkStartPos;
|
|
currentChunkStartPos = Math.max(
|
|
floorToMultiple(
|
|
currentChunkStartPos - firstPesPacketHeader.sectionStartPos - seekChunkSize,
|
|
demuxer.packetStride,
|
|
) + firstPesPacketHeader.sectionStartPos,
|
|
firstPesPacketHeader.sectionStartPos,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class MpegTsVideoTrackBacking extends MpegTsTrackBacking implements InputVideoTrackBacking {
|
|
override elementaryStream!: ElementaryVideoStream;
|
|
|
|
getType() {
|
|
return 'video' as const;
|
|
}
|
|
|
|
override getCodec(): VideoCodec {
|
|
return this.elementaryStream.info.codec;
|
|
}
|
|
|
|
getCodedWidth() {
|
|
return this.elementaryStream.info.width;
|
|
}
|
|
|
|
getCodedHeight() {
|
|
return this.elementaryStream.info.height;
|
|
}
|
|
|
|
getSquarePixelWidth() {
|
|
return this.elementaryStream.info.squarePixelWidth;
|
|
}
|
|
|
|
getSquarePixelHeight() {
|
|
return this.elementaryStream.info.squarePixelHeight;
|
|
}
|
|
|
|
getRotation(): Rotation {
|
|
return 0;
|
|
}
|
|
|
|
async getColorSpace(): Promise<VideoColorSpaceInit> {
|
|
return this.elementaryStream.info.colorSpace;
|
|
}
|
|
|
|
async canBeTransparent() {
|
|
return false;
|
|
}
|
|
|
|
async getDecoderConfig(): Promise<VideoDecoderConfig> {
|
|
assert(this.elementaryStream.info.decoderConfig);
|
|
return this.elementaryStream.info.decoderConfig;
|
|
}
|
|
|
|
override allPacketsAreKeyPackets(): boolean {
|
|
return false;
|
|
}
|
|
|
|
override getReorderSize(): number {
|
|
return this.elementaryStream.info.reorderSize;
|
|
}
|
|
}
|
|
|
|
class MpegTsAudioTrackBacking extends MpegTsTrackBacking implements InputAudioTrackBacking {
|
|
override elementaryStream!: ElementaryAudioStream;
|
|
|
|
getType() {
|
|
return 'audio' as const;
|
|
}
|
|
|
|
override getCodec(): AudioCodec {
|
|
return this.elementaryStream.info.codec;
|
|
}
|
|
|
|
getNumberOfChannels() {
|
|
return this.elementaryStream.info.numberOfChannels;
|
|
}
|
|
|
|
getSampleRate() {
|
|
return this.elementaryStream.info.sampleRate;
|
|
}
|
|
|
|
async getDecoderConfig(): Promise<AudioDecoderConfig> {
|
|
assert(this.elementaryStream.info.decoderConfig);
|
|
return this.elementaryStream.info.decoderConfig;
|
|
}
|
|
|
|
override allPacketsAreKeyPackets(): boolean {
|
|
return true;
|
|
}
|
|
|
|
override getReorderSize(): number {
|
|
return 0; // No reordering, since no B-frames because goated
|
|
}
|
|
}
|
|
|
|
const maybeInsertReferencePacket = (
|
|
elementaryStream: ElementaryStream,
|
|
pesPacketHeader: TimestampedPesPacketHeader,
|
|
) => {
|
|
const referencePesPackets = elementaryStream.referencePesPackets;
|
|
|
|
const index = binarySearchLessOrEqual(
|
|
referencePesPackets,
|
|
pesPacketHeader.sectionStartPos,
|
|
x => x.sectionStartPos,
|
|
);
|
|
if (index >= 0) {
|
|
// Since pts and file position don't necessarily have a monotonic relationship (since pts can go crazy),
|
|
// let's see if inserting at the given index would violate the pts order. If so, return.
|
|
const entry = referencePesPackets[index]!;
|
|
if (pesPacketHeader.pts <= entry.pts) {
|
|
return false;
|
|
}
|
|
|
|
const minByteDistance = elementaryStream.demuxer.minReferencePointByteDistance;
|
|
|
|
if (pesPacketHeader.sectionStartPos - entry.sectionStartPos < minByteDistance) {
|
|
// Too close
|
|
return false;
|
|
}
|
|
|
|
if (index < referencePesPackets.length - 1) {
|
|
const nextEntry = referencePesPackets[index + 1]!;
|
|
if (nextEntry.pts < pesPacketHeader.pts) {
|
|
// Out of order
|
|
return false;
|
|
}
|
|
|
|
if (nextEntry.sectionStartPos - pesPacketHeader.sectionStartPos < minByteDistance) {
|
|
// Too close
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
referencePesPackets.splice(index + 1, 0, pesPacketHeader);
|
|
return true;
|
|
};
|
|
|
|
type SuppliedPacket = {
|
|
pts: number;
|
|
data: Uint8Array;
|
|
sequenceNumber: number;
|
|
sectionStartPos: number;
|
|
randomAccessIndicator: number;
|
|
};
|
|
|
|
/** Stateful context used to extract exact encoded packets from the underlying data stream. */
|
|
class PacketReadingContext {
|
|
elementaryStream: ElementaryStream;
|
|
pid: number;
|
|
demuxer: MpegTsDemuxer;
|
|
startingPesPacket: TimestampedPesPacket;
|
|
|
|
currentPos = 0; // Relative to the data in startingPesPacket
|
|
pesPackets: PesPacket[] = [];
|
|
currentPesPacketIndex = 0;
|
|
currentPesPacketPos = 0;
|
|
endPos = 0;
|
|
lastSuppliedPesPacket: PesPacket | null = null;
|
|
nextPts: number | null = null;
|
|
|
|
suppliedPacket: SuppliedPacket | null = null;
|
|
|
|
constructor(elementaryStream: ElementaryStream, startingPesPacket: TimestampedPesPacket) {
|
|
this.elementaryStream = elementaryStream;
|
|
this.pid = elementaryStream.pid;
|
|
this.demuxer = elementaryStream.demuxer;
|
|
this.startingPesPacket = startingPesPacket;
|
|
}
|
|
|
|
ensureBuffered(length: number) {
|
|
const remaining = this.endPos - this.currentPos;
|
|
if (remaining >= length) {
|
|
return length;
|
|
}
|
|
|
|
return this.bufferData(length - remaining)
|
|
.then(() => Math.min(this.endPos - this.currentPos, length));
|
|
}
|
|
|
|
getCurrentPesPacket() {
|
|
const packet = this.pesPackets[this.currentPesPacketIndex];
|
|
assert(packet);
|
|
|
|
return packet;
|
|
}
|
|
|
|
async bufferData(length: number): Promise<void> {
|
|
const targetEndPos = this.endPos + length;
|
|
|
|
while (this.endPos < targetEndPos) {
|
|
let pesPacket: PesPacket;
|
|
if (this.pesPackets.length === 0) {
|
|
pesPacket = this.startingPesPacket;
|
|
} else {
|
|
// Find the next PES packet
|
|
let currentPos = last(this.pesPackets)!.sectionEndPos;
|
|
assert(currentPos !== null);
|
|
|
|
while (true) {
|
|
const packetHeader = await this.demuxer.readPacketHeader(currentPos);
|
|
if (!packetHeader) {
|
|
return;
|
|
}
|
|
|
|
if (packetHeader.pid === this.pid) {
|
|
const nextSection = await this.demuxer.readSection(currentPos, true);
|
|
if (!nextSection) {
|
|
return;
|
|
}
|
|
|
|
const nextPesPacket = readPesPacket(nextSection, false);
|
|
if (nextPesPacket) {
|
|
pesPacket = nextPesPacket;
|
|
break;
|
|
}
|
|
}
|
|
|
|
currentPos += this.demuxer.packetStride;
|
|
}
|
|
}
|
|
|
|
this.pesPackets.push(pesPacket);
|
|
this.endPos += pesPacket.data.byteLength;
|
|
}
|
|
}
|
|
|
|
readBytes(length: number) {
|
|
const currentPesPacket = this.getCurrentPesPacket();
|
|
|
|
const relativeStartOffset = this.currentPos - this.currentPesPacketPos;
|
|
const relativeEndOffset = relativeStartOffset + length;
|
|
|
|
this.currentPos += length;
|
|
|
|
if (relativeEndOffset <= currentPesPacket.data.byteLength) {
|
|
// Request can be satisfied with one PES packet
|
|
return currentPesPacket.data.subarray(relativeStartOffset, relativeEndOffset);
|
|
}
|
|
|
|
// Data spans multiple PES packets, we must do some merging
|
|
const result = new Uint8Array(length);
|
|
result.set(currentPesPacket.data.subarray(relativeStartOffset));
|
|
let offset = currentPesPacket.data.byteLength - relativeStartOffset;
|
|
|
|
while (true) {
|
|
this.advanceCurrentPacket();
|
|
const currentPesPacket = this.getCurrentPesPacket();
|
|
const relativeEndOffset = length - offset;
|
|
|
|
if (relativeEndOffset <= currentPesPacket.data.byteLength) {
|
|
result.set(currentPesPacket.data.subarray(0, relativeEndOffset), offset);
|
|
break;
|
|
}
|
|
|
|
result.set(currentPesPacket.data, offset);
|
|
offset += currentPesPacket.data.byteLength;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
readU8() {
|
|
let currentPesPacket = this.getCurrentPesPacket();
|
|
|
|
const relativeOffset = this.currentPos - this.currentPesPacketPos;
|
|
this.currentPos++;
|
|
|
|
if (relativeOffset < currentPesPacket.data.byteLength) {
|
|
return currentPesPacket.data[relativeOffset]!;
|
|
}
|
|
|
|
this.advanceCurrentPacket();
|
|
|
|
currentPesPacket = this.getCurrentPesPacket();
|
|
return currentPesPacket.data[0]!;
|
|
}
|
|
|
|
seekTo(pos: number) {
|
|
if (pos === this.currentPos) {
|
|
return;
|
|
}
|
|
|
|
if (pos < this.currentPos) {
|
|
while (pos < this.currentPesPacketPos) {
|
|
// Move to the previous PES packet
|
|
this.currentPesPacketIndex--;
|
|
const currentPacket = this.getCurrentPesPacket();
|
|
this.currentPesPacketPos -= currentPacket.data.byteLength;
|
|
}
|
|
} else {
|
|
while (true) {
|
|
// Move to the next PES packet
|
|
const currentPesPacket = this.getCurrentPesPacket();
|
|
const currentEndPos = this.currentPesPacketPos + currentPesPacket.data.byteLength;
|
|
|
|
if (pos < currentEndPos) {
|
|
break;
|
|
}
|
|
|
|
this.currentPesPacketPos += currentPesPacket.data.byteLength;
|
|
this.currentPesPacketIndex++;
|
|
}
|
|
}
|
|
|
|
this.currentPos = pos;
|
|
}
|
|
|
|
skip(n: number) {
|
|
this.seekTo(this.currentPos + n);
|
|
}
|
|
|
|
advanceCurrentPacket() {
|
|
this.currentPesPacketPos += this.getCurrentPesPacket().data.byteLength;
|
|
this.currentPesPacketIndex++;
|
|
}
|
|
|
|
async markNextPacket() {
|
|
assert(!this.suppliedPacket);
|
|
|
|
const elementaryStream = this.elementaryStream;
|
|
|
|
if (elementaryStream.info.type === 'video') {
|
|
const codec = elementaryStream.info.codec;
|
|
const CHUNK_SIZE = 1024;
|
|
|
|
if (codec !== 'avc' && codec !== 'hevc') {
|
|
throw new Error('Unhandled.');
|
|
}
|
|
|
|
let packetStartPos: number | null = null;
|
|
|
|
while (true) {
|
|
let remaining = this.ensureBuffered(CHUNK_SIZE);
|
|
if (remaining instanceof Promise) remaining = await remaining;
|
|
|
|
if (remaining === 0) {
|
|
break;
|
|
}
|
|
|
|
const chunkStartPos = this.currentPos;
|
|
const chunk = this.readBytes(remaining);
|
|
const length = chunk.byteLength;
|
|
|
|
let i = 0;
|
|
while (i < length) {
|
|
const zeroIndex = chunk.indexOf(0, i);
|
|
if (zeroIndex === -1 || zeroIndex >= length) {
|
|
break;
|
|
}
|
|
i = zeroIndex;
|
|
|
|
// Check if we have enough bytes to identify a start code
|
|
const posBeforeZero = chunkStartPos + i;
|
|
|
|
// Need at least 4 more bytes after the 0x00 to check for start code + NAL type
|
|
if (i + 4 >= length) {
|
|
// Not enough data in current chunk, seek back and let the next iteration handle it
|
|
this.seekTo(posBeforeZero);
|
|
break;
|
|
}
|
|
|
|
const b1 = chunk[i + 1]!;
|
|
const b2 = chunk[i + 2]!;
|
|
const b3 = chunk[i + 3]!;
|
|
|
|
let startCodeLength = 0;
|
|
let nalUnitTypeByte: number | null = null;
|
|
|
|
// Check for 4-byte start code (0x00000001)
|
|
if (b1 === 0x00 && b2 === 0x00 && b3 === 0x01) {
|
|
startCodeLength = 4;
|
|
nalUnitTypeByte = chunk[i + 4]!;
|
|
} else if (b1 === 0x00 && b2 === 0x01) {
|
|
// 3-byte start code (0x000001)
|
|
startCodeLength = 3;
|
|
nalUnitTypeByte = b3;
|
|
}
|
|
|
|
if (startCodeLength === 0) {
|
|
// Not a start code, continue
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
const startCodePos = posBeforeZero;
|
|
|
|
if (packetStartPos === null) {
|
|
// This is our first start code, mark packet start
|
|
packetStartPos = startCodePos;
|
|
i += startCodeLength;
|
|
continue;
|
|
}
|
|
|
|
// We have a second start code. Check if it's an AUD.
|
|
if (nalUnitTypeByte !== null) {
|
|
const nalUnitType = codec === 'avc'
|
|
? extractNalUnitTypeForAvc(nalUnitTypeByte)
|
|
: extractNalUnitTypeForHevc(nalUnitTypeByte);
|
|
const isAud = codec === 'avc'
|
|
? nalUnitType === AvcNalUnitType.AUD
|
|
: nalUnitType === HevcNalUnitType.AUD_NUT;
|
|
|
|
if (isAud) {
|
|
// End the packet at this start code (before the AUD)
|
|
const packetLength = startCodePos - packetStartPos;
|
|
this.seekTo(packetStartPos);
|
|
return this.supplyPacket(packetLength, 0);
|
|
}
|
|
}
|
|
|
|
// Not an AUD, continue searching
|
|
i += startCodeLength;
|
|
}
|
|
|
|
if (remaining < CHUNK_SIZE) {
|
|
// End of stream
|
|
break;
|
|
}
|
|
}
|
|
|
|
// End of stream - return remaining data if we have a packet start
|
|
if (packetStartPos !== null) {
|
|
const packetLength = this.endPos - packetStartPos;
|
|
this.seekTo(packetStartPos);
|
|
return this.supplyPacket(packetLength, 0);
|
|
}
|
|
} else {
|
|
const codec = elementaryStream.info.codec;
|
|
const CHUNK_SIZE = 128;
|
|
|
|
while (true) {
|
|
let remaining = this.ensureBuffered(CHUNK_SIZE);
|
|
if (remaining instanceof Promise) remaining = await remaining;
|
|
|
|
const startPos = this.currentPos;
|
|
|
|
while (this.currentPos - startPos < remaining) {
|
|
const byte = this.readU8();
|
|
|
|
if (codec === 'aac') {
|
|
if (byte !== 0xff) {
|
|
continue;
|
|
}
|
|
|
|
this.skip(-1);
|
|
const possibleHeaderStartPos = this.currentPos;
|
|
|
|
let remaining = this.ensureBuffered(MAX_ADTS_FRAME_HEADER_SIZE);
|
|
if (remaining instanceof Promise) remaining = await remaining;
|
|
|
|
if (remaining < MAX_ADTS_FRAME_HEADER_SIZE) {
|
|
return;
|
|
}
|
|
|
|
const headerBytes = this.readBytes(MAX_ADTS_FRAME_HEADER_SIZE);
|
|
const header = readAdtsFrameHeader(FileSlice.tempFromBytes(headerBytes));
|
|
|
|
if (header) {
|
|
this.seekTo(possibleHeaderStartPos);
|
|
|
|
let remaining = this.ensureBuffered(header.frameLength);
|
|
if (remaining instanceof Promise) remaining = await remaining;
|
|
|
|
return this.supplyPacket(
|
|
remaining,
|
|
Math.round(SAMPLES_PER_AAC_FRAME * TIMESCALE / elementaryStream.info.sampleRate),
|
|
);
|
|
} else {
|
|
this.seekTo(possibleHeaderStartPos + 1);
|
|
}
|
|
} else if (codec === 'mp3') {
|
|
if (byte !== 0xff) {
|
|
continue;
|
|
}
|
|
|
|
this.skip(-1);
|
|
const possibleHeaderStartPos = this.currentPos;
|
|
|
|
let remaining = this.ensureBuffered(MP3_FRAME_HEADER_SIZE);
|
|
if (remaining instanceof Promise) remaining = await remaining;
|
|
|
|
if (remaining < MP3_FRAME_HEADER_SIZE) {
|
|
return;
|
|
}
|
|
|
|
const headerBytes = this.readBytes(MP3_FRAME_HEADER_SIZE);
|
|
const word = toDataView(headerBytes).getUint32(0);
|
|
const result = readMp3FrameHeader(word, null);
|
|
|
|
if (result.header) {
|
|
this.seekTo(possibleHeaderStartPos);
|
|
|
|
let remaining = this.ensureBuffered(result.header.totalSize);
|
|
if (remaining instanceof Promise) remaining = await remaining;
|
|
|
|
const duration = result.header.audioSamplesInFrame * TIMESCALE
|
|
/ elementaryStream.info.sampleRate;
|
|
return this.supplyPacket(remaining, Math.round(duration));
|
|
} else {
|
|
this.seekTo(possibleHeaderStartPos + 1);
|
|
}
|
|
} else if (codec === 'ac3') {
|
|
if (byte !== 0x0b) {
|
|
continue;
|
|
}
|
|
|
|
this.skip(-1);
|
|
const possibleSyncPos = this.currentPos;
|
|
|
|
// Need at least 5 bytes for sync word + CRC + fscod/frmsizecod
|
|
let remaining = this.ensureBuffered(5);
|
|
if (remaining instanceof Promise) remaining = await remaining;
|
|
|
|
if (remaining < 5) {
|
|
return;
|
|
}
|
|
|
|
const headerBytes = this.readBytes(5);
|
|
|
|
// Verify sync word (0x0B77)
|
|
if (headerBytes[0] !== 0x0b || headerBytes[1] !== 0x77) {
|
|
this.seekTo(possibleSyncPos + 1);
|
|
continue;
|
|
}
|
|
|
|
const fscod = headerBytes[4]! >> 6;
|
|
const frmsizecod = headerBytes[4]! & 0x3f;
|
|
|
|
if (fscod === 3 || frmsizecod > 37) {
|
|
// Invalid
|
|
this.seekTo(possibleSyncPos + 1);
|
|
continue;
|
|
}
|
|
|
|
const frameSize = AC3_FRAME_SIZES[3 * frmsizecod + fscod];
|
|
assert(frameSize !== undefined);
|
|
|
|
this.seekTo(possibleSyncPos);
|
|
|
|
remaining = this.ensureBuffered(frameSize);
|
|
if (remaining instanceof Promise) remaining = await remaining;
|
|
|
|
const duration = Math.round(
|
|
AC3_SAMPLES_PER_FRAME * TIMESCALE / elementaryStream.info.sampleRate,
|
|
);
|
|
return this.supplyPacket(remaining, duration);
|
|
} else if (codec === 'eac3') {
|
|
if (byte !== 0x0b) {
|
|
continue;
|
|
}
|
|
|
|
this.skip(-1);
|
|
const possibleSyncPos = this.currentPos;
|
|
|
|
// Need at least 5 bytes for E-AC-3 header parsing (sync word + frmsiz + fscod/numblkscod)
|
|
let remaining = this.ensureBuffered(5);
|
|
if (remaining instanceof Promise) remaining = await remaining;
|
|
|
|
if (remaining < 5) {
|
|
return;
|
|
}
|
|
|
|
const headerBytes = this.readBytes(5);
|
|
|
|
if (headerBytes[0] !== 0x0b || headerBytes[1] !== 0x77) {
|
|
this.seekTo(possibleSyncPos + 1);
|
|
continue;
|
|
}
|
|
|
|
const frmsiz = ((headerBytes[2]! & 0x07) << 8) | headerBytes[3]!;
|
|
const frameSize = (frmsiz + 1) * 2;
|
|
const fscod = headerBytes[4]! >> 6;
|
|
const numblkscod = fscod === 3 ? 3 : (headerBytes[4]! >> 4) & 0x03;
|
|
const numblks = EAC3_NUMBLKS_TABLE[numblkscod]!;
|
|
|
|
this.seekTo(possibleSyncPos);
|
|
|
|
remaining = this.ensureBuffered(frameSize);
|
|
if (remaining instanceof Promise) remaining = await remaining;
|
|
|
|
// Duration = numblks * 256 samples per block
|
|
const samplesPerFrame = numblks * 256;
|
|
const duration = Math.round(
|
|
samplesPerFrame * TIMESCALE / elementaryStream.info.sampleRate,
|
|
);
|
|
return this.supplyPacket(remaining, duration);
|
|
} else {
|
|
throw new Error('Unhandled.');
|
|
}
|
|
}
|
|
|
|
if (remaining < CHUNK_SIZE) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Supplies the context with a new encoded packet, beginning at the current position. */
|
|
supplyPacket(packetLength: number, intrinsicDuration: number) {
|
|
const currentPesPacket = this.getCurrentPesPacket();
|
|
|
|
let pts: number;
|
|
if (this.lastSuppliedPesPacket === currentPesPacket) {
|
|
assert(this.nextPts !== null);
|
|
pts = this.nextPts;
|
|
} else {
|
|
if (currentPesPacket.pts === null) {
|
|
throw new Error(MISSING_PTS_ERROR_MESSAGE);
|
|
}
|
|
|
|
pts = currentPesPacket.pts;
|
|
|
|
maybeInsertReferencePacket(this.elementaryStream, currentPesPacket as TimestampedPesPacket);
|
|
}
|
|
|
|
this.lastSuppliedPesPacket = currentPesPacket;
|
|
this.nextPts = pts + intrinsicDuration;
|
|
|
|
const sectionStartPos = currentPesPacket.sectionStartPos;
|
|
// The sequence number is the starting position of the section the PES packet is in, PLUS the offset within the
|
|
// PES packet where the packet starts.
|
|
const sequenceNumber = sectionStartPos + (this.currentPos - this.currentPesPacketPos);
|
|
const data = this.readBytes(packetLength);
|
|
|
|
let randomAccessIndicator = currentPesPacket.randomAccessIndicator;
|
|
|
|
if (randomAccessIndicator === 0 && !this.elementaryStream.canBeTrustedWithKeyPackets) {
|
|
if (this.elementaryStream.info.type === 'audio') {
|
|
randomAccessIndicator = 1;
|
|
} else {
|
|
if (this.elementaryStream.info.decoderConfig) {
|
|
const isKey = determineVideoPacketType(
|
|
this.elementaryStream.info.codec,
|
|
this.elementaryStream.info.decoderConfig,
|
|
data,
|
|
) === 'key';
|
|
|
|
randomAccessIndicator = Number(isKey);
|
|
} else {
|
|
// We're reading packets before the decoder config is determined
|
|
}
|
|
}
|
|
}
|
|
|
|
this.suppliedPacket = {
|
|
pts,
|
|
data,
|
|
sequenceNumber,
|
|
sectionStartPos,
|
|
randomAccessIndicator,
|
|
};
|
|
|
|
this.pesPackets.splice(0, this.currentPesPacketIndex);
|
|
this.currentPesPacketIndex = 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A buffer that simulates decoder frame reordering to compute packet durations. Packets arrive in decode order but
|
|
* durations are based on presentation order.
|
|
*/
|
|
class PacketBuffer {
|
|
backing: MpegTsTrackBacking;
|
|
context: PacketReadingContext;
|
|
|
|
decodeOrderPackets: SuppliedPacket[] = [];
|
|
reorderSize: number;
|
|
reorderBuffer: SuppliedPacket[] = [];
|
|
presentationOrderPackets: SuppliedPacket[] = [];
|
|
reachedEnd = false;
|
|
lastDuration = 0;
|
|
|
|
constructor(backing: MpegTsTrackBacking, context: PacketReadingContext) {
|
|
this.backing = backing;
|
|
this.context = context;
|
|
this.reorderSize = backing.getReorderSize();
|
|
assert(this.reorderSize >= 0);
|
|
}
|
|
|
|
async readNext(): Promise<{ packet: SuppliedPacket; duration: number } | null> {
|
|
if (this.decodeOrderPackets.length === 0) {
|
|
// We need the next packet
|
|
const didRead = await this.readNextPacket();
|
|
if (!didRead) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Ensure we know the next packet in presentation order so we can compute the current packet's duration
|
|
await this.ensureCurrentPacketHasNext();
|
|
|
|
const packet = this.decodeOrderPackets[0]!;
|
|
|
|
// Let's compute the duration
|
|
const presentationIndex = this.presentationOrderPackets.indexOf(packet);
|
|
assert(presentationIndex !== -1);
|
|
|
|
let duration: number;
|
|
if (presentationIndex === this.presentationOrderPackets.length - 1) {
|
|
duration = this.lastDuration; // Reasonable heuristic
|
|
} else {
|
|
const nextPacket = this.presentationOrderPackets[presentationIndex + 1]!;
|
|
duration = nextPacket.pts - packet.pts;
|
|
this.lastDuration = duration;
|
|
}
|
|
|
|
this.decodeOrderPackets.shift();
|
|
|
|
// Shrink the presentation array as much as possible
|
|
while (this.presentationOrderPackets.length > 0) {
|
|
const first = this.presentationOrderPackets[0]!;
|
|
if (this.decodeOrderPackets.includes(first)) {
|
|
break;
|
|
}
|
|
|
|
this.presentationOrderPackets.shift();
|
|
}
|
|
|
|
return { packet, duration };
|
|
}
|
|
|
|
async readNextPacket() {
|
|
if (this.reachedEnd) {
|
|
return false;
|
|
}
|
|
|
|
let suppliedPacket: SuppliedPacket | null;
|
|
if (this.context.suppliedPacket) {
|
|
// Small optimization: there was already a supplied packet in the context, so let's first use that one
|
|
suppliedPacket = this.context.suppliedPacket;
|
|
} else {
|
|
await this.context.markNextPacket();
|
|
suppliedPacket = this.context.suppliedPacket;
|
|
}
|
|
this.context.suppliedPacket = null;
|
|
|
|
if (!suppliedPacket) {
|
|
this.reachedEnd = true;
|
|
this.flushReorderBuffer();
|
|
|
|
return false;
|
|
}
|
|
|
|
this.decodeOrderPackets.push(suppliedPacket);
|
|
this.processPacketThroughReorderBuffer(suppliedPacket);
|
|
|
|
return true;
|
|
}
|
|
|
|
async ensureCurrentPacketHasNext() {
|
|
const current = this.decodeOrderPackets[0];
|
|
assert(current);
|
|
|
|
while (true) {
|
|
const presentationIndex = this.presentationOrderPackets.indexOf(current);
|
|
|
|
// Check if current packet has a next packet
|
|
if (presentationIndex !== -1 && presentationIndex <= this.presentationOrderPackets.length - 2) {
|
|
break;
|
|
}
|
|
|
|
const didRead = await this.readNextPacket();
|
|
if (!didRead) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
processPacketThroughReorderBuffer(packet: SuppliedPacket) {
|
|
this.reorderBuffer.push(packet);
|
|
|
|
// If buffer is now overfull, output the packet with smallest PTS
|
|
if (this.reorderBuffer.length > this.reorderSize) {
|
|
let minIndex = 0;
|
|
for (let i = 1; i < this.reorderBuffer.length; i++) {
|
|
if (this.reorderBuffer[i]!.pts < this.reorderBuffer[minIndex]!.pts) {
|
|
minIndex = i;
|
|
}
|
|
}
|
|
|
|
const packet = this.reorderBuffer[minIndex]!;
|
|
this.presentationOrderPackets.push(packet);
|
|
this.reorderBuffer.splice(minIndex, 1);
|
|
}
|
|
}
|
|
|
|
flushReorderBuffer() {
|
|
this.reorderBuffer.sort((a, b) => a.pts - b.pts);
|
|
this.presentationOrderPackets.push(...this.reorderBuffer);
|
|
this.reorderBuffer.length = 0;
|
|
}
|
|
}
|