mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-03 13:53:53 +02:00
Implement the bulk of the MPEG-TS demuxer, add a bunch of tests, and a few other things
This commit is contained in:
@@ -60,6 +60,11 @@ await input.computeDuration(); // => 1905.4615
|
||||
```
|
||||
More specifically, the duration is defined as the maximum end timestamp across all tracks.
|
||||
|
||||
Since not all media files begin at time zero, you can also retrieve the *starting timestamp* of the media file in seconds:
|
||||
```ts
|
||||
await input.getFirstTimestamp(); // => 0.0
|
||||
```
|
||||
|
||||
Mediabunny also lets you read descriptive metadata tags from media files, such as title, artist, or cover art:
|
||||
```ts
|
||||
await input.getMetadataTags(); // => MetadataTags
|
||||
|
||||
@@ -43,12 +43,14 @@ const extractMetadata = (resource: File | string) => {
|
||||
const object = {
|
||||
'Format': input.getFormat().then(format => format.name),
|
||||
'Full MIME type': input.getMimeType(),
|
||||
'Duration': input.computeDuration().then(duration => `${duration} seconds`),
|
||||
'Starts at': input.getFirstTimestamp().then(start => `${start} seconds`),
|
||||
'Ends at': input.computeDuration().then(duration => `${duration} seconds`),
|
||||
'Tracks': input.getTracks().then(tracks => tracks.map(track => ({
|
||||
'Type': track.type,
|
||||
'Codec': track.codec,
|
||||
'Full codec string': track.getCodecParameterString(),
|
||||
'Duration': track.computeDuration().then(duration => `${duration} seconds`),
|
||||
'Starts at': track.getFirstTimestamp().then(start => `${start} seconds`),
|
||||
'Ends at': track.computeDuration().then(duration => `${duration} seconds`),
|
||||
'Language code': track.languageCode,
|
||||
...(track.isVideoTrack()
|
||||
? {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, registerEncoder } from 'mediabunny';
|
||||
import { FRAME_HEADER_SIZE, readFrameHeader, SAMPLING_RATES } from '../../../shared/mp3-misc';
|
||||
import { FRAME_HEADER_SIZE, readMp3FrameHeader, SAMPLING_RATES } from '../../../shared/mp3-misc';
|
||||
import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
|
||||
// @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand
|
||||
import createWorker from './encode.worker';
|
||||
@@ -145,7 +145,7 @@ class Mp3Encoder extends CustomAudioEncoder {
|
||||
let pos = 0;
|
||||
while (pos <= this.currentBufferOffset - FRAME_HEADER_SIZE) {
|
||||
const word = new DataView(this.buffer.buffer).getUint32(pos, false);
|
||||
const header = readFrameHeader(word, null).header;
|
||||
const header = readMp3FrameHeader(word, null).header;
|
||||
if (!header) {
|
||||
break;
|
||||
}
|
||||
|
||||
+3
-3
@@ -27,7 +27,7 @@ export const XING = 0x58696e67;
|
||||
/** 'Info' */
|
||||
export const INFO = 0x496e666f;
|
||||
|
||||
export type FrameHeader = {
|
||||
export type Mp3FrameHeader = {
|
||||
totalSize: number;
|
||||
mpegVersionId: number;
|
||||
layer: number;
|
||||
@@ -66,8 +66,8 @@ export const getXingOffset = (mpegVersionId: number, channel: number) => {
|
||||
: (channel === 3 ? 13 : 21);
|
||||
};
|
||||
|
||||
export const readFrameHeader = (word: number, remainingBytes: number | null): {
|
||||
header: FrameHeader | null;
|
||||
export const readMp3FrameHeader = (word: number, remainingBytes: number | null): {
|
||||
header: Mp3FrameHeader | null;
|
||||
bytesAdvanced: number;
|
||||
} => {
|
||||
const firstByte = word >>> 24;
|
||||
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
|
||||
import { readBytes, Reader } from '../reader';
|
||||
import { DEFAULT_TRACK_DISPOSITION } from '../metadata';
|
||||
import { FrameHeader, MAX_FRAME_HEADER_SIZE, MIN_FRAME_HEADER_SIZE, readFrameHeader } from './adts-reader';
|
||||
import { AdtsFrameHeader, MAX_FRAME_HEADER_SIZE, MIN_FRAME_HEADER_SIZE, readAdtsFrameHeader } from './adts-reader';
|
||||
|
||||
const SAMPLES_PER_AAC_FRAME = 1024;
|
||||
export const SAMPLES_PER_AAC_FRAME = 1024;
|
||||
|
||||
type Sample = {
|
||||
timestamp: number;
|
||||
@@ -37,7 +37,7 @@ export class AdtsDemuxer extends Demuxer {
|
||||
reader: Reader;
|
||||
|
||||
metadataPromise: Promise<void> | null = null;
|
||||
firstFrameHeader: FrameHeader | null = null;
|
||||
firstFrameHeader: AdtsFrameHeader | null = null;
|
||||
loadedSamples: Sample[] = [];
|
||||
|
||||
tracks: InputAudioTrack[] = [];
|
||||
@@ -76,7 +76,7 @@ export class AdtsDemuxer extends Demuxer {
|
||||
return;
|
||||
}
|
||||
|
||||
const header = readFrameHeader(slice);
|
||||
const header = readAdtsFrameHeader(slice);
|
||||
if (!header) {
|
||||
this.lastSampleLoaded = true;
|
||||
return;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FileSlice, readBytes } from '../reader';
|
||||
export const MIN_FRAME_HEADER_SIZE = 7;
|
||||
export const MAX_FRAME_HEADER_SIZE = 9;
|
||||
|
||||
export type FrameHeader = {
|
||||
export type AdtsFrameHeader = {
|
||||
objectType: number;
|
||||
samplingFrequencyIndex: number;
|
||||
channelConfiguration: number;
|
||||
@@ -22,7 +22,7 @@ export type FrameHeader = {
|
||||
startPos: number;
|
||||
};
|
||||
|
||||
export const readFrameHeader = (slice: FileSlice): FrameHeader | null => {
|
||||
export const readAdtsFrameHeader = (slice: FileSlice): AdtsFrameHeader | null => {
|
||||
// https://wiki.multimedia.cx/index.php/ADTS (last visited: 2025/08/17)
|
||||
|
||||
const startPos = slice.filePos;
|
||||
|
||||
+101
-9
@@ -40,6 +40,7 @@ export enum AvcNalUnitType {
|
||||
SEI = 6,
|
||||
SPS = 7,
|
||||
PPS = 8,
|
||||
AUD = 9,
|
||||
SPS_EXT = 13,
|
||||
}
|
||||
|
||||
@@ -474,9 +475,17 @@ export type AvcSpsInfo = {
|
||||
constraintFlags: number;
|
||||
levelIdc: number;
|
||||
frameMbsOnlyFlag: number;
|
||||
chromaFormatIdc: number | null;
|
||||
bitDepthLumaMinus8: number | null;
|
||||
bitDepthChromaMinus8: number | null;
|
||||
chromaFormatIdc: number;
|
||||
bitDepthLumaMinus8: number;
|
||||
bitDepthChromaMinus8: number;
|
||||
codedWidth: number;
|
||||
codedHeight: number;
|
||||
displayWidth: number;
|
||||
displayHeight: number;
|
||||
colourPrimaries: number;
|
||||
transferCharacteristics: number;
|
||||
matrixCoefficients: number;
|
||||
fullRangeFlag: number;
|
||||
};
|
||||
|
||||
/** Parses an AVC SPS (Sequence Parameter Set) to extract basic information. */
|
||||
@@ -498,9 +507,14 @@ export const parseAvcSps = (sps: Uint8Array): AvcSpsInfo | null => {
|
||||
|
||||
readExpGolomb(bitstream); // seq_parameter_set_id
|
||||
|
||||
let chromaFormatIdc: number | null = null;
|
||||
let bitDepthLumaMinus8: number | null = null;
|
||||
let bitDepthChromaMinus8: number | null = null;
|
||||
// "When chroma_format_idc is not present, it shall be inferred to be equal to 1 (4:2:0 chroma format)."
|
||||
let chromaFormatIdc = 1;
|
||||
// "When bit_depth_luma_minus8 is not present, it shall be inferred to be equal to 0.""
|
||||
let bitDepthLumaMinus8 = 0;
|
||||
// "When bit_depth_chroma_minus8 is not present, it shall be inferred to be equal to 0."
|
||||
let bitDepthChromaMinus8 = 0;
|
||||
// "When separate_colour_plane_flag is not present, it shall be inferred to be equal to 0."
|
||||
let separateColourPlaneFlag = 0;
|
||||
|
||||
// Handle high profile chroma_format_idc
|
||||
if (
|
||||
@@ -516,7 +530,7 @@ export const parseAvcSps = (sps: Uint8Array): AvcSpsInfo | null => {
|
||||
) {
|
||||
chromaFormatIdc = readExpGolomb(bitstream);
|
||||
if (chromaFormatIdc === 3) {
|
||||
bitstream.skipBits(1); // separate_colour_plane_flag
|
||||
separateColourPlaneFlag = bitstream.readBits(1);
|
||||
}
|
||||
bitDepthLumaMinus8 = readExpGolomb(bitstream);
|
||||
bitDepthChromaMinus8 = readExpGolomb(bitstream);
|
||||
@@ -559,10 +573,80 @@ export const parseAvcSps = (sps: Uint8Array): AvcSpsInfo | null => {
|
||||
readExpGolomb(bitstream); // max_num_ref_frames
|
||||
bitstream.skipBits(1); // gaps_in_frame_num_value_allowed_flag
|
||||
|
||||
readExpGolomb(bitstream); // pic_width_in_mbs_minus1
|
||||
readExpGolomb(bitstream); // pic_height_in_map_units_minus1
|
||||
const codedWidth = 16 * (readExpGolomb(bitstream) + 1); // pic_width_in_mbs_minus1
|
||||
const codedHeight = 16 * (readExpGolomb(bitstream) + 1); // pic_height_in_map_units_minus1
|
||||
let displayWidth = codedWidth;
|
||||
let displayHeight = codedHeight;
|
||||
|
||||
const frameMbsOnlyFlag = bitstream.readBits(1);
|
||||
if (!frameMbsOnlyFlag) {
|
||||
bitstream.skipBits(1); // mb_adaptive_frame_field_flag
|
||||
}
|
||||
|
||||
bitstream.skipBits(1); // direct_8x8_inference_flag
|
||||
const frameCroppingFlag = bitstream.readBits(1);
|
||||
|
||||
if (frameCroppingFlag) {
|
||||
const frameCropLeftOffset = readExpGolomb(bitstream);
|
||||
const frameCropRightOffset = readExpGolomb(bitstream);
|
||||
const frameCropTopOffset = readExpGolomb(bitstream);
|
||||
const frameCropBottomOffset = readExpGolomb(bitstream);
|
||||
|
||||
let cropUnitX: number;
|
||||
let cropUnitY: number;
|
||||
|
||||
const chromaArrayType = separateColourPlaneFlag === 0 ? chromaFormatIdc : 0;
|
||||
if (chromaArrayType === 0) {
|
||||
// "If ChromaArrayType is equal to 0, CropUnitX and CropUnitY are derived as:"
|
||||
cropUnitX = 1;
|
||||
cropUnitY = 2 - frameMbsOnlyFlag;
|
||||
} else {
|
||||
// "Otherwise (ChromaArrayType is equal to 1, 2, or 3), CropUnitX and CropUnitY are derived as:"
|
||||
const subWidthC = chromaFormatIdc === 3 ? 1 : 2;
|
||||
const subHeightC = chromaFormatIdc === 1 ? 2 : 1;
|
||||
|
||||
cropUnitX = subWidthC;
|
||||
cropUnitY = subHeightC * (2 - frameMbsOnlyFlag);
|
||||
}
|
||||
|
||||
displayWidth -= (cropUnitX * (frameCropLeftOffset + frameCropRightOffset));
|
||||
displayHeight -= (cropUnitY * (frameCropTopOffset + frameCropBottomOffset));
|
||||
}
|
||||
|
||||
// 2 = unspecified
|
||||
let colourPrimaries = 2;
|
||||
let transferCharacteristics = 2;
|
||||
let matrixCoefficients = 2;
|
||||
let fullRangeFlag = 0;
|
||||
|
||||
const vuiParametersPresentFlag = bitstream.readBits(1);
|
||||
if (vuiParametersPresentFlag) {
|
||||
const aspectRatioInfoPresentFlag = bitstream.readBits(1);
|
||||
if (aspectRatioInfoPresentFlag) {
|
||||
const aspectRatioIdc = bitstream.readBits(8);
|
||||
if (aspectRatioIdc === 255) { // Extended_SAR
|
||||
bitstream.skipBits(16); // sar_width
|
||||
bitstream.skipBits(16); // sar_height
|
||||
}
|
||||
}
|
||||
|
||||
const overscanInfoPresentFlag = bitstream.readBits(1);
|
||||
if (overscanInfoPresentFlag) {
|
||||
bitstream.skipBits(1); // overscan_appropriate_flag
|
||||
}
|
||||
|
||||
const videoSignalTypePresentFlag = bitstream.readBits(1);
|
||||
if (videoSignalTypePresentFlag) {
|
||||
bitstream.skipBits(3); // video_format
|
||||
fullRangeFlag = bitstream.readBits(1);
|
||||
const colourDescriptionPresentFlag = bitstream.readBits(1);
|
||||
if (colourDescriptionPresentFlag) {
|
||||
colourPrimaries = bitstream.readBits(8);
|
||||
transferCharacteristics = bitstream.readBits(8);
|
||||
matrixCoefficients = bitstream.readBits(8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
profileIdc,
|
||||
@@ -572,6 +656,14 @@ export const parseAvcSps = (sps: Uint8Array): AvcSpsInfo | null => {
|
||||
chromaFormatIdc,
|
||||
bitDepthLumaMinus8,
|
||||
bitDepthChromaMinus8,
|
||||
codedWidth,
|
||||
codedHeight,
|
||||
displayWidth,
|
||||
displayHeight,
|
||||
colourPrimaries,
|
||||
matrixCoefficients,
|
||||
transferCharacteristics,
|
||||
fullRangeFlag,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error parsing AVC SPS:', error);
|
||||
|
||||
+10
-2
@@ -537,6 +537,7 @@ export const buildAudioCodecString = (codec: AudioCodec, numberOfChannels: numbe
|
||||
|
||||
export type AacCodecInfo = {
|
||||
isMpeg2: boolean;
|
||||
objectType: number | null;
|
||||
};
|
||||
|
||||
export const extractAudioCodecString = (trackInfo: {
|
||||
@@ -554,8 +555,15 @@ export const extractAudioCodecString = (trackInfo: {
|
||||
if (aacCodecInfo.isMpeg2) {
|
||||
return 'mp4a.67';
|
||||
} else {
|
||||
const audioSpecificConfig = parseAacAudioSpecificConfig(codecDescription);
|
||||
return `mp4a.40.${audioSpecificConfig.objectType}`;
|
||||
let objectType: number;
|
||||
if (aacCodecInfo.objectType !== null) {
|
||||
objectType = aacCodecInfo.objectType;
|
||||
} else {
|
||||
const audioSpecificConfig = parseAacAudioSpecificConfig(codecDescription);
|
||||
objectType = audioSpecificConfig.objectType;
|
||||
}
|
||||
|
||||
return `mp4a.40.${objectType}`;
|
||||
}
|
||||
} else if (codec === 'mp3') {
|
||||
return 'mp3';
|
||||
|
||||
+6
-6
@@ -23,10 +23,10 @@ import { MatroskaDemuxer } from './matroska/matroska-demuxer';
|
||||
import { Mp3Demuxer } from './mp3/mp3-demuxer';
|
||||
import { FRAME_HEADER_SIZE } from '../shared/mp3-misc';
|
||||
import { ID3_V2_HEADER_SIZE, readId3V2Header } from './id3';
|
||||
import { readNextFrameHeader } from './mp3/mp3-reader';
|
||||
import { readNextMp3FrameHeader } from './mp3/mp3-reader';
|
||||
import { OggDemuxer } from './ogg/ogg-demuxer';
|
||||
import { WaveDemuxer } from './wave/wave-demuxer';
|
||||
import { MAX_FRAME_HEADER_SIZE, MIN_FRAME_HEADER_SIZE, readFrameHeader } from './adts/adts-reader';
|
||||
import { MAX_FRAME_HEADER_SIZE, MIN_FRAME_HEADER_SIZE, readAdtsFrameHeader } from './adts/adts-reader';
|
||||
import { AdtsDemuxer } from './adts/adts-demuxer';
|
||||
import { readAscii } from './reader';
|
||||
import { FlacDemuxer } from './flac/flac-demuxer';
|
||||
@@ -281,7 +281,7 @@ export class Mp3InputFormat extends InputFormat {
|
||||
currentPos = slice.filePos + id3V2Header.size;
|
||||
}
|
||||
|
||||
const firstResult = await readNextFrameHeader(input._reader, currentPos, currentPos + 4096);
|
||||
const firstResult = await readNextMp3FrameHeader(input._reader, currentPos, currentPos + 4096);
|
||||
if (!firstResult) {
|
||||
return false;
|
||||
}
|
||||
@@ -295,7 +295,7 @@ export class Mp3InputFormat extends InputFormat {
|
||||
|
||||
// Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find
|
||||
// another header right after it:
|
||||
const secondResult = await readNextFrameHeader(input._reader, currentPos, currentPos + FRAME_HEADER_SIZE);
|
||||
const secondResult = await readNextMp3FrameHeader(input._reader, currentPos, currentPos + FRAME_HEADER_SIZE);
|
||||
if (!secondResult) {
|
||||
return false;
|
||||
}
|
||||
@@ -444,7 +444,7 @@ export class AdtsInputFormat extends InputFormat {
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice) return false;
|
||||
|
||||
const firstHeader = readFrameHeader(slice);
|
||||
const firstHeader = readAdtsFrameHeader(slice);
|
||||
if (!firstHeader) {
|
||||
return false;
|
||||
}
|
||||
@@ -453,7 +453,7 @@ export class AdtsInputFormat extends InputFormat {
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice) return false;
|
||||
|
||||
const secondHeader = readFrameHeader(slice);
|
||||
const secondHeader = readAdtsFrameHeader(slice);
|
||||
if (!secondHeader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,20 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
return demuxer.computeDuration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the timestamp at which the input file starts. More precisely, returns the smallest starting timestamp
|
||||
* among all tracks.
|
||||
*/
|
||||
async getFirstTimestamp() {
|
||||
const tracks = await this.getTracks();
|
||||
if (tracks.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const firstTimestamps = await Promise.all(tracks.map(x => x.getFirstTimestamp()));
|
||||
return Math.min(...firstTimestamps);
|
||||
}
|
||||
|
||||
/** Returns the list of all tracks of this input file. */
|
||||
async getTracks() {
|
||||
const demuxer = await this._getDemuxer();
|
||||
|
||||
@@ -1235,7 +1235,10 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
const objectTypeIndication = readU8(slice);
|
||||
if (objectTypeIndication === 0x40 || objectTypeIndication === 0x67) {
|
||||
track.info.codec = 'aac';
|
||||
track.info.aacCodecInfo = { isMpeg2: objectTypeIndication === 0x67 };
|
||||
track.info.aacCodecInfo = {
|
||||
isMpeg2: objectTypeIndication === 0x67,
|
||||
objectType: null,
|
||||
};
|
||||
} else if (objectTypeIndication === 0x69 || objectTypeIndication === 0x6b) {
|
||||
track.info.codec = 'mp3';
|
||||
} else if (objectTypeIndication === 0xdd) {
|
||||
|
||||
@@ -1057,6 +1057,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
this.currentTrack.info.codec = 'aac';
|
||||
this.currentTrack.info.aacCodecInfo = {
|
||||
isMpeg2: this.currentTrack.codecId.includes('MPEG2'),
|
||||
objectType: null,
|
||||
};
|
||||
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
|
||||
} else if (this.currentTrack.codecId === CODEC_STRING_MAP.mp3) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
|
||||
import { PacketRetrievalOptions } from '../media-sink';
|
||||
import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, UNDETERMINED_LANGUAGE } from '../misc';
|
||||
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
|
||||
import { FrameHeader, getXingOffset, INFO, XING } from '../../shared/mp3-misc';
|
||||
import { Mp3FrameHeader, getXingOffset, INFO, XING } from '../../shared/mp3-misc';
|
||||
import {
|
||||
ID3_V1_TAG_SIZE,
|
||||
ID3_V2_HEADER_SIZE,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
parseId3V2Tag,
|
||||
readId3V2Header,
|
||||
} from '../id3';
|
||||
import { readNextFrameHeader } from './mp3-reader';
|
||||
import { readNextMp3FrameHeader } from './mp3-reader';
|
||||
import { readAscii, readBytes, Reader, readU32Be } from '../reader';
|
||||
|
||||
type Sample = {
|
||||
@@ -36,7 +36,7 @@ export class Mp3Demuxer extends Demuxer {
|
||||
reader: Reader;
|
||||
|
||||
metadataPromise: Promise<void> | null = null;
|
||||
firstFrameHeader: FrameHeader | null = null;
|
||||
firstFrameHeader: Mp3FrameHeader | null = null;
|
||||
loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos
|
||||
metadataTags: MetadataTags | null = null;
|
||||
|
||||
@@ -89,7 +89,7 @@ export class Mp3Demuxer extends Demuxer {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await readNextFrameHeader(this.reader, this.lastLoadedPos, this.reader.fileSize);
|
||||
const result = await readNextMp3FrameHeader(this.reader, this.lastLoadedPos, this.reader.fileSize);
|
||||
if (!result) {
|
||||
this.lastSampleLoaded = true;
|
||||
return;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Output, OutputAudioTrack } from '../output';
|
||||
import { Mp3OutputFormat } from '../output-format';
|
||||
import { EncodedPacket } from '../packet';
|
||||
import { Writer } from '../writer';
|
||||
import { getXingOffset, INFO, readFrameHeader, XING } from '../../shared/mp3-misc';
|
||||
import { getXingOffset, INFO, readMp3FrameHeader, XING } from '../../shared/mp3-misc';
|
||||
import { Mp3Writer, XingFrameData } from './mp3-writer';
|
||||
import { Id3V2Writer } from '../id3';
|
||||
|
||||
@@ -65,7 +65,7 @@ export class Mp3Muxer extends Muxer {
|
||||
}
|
||||
|
||||
const word = view.getUint32(0, false);
|
||||
const header = readFrameHeader(word, null).header;
|
||||
const header = readMp3FrameHeader(word, null).header;
|
||||
if (!header) {
|
||||
throw new Error('Invalid MP3 header in sample.');
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from '../../shared/mp3-misc';
|
||||
import { FRAME_HEADER_SIZE, Mp3FrameHeader, readMp3FrameHeader } from '../../shared/mp3-misc';
|
||||
import { Reader, readU32Be } from '../reader';
|
||||
|
||||
export const readNextFrameHeader = async (reader: Reader, startPos: number, until: number | null): Promise<{
|
||||
header: FrameHeader;
|
||||
export const readNextMp3FrameHeader = async (reader: Reader, startPos: number, until: number | null): Promise<{
|
||||
header: Mp3FrameHeader;
|
||||
startPos: number;
|
||||
} | null> => {
|
||||
let currentPos = startPos;
|
||||
@@ -22,7 +22,7 @@ export const readNextFrameHeader = async (reader: Reader, startPos: number, unti
|
||||
|
||||
const word = readU32Be(slice);
|
||||
|
||||
const result = readFrameHeader(word, reader.fileSize !== null ? reader.fileSize - currentPos : null);
|
||||
const result = readMp3FrameHeader(word, reader.fileSize !== null ? reader.fileSize - currentPos : null);
|
||||
if (result.header) {
|
||||
return { header: result.header, startPos: currentPos };
|
||||
}
|
||||
|
||||
+1452
-19
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,10 @@ export class Reader {
|
||||
throw new InputDisposedError();
|
||||
}
|
||||
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.fileSize !== null && start + length > this.fileSize) {
|
||||
return null;
|
||||
}
|
||||
@@ -49,6 +53,10 @@ export class Reader {
|
||||
throw new InputDisposedError();
|
||||
}
|
||||
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.fileSize !== null) {
|
||||
return this.requestSlice(
|
||||
start,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from 'vitest';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { UrlSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
import { VideoSampleSink, AudioSampleSink } from '../../src/media-sink.js';
|
||||
import { assert } from '../../src/misc.js';
|
||||
|
||||
test('MPEG-TS video samples are decodable', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/0.ts'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
assert(videoTrack);
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
|
||||
let count = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
for await (using sample of sink.samples()) {
|
||||
count++;
|
||||
}
|
||||
|
||||
assert(count > 0);
|
||||
});
|
||||
|
||||
test('MPEG-TS audio samples are decodable', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/0.ts'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
assert(audioTrack);
|
||||
|
||||
const sink = new AudioSampleSink(audioTrack);
|
||||
|
||||
let count = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
for await (using sample of sink.samples()) {
|
||||
count++;
|
||||
}
|
||||
|
||||
assert(count > 0);
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { FilePathSource } from '../../src/source.js';
|
||||
import path from 'node:path';
|
||||
import { ALL_FORMATS, MPEG_TS } from '../../src/input-format.js';
|
||||
import { assert } from '../../src/misc.js';
|
||||
import { EncodedPacketSink } from '../../src/media-sink.js';
|
||||
import { EncodedPacket } from '../../src/packet.js';
|
||||
|
||||
const __dirname = new URL('.', import.meta.url).pathname;
|
||||
|
||||
test('MPEG-TS metadata reading', async () => {
|
||||
using input = new Input({
|
||||
source: new FilePathSource(path.join(__dirname, '../public/0.ts')),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
expect(await input.getFormat()).toBe(MPEG_TS);
|
||||
expect(await input.getMimeType()).toBe('video/MP2T');
|
||||
|
||||
const tracks = await input.getTracks();
|
||||
expect(tracks).toHaveLength(2);
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
assert(videoTrack);
|
||||
|
||||
expect(videoTrack.id).toBe(0x100);
|
||||
expect(videoTrack.codec).toBe('avc');
|
||||
expect(videoTrack.internalCodecId).toBe(0x1b);
|
||||
expect(videoTrack.displayWidth).toEqual(720);
|
||||
expect(videoTrack.displayHeight).toEqual(720);
|
||||
expect(videoTrack.timeResolution).toBe(90_000);
|
||||
|
||||
const videoDecoderConfig = await videoTrack.getDecoderConfig();
|
||||
expect(videoDecoderConfig).toEqual({
|
||||
codec: 'avc1.640020',
|
||||
codedWidth: 720,
|
||||
codedHeight: 720,
|
||||
colorSpace: {
|
||||
primaries: 'bt2020',
|
||||
transfer: 'hlg',
|
||||
matrix: 'bt2020-ncl',
|
||||
fullRange: false,
|
||||
},
|
||||
// No description, it's Annex B
|
||||
});
|
||||
|
||||
expect(await videoTrack.getFirstTimestamp()).toBe(10.033333333333333);
|
||||
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
assert(audioTrack);
|
||||
|
||||
expect(audioTrack.id).toBe(0x101);
|
||||
expect(audioTrack.codec).toBe('aac');
|
||||
expect(audioTrack.numberOfChannels).toBe(2);
|
||||
expect(audioTrack.sampleRate).toBe(48000);
|
||||
|
||||
const audioDecoderConfig = await audioTrack.getDecoderConfig();
|
||||
expect(audioDecoderConfig).toEqual({
|
||||
codec: 'mp4a.40.2',
|
||||
numberOfChannels: 2,
|
||||
sampleRate: 48000,
|
||||
// No description, it's ADTS audio
|
||||
});
|
||||
|
||||
expect(await audioTrack.getFirstTimestamp()).toBe(10.012);
|
||||
});
|
||||
|
||||
test('MPEG-TS AVC video packets', async () => {
|
||||
using input = new Input({
|
||||
source: new FilePathSource(path.join(__dirname, '../public/0.ts')),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
assert(videoTrack);
|
||||
|
||||
const sink = new EncodedPacketSink(videoTrack);
|
||||
|
||||
const firstPacket = await sink.getFirstPacket();
|
||||
assert(firstPacket);
|
||||
|
||||
expect([...firstPacket.data.slice(0, 4)]).toEqual([0, 0, 0, 1]);
|
||||
expect(firstPacket.data.byteLength).toBe(23813);
|
||||
expect(firstPacket.type).toBe('key');
|
||||
expect(firstPacket.timestamp).toBe(10.033333333333333);
|
||||
expect(firstPacket.duration).toBe(0);
|
||||
expect(firstPacket.sequenceNumber).not.toBe(-1);
|
||||
|
||||
const firstPacketMetadataOnly = await sink.getFirstPacket({ metadataOnly: true });
|
||||
assert(firstPacketMetadataOnly);
|
||||
expect(firstPacketMetadataOnly.data).toHaveLength(0);
|
||||
expect(firstPacketMetadataOnly.byteLength).toBe(23813);
|
||||
|
||||
const secondPacket = await sink.getNextPacket(firstPacket);
|
||||
assert(secondPacket);
|
||||
|
||||
expect([...secondPacket.data.slice(0, 4)]).toEqual([0, 0, 0, 1]);
|
||||
expect(secondPacket.data.byteLength).toBe(5700);
|
||||
expect(secondPacket.type).toBe('delta');
|
||||
expect(secondPacket.timestamp).toBe(10.1);
|
||||
expect(secondPacket.duration).toBe(0);
|
||||
expect(secondPacket.sequenceNumber).toBeGreaterThan(firstPacket.sequenceNumber);
|
||||
|
||||
let currentPacket: EncodedPacket | null = firstPacket;
|
||||
let count = 0;
|
||||
|
||||
while (currentPacket) {
|
||||
count++;
|
||||
currentPacket = await sink.getNextPacket(currentPacket);
|
||||
}
|
||||
|
||||
expect(count).toBe(298);
|
||||
});
|
||||
|
||||
test('MPEG-TS AAC audio packets', async () => {
|
||||
using input = new Input({
|
||||
source: new FilePathSource(path.join(__dirname, '../public/0.ts')),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
assert(audioTrack);
|
||||
|
||||
const sink = new EncodedPacketSink(audioTrack);
|
||||
|
||||
const firstPacket = await sink.getFirstPacket();
|
||||
assert(firstPacket);
|
||||
|
||||
expect(firstPacket.data[0]).toEqual(0xff);
|
||||
expect(firstPacket.data.byteLength).toBe(348);
|
||||
expect(firstPacket.type).toBe('key');
|
||||
expect(firstPacket.timestamp).toBe(10.012);
|
||||
expect(firstPacket.duration).toBeCloseTo(0.021333333333333333);
|
||||
expect(firstPacket.sequenceNumber).not.toBe(-1);
|
||||
|
||||
const secondPacket = await sink.getNextPacket(firstPacket);
|
||||
assert(secondPacket);
|
||||
|
||||
expect(secondPacket.data[0]).toEqual(0xff);
|
||||
expect(secondPacket.data.byteLength).toBe(349);
|
||||
expect(secondPacket.type).toBe('key');
|
||||
expect(secondPacket.timestamp).toBeCloseTo(10.033333333333333);
|
||||
expect(secondPacket.duration).toBeCloseTo(0.021333333333333333);
|
||||
expect(secondPacket.sequenceNumber).toBeGreaterThan(firstPacket.sequenceNumber);
|
||||
|
||||
let currentPacket: EncodedPacket | null = firstPacket;
|
||||
let count = 0;
|
||||
|
||||
while (currentPacket) {
|
||||
count++;
|
||||
currentPacket = await sink.getNextPacket(currentPacket);
|
||||
}
|
||||
|
||||
expect(count).toBe(234);
|
||||
});
|
||||
|
||||
test('MPEG-TS video seeking', async () => {
|
||||
using input = new Input({
|
||||
source: new FilePathSource(path.join(__dirname, '../public/0.ts')),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
assert(videoTrack);
|
||||
|
||||
const sink = new EncodedPacketSink(videoTrack);
|
||||
|
||||
const firstTimestamp = await videoTrack.getFirstTimestamp();
|
||||
const firstPacket = await sink.getPacket(firstTimestamp);
|
||||
assert(firstPacket);
|
||||
|
||||
expect(firstPacket.timestamp).toBe(firstTimestamp);
|
||||
|
||||
const lastPacket = await sink.getPacket(Infinity);
|
||||
assert(lastPacket);
|
||||
|
||||
expect(lastPacket.timestamp).toBeCloseTo(14.983333333333333);
|
||||
|
||||
const beforeFirst = await sink.getPacket(-10);
|
||||
expect(beforeFirst).toBeNull();
|
||||
|
||||
const middlePacket = await sink.getPacket(12.5);
|
||||
assert(middlePacket);
|
||||
expect(middlePacket.timestamp).toBeCloseTo(12.5);
|
||||
|
||||
const allPackets: EncodedPacket[] = [];
|
||||
let currentPacket: EncodedPacket | null = firstPacket;
|
||||
|
||||
while (currentPacket) {
|
||||
allPackets.push(currentPacket);
|
||||
currentPacket = await sink.getNextPacket(currentPacket);
|
||||
}
|
||||
|
||||
for (const packet of allPackets) {
|
||||
const seekedPacked = await sink.getPacket(packet.timestamp);
|
||||
assert(seekedPacked);
|
||||
expect(seekedPacked.timestamp).toBe(packet.timestamp); // The correct timestamp was retrieved for this packet
|
||||
expect(seekedPacked.sequenceNumber).toBe(packet.sequenceNumber);
|
||||
}
|
||||
});
|
||||
|
||||
test('MPEG-TS audio seeking', async () => {
|
||||
using input = new Input({
|
||||
source: new FilePathSource(path.join(__dirname, '../public/0.ts')),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
assert(audioTrack);
|
||||
|
||||
const sink = new EncodedPacketSink(audioTrack);
|
||||
|
||||
const firstTimestamp = await audioTrack.getFirstTimestamp();
|
||||
const firstPacket = await sink.getPacket(firstTimestamp);
|
||||
assert(firstPacket);
|
||||
|
||||
expect(firstPacket.timestamp).toBe(firstTimestamp);
|
||||
|
||||
const lastPacket = await sink.getPacket(Infinity);
|
||||
assert(lastPacket);
|
||||
|
||||
expect(lastPacket.timestamp).toBeCloseTo(14.982666666666667);
|
||||
|
||||
const beforeFirst = await sink.getPacket(-10);
|
||||
expect(beforeFirst).toBeNull();
|
||||
|
||||
const middlePacket = await sink.getPacket(12.5);
|
||||
assert(middlePacket);
|
||||
expect(middlePacket.timestamp).toBeCloseTo(12.486666666666666);
|
||||
|
||||
const allPackets: EncodedPacket[] = [];
|
||||
let currentPacket: EncodedPacket | null = firstPacket;
|
||||
|
||||
while (currentPacket) {
|
||||
allPackets.push(currentPacket);
|
||||
currentPacket = await sink.getNextPacket(currentPacket);
|
||||
}
|
||||
|
||||
for (const packet of allPackets) {
|
||||
const seekedPacket = await sink.getPacket(packet.timestamp);
|
||||
assert(seekedPacket);
|
||||
expect(seekedPacket.timestamp).toBe(packet.timestamp); // The correct timestamp was retrieved for this packet
|
||||
expect(seekedPacket.sequenceNumber).toBe(packet.sequenceNumber);
|
||||
}
|
||||
});
|
||||
|
||||
test('MPEG-TS video key packets', async () => {
|
||||
using input = new Input({
|
||||
source: new FilePathSource(path.join(__dirname, '../public/193039199_mp4_h264_aac_fhd_7.ts')),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
assert(videoTrack);
|
||||
|
||||
const sink = new EncodedPacketSink(videoTrack);
|
||||
|
||||
const firstPacket = await sink.getFirstPacket();
|
||||
assert(firstPacket);
|
||||
expect(firstPacket.type).toBe('key');
|
||||
|
||||
const secondPacket = await sink.getNextPacket(firstPacket);
|
||||
assert(secondPacket);
|
||||
expect(secondPacket.type).toBe('delta');
|
||||
|
||||
const nextKeyPacket = await sink.getNextKeyPacket(firstPacket);
|
||||
assert(nextKeyPacket);
|
||||
expect(nextKeyPacket.type).toBe('key');
|
||||
expect(nextKeyPacket.sequenceNumber).toBeGreaterThan(secondPacket.sequenceNumber);
|
||||
|
||||
const firstKeyPacket = await sink.getKeyPacket(firstPacket.timestamp + 1);
|
||||
assert(firstKeyPacket);
|
||||
expect(firstKeyPacket.type).toBe('key');
|
||||
expect(firstKeyPacket.sequenceNumber).toBe(firstPacket.sequenceNumber);
|
||||
|
||||
const secondKeyPacket = await sink.getKeyPacket(15);
|
||||
assert(secondKeyPacket);
|
||||
expect(secondKeyPacket.type).toBe('key');
|
||||
expect(secondKeyPacket.sequenceNumber).toBe(nextKeyPacket.sequenceNumber);
|
||||
|
||||
const lastKeyPacket = await sink.getKeyPacket(Infinity);
|
||||
assert(lastKeyPacket);
|
||||
expect(lastKeyPacket.type).toBe('key');
|
||||
expect(lastKeyPacket.sequenceNumber).toBe(secondKeyPacket.sequenceNumber); // It's actually the last key packet
|
||||
|
||||
const allKeyPackets: EncodedPacket[] = [];
|
||||
let currentKeyPacket: EncodedPacket | null = firstPacket;
|
||||
|
||||
while (currentKeyPacket) {
|
||||
allKeyPackets.push(currentKeyPacket);
|
||||
currentKeyPacket = await sink.getNextKeyPacket(currentKeyPacket);
|
||||
}
|
||||
|
||||
for (const packet of allKeyPackets) {
|
||||
const keyPacket = await sink.getKeyPacket(packet.timestamp);
|
||||
assert(keyPacket);
|
||||
expect(keyPacket.timestamp).toBe(packet.timestamp); // The correct timestamp was retrieved for this packet
|
||||
expect(keyPacket.sequenceNumber).toBe(packet.sequenceNumber);
|
||||
}
|
||||
});
|
||||
|
||||
test('MPEG-TS audio key packets', async () => {
|
||||
using input = new Input({
|
||||
source: new FilePathSource(path.join(__dirname, '../public/193039199_mp4_h264_aac_fhd_7.ts')),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
assert(audioTrack);
|
||||
|
||||
const sink = new EncodedPacketSink(audioTrack);
|
||||
|
||||
const firstPacket = await sink.getFirstPacket();
|
||||
assert(firstPacket);
|
||||
expect(firstPacket.type).toBe('key');
|
||||
|
||||
const secondPacket = await sink.getNextPacket(firstPacket);
|
||||
assert(secondPacket);
|
||||
expect(secondPacket.type).toBe('key');
|
||||
|
||||
const nextKeyPacket = await sink.getNextKeyPacket(firstPacket);
|
||||
assert(nextKeyPacket);
|
||||
expect(nextKeyPacket.type).toBe('key');
|
||||
expect(nextKeyPacket.sequenceNumber).toBe(secondPacket.sequenceNumber); // All audio packets are key packets
|
||||
|
||||
const lastPacket = await sink.getPacket(Infinity);
|
||||
assert(lastPacket);
|
||||
|
||||
const lastKeyPacket = await sink.getKeyPacket(Infinity);
|
||||
assert(lastKeyPacket);
|
||||
expect(lastKeyPacket.type).toBe('key');
|
||||
expect(lastKeyPacket.sequenceNumber).toBe(lastPacket.sequenceNumber); // It's actually the last packet
|
||||
|
||||
const allKeyPackets: EncodedPacket[] = [];
|
||||
let currentKeyPacket: EncodedPacket | null = firstPacket;
|
||||
|
||||
while (currentKeyPacket) {
|
||||
allKeyPackets.push(currentKeyPacket);
|
||||
currentKeyPacket = await sink.getNextKeyPacket(currentKeyPacket);
|
||||
}
|
||||
|
||||
for (const packet of allKeyPackets) {
|
||||
const keyPacket = await sink.getKeyPacket(packet.timestamp);
|
||||
assert(keyPacket);
|
||||
expect(keyPacket.timestamp).toBe(packet.timestamp); // The correct timestamp was retrieved for this packet
|
||||
expect(keyPacket.sequenceNumber).toBe(packet.sequenceNumber);
|
||||
}
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
@@ -7,5 +7,6 @@
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["vitest.config.ts", "./test/**/*"],
|
||||
"exclude": ["./test/public/**/*"],
|
||||
"references": [{ "path": "./src" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user