Implement the bulk of the MPEG-TS demuxer, add a bunch of tests, and a few other things

This commit is contained in:
Vanilagy
2026-01-14 12:05:39 +01:00
parent f363fe50e2
commit 888441232d
22 changed files with 2023 additions and 61 deletions
+5
View File
@@ -60,6 +60,11 @@ await input.computeDuration(); // => 1905.4615
``` ```
More specifically, the duration is defined as the maximum end timestamp across all tracks. 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: Mediabunny also lets you read descriptive metadata tags from media files, such as title, artist, or cover art:
```ts ```ts
await input.getMetadataTags(); // => MetadataTags await input.getMetadataTags(); // => MetadataTags
@@ -43,12 +43,14 @@ const extractMetadata = (resource: File | string) => {
const object = { const object = {
'Format': input.getFormat().then(format => format.name), 'Format': input.getFormat().then(format => format.name),
'Full MIME type': input.getMimeType(), '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 => ({ 'Tracks': input.getTracks().then(tracks => tracks.map(track => ({
'Type': track.type, 'Type': track.type,
'Codec': track.codec, 'Codec': track.codec,
'Full codec string': track.getCodecParameterString(), '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, 'Language code': track.languageCode,
...(track.isVideoTrack() ...(track.isVideoTrack()
? { ? {
+2 -2
View File
@@ -7,7 +7,7 @@
*/ */
import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, registerEncoder } from 'mediabunny'; 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'; import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
// @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand // @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand
import createWorker from './encode.worker'; import createWorker from './encode.worker';
@@ -145,7 +145,7 @@ class Mp3Encoder extends CustomAudioEncoder {
let pos = 0; let pos = 0;
while (pos <= this.currentBufferOffset - FRAME_HEADER_SIZE) { while (pos <= this.currentBufferOffset - FRAME_HEADER_SIZE) {
const word = new DataView(this.buffer.buffer).getUint32(pos, false); const word = new DataView(this.buffer.buffer).getUint32(pos, false);
const header = readFrameHeader(word, null).header; const header = readMp3FrameHeader(word, null).header;
if (!header) { if (!header) {
break; break;
} }
+3 -3
View File
@@ -27,7 +27,7 @@ export const XING = 0x58696e67;
/** 'Info' */ /** 'Info' */
export const INFO = 0x496e666f; export const INFO = 0x496e666f;
export type FrameHeader = { export type Mp3FrameHeader = {
totalSize: number; totalSize: number;
mpegVersionId: number; mpegVersionId: number;
layer: number; layer: number;
@@ -66,8 +66,8 @@ export const getXingOffset = (mpegVersionId: number, channel: number) => {
: (channel === 3 ? 13 : 21); : (channel === 3 ? 13 : 21);
}; };
export const readFrameHeader = (word: number, remainingBytes: number | null): { export const readMp3FrameHeader = (word: number, remainingBytes: number | null): {
header: FrameHeader | null; header: Mp3FrameHeader | null;
bytesAdvanced: number; bytesAdvanced: number;
} => { } => {
const firstByte = word >>> 24; const firstByte = word >>> 24;
+4 -4
View File
@@ -22,9 +22,9 @@ import {
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { readBytes, Reader } from '../reader'; import { readBytes, Reader } from '../reader';
import { DEFAULT_TRACK_DISPOSITION } from '../metadata'; 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 = { type Sample = {
timestamp: number; timestamp: number;
@@ -37,7 +37,7 @@ export class AdtsDemuxer extends Demuxer {
reader: Reader; reader: Reader;
metadataPromise: Promise<void> | null = null; metadataPromise: Promise<void> | null = null;
firstFrameHeader: FrameHeader | null = null; firstFrameHeader: AdtsFrameHeader | null = null;
loadedSamples: Sample[] = []; loadedSamples: Sample[] = [];
tracks: InputAudioTrack[] = []; tracks: InputAudioTrack[] = [];
@@ -76,7 +76,7 @@ export class AdtsDemuxer extends Demuxer {
return; return;
} }
const header = readFrameHeader(slice); const header = readAdtsFrameHeader(slice);
if (!header) { if (!header) {
this.lastSampleLoaded = true; this.lastSampleLoaded = true;
return; return;
+2 -2
View File
@@ -12,7 +12,7 @@ import { FileSlice, readBytes } from '../reader';
export const MIN_FRAME_HEADER_SIZE = 7; export const MIN_FRAME_HEADER_SIZE = 7;
export const MAX_FRAME_HEADER_SIZE = 9; export const MAX_FRAME_HEADER_SIZE = 9;
export type FrameHeader = { export type AdtsFrameHeader = {
objectType: number; objectType: number;
samplingFrequencyIndex: number; samplingFrequencyIndex: number;
channelConfiguration: number; channelConfiguration: number;
@@ -22,7 +22,7 @@ export type FrameHeader = {
startPos: number; 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) // https://wiki.multimedia.cx/index.php/ADTS (last visited: 2025/08/17)
const startPos = slice.filePos; const startPos = slice.filePos;
+101 -9
View File
@@ -40,6 +40,7 @@ export enum AvcNalUnitType {
SEI = 6, SEI = 6,
SPS = 7, SPS = 7,
PPS = 8, PPS = 8,
AUD = 9,
SPS_EXT = 13, SPS_EXT = 13,
} }
@@ -474,9 +475,17 @@ export type AvcSpsInfo = {
constraintFlags: number; constraintFlags: number;
levelIdc: number; levelIdc: number;
frameMbsOnlyFlag: number; frameMbsOnlyFlag: number;
chromaFormatIdc: number | null; chromaFormatIdc: number;
bitDepthLumaMinus8: number | null; bitDepthLumaMinus8: number;
bitDepthChromaMinus8: number | null; 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. */ /** 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 readExpGolomb(bitstream); // seq_parameter_set_id
let chromaFormatIdc: 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 bitDepthLumaMinus8: number | null = null; let chromaFormatIdc = 1;
let bitDepthChromaMinus8: number | null = null; // "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 // Handle high profile chroma_format_idc
if ( if (
@@ -516,7 +530,7 @@ export const parseAvcSps = (sps: Uint8Array): AvcSpsInfo | null => {
) { ) {
chromaFormatIdc = readExpGolomb(bitstream); chromaFormatIdc = readExpGolomb(bitstream);
if (chromaFormatIdc === 3) { if (chromaFormatIdc === 3) {
bitstream.skipBits(1); // separate_colour_plane_flag separateColourPlaneFlag = bitstream.readBits(1);
} }
bitDepthLumaMinus8 = readExpGolomb(bitstream); bitDepthLumaMinus8 = readExpGolomb(bitstream);
bitDepthChromaMinus8 = readExpGolomb(bitstream); bitDepthChromaMinus8 = readExpGolomb(bitstream);
@@ -559,10 +573,80 @@ export const parseAvcSps = (sps: Uint8Array): AvcSpsInfo | null => {
readExpGolomb(bitstream); // max_num_ref_frames readExpGolomb(bitstream); // max_num_ref_frames
bitstream.skipBits(1); // gaps_in_frame_num_value_allowed_flag bitstream.skipBits(1); // gaps_in_frame_num_value_allowed_flag
readExpGolomb(bitstream); // pic_width_in_mbs_minus1 const codedWidth = 16 * (readExpGolomb(bitstream) + 1); // pic_width_in_mbs_minus1
readExpGolomb(bitstream); // pic_height_in_map_units_minus1 const codedHeight = 16 * (readExpGolomb(bitstream) + 1); // pic_height_in_map_units_minus1
let displayWidth = codedWidth;
let displayHeight = codedHeight;
const frameMbsOnlyFlag = bitstream.readBits(1); 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 { return {
profileIdc, profileIdc,
@@ -572,6 +656,14 @@ export const parseAvcSps = (sps: Uint8Array): AvcSpsInfo | null => {
chromaFormatIdc, chromaFormatIdc,
bitDepthLumaMinus8, bitDepthLumaMinus8,
bitDepthChromaMinus8, bitDepthChromaMinus8,
codedWidth,
codedHeight,
displayWidth,
displayHeight,
colourPrimaries,
matrixCoefficients,
transferCharacteristics,
fullRangeFlag,
}; };
} catch (error) { } catch (error) {
console.error('Error parsing AVC SPS:', error); console.error('Error parsing AVC SPS:', error);
+10 -2
View File
@@ -537,6 +537,7 @@ export const buildAudioCodecString = (codec: AudioCodec, numberOfChannels: numbe
export type AacCodecInfo = { export type AacCodecInfo = {
isMpeg2: boolean; isMpeg2: boolean;
objectType: number | null;
}; };
export const extractAudioCodecString = (trackInfo: { export const extractAudioCodecString = (trackInfo: {
@@ -554,8 +555,15 @@ export const extractAudioCodecString = (trackInfo: {
if (aacCodecInfo.isMpeg2) { if (aacCodecInfo.isMpeg2) {
return 'mp4a.67'; return 'mp4a.67';
} else { } else {
const audioSpecificConfig = parseAacAudioSpecificConfig(codecDescription); let objectType: number;
return `mp4a.40.${audioSpecificConfig.objectType}`; if (aacCodecInfo.objectType !== null) {
objectType = aacCodecInfo.objectType;
} else {
const audioSpecificConfig = parseAacAudioSpecificConfig(codecDescription);
objectType = audioSpecificConfig.objectType;
}
return `mp4a.40.${objectType}`;
} }
} else if (codec === 'mp3') { } else if (codec === 'mp3') {
return 'mp3'; return 'mp3';
+6 -6
View File
@@ -23,10 +23,10 @@ import { MatroskaDemuxer } from './matroska/matroska-demuxer';
import { Mp3Demuxer } from './mp3/mp3-demuxer'; import { Mp3Demuxer } from './mp3/mp3-demuxer';
import { FRAME_HEADER_SIZE } from '../shared/mp3-misc'; import { FRAME_HEADER_SIZE } from '../shared/mp3-misc';
import { ID3_V2_HEADER_SIZE, readId3V2Header } from './id3'; 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 { OggDemuxer } from './ogg/ogg-demuxer';
import { WaveDemuxer } from './wave/wave-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 { AdtsDemuxer } from './adts/adts-demuxer';
import { readAscii } from './reader'; import { readAscii } from './reader';
import { FlacDemuxer } from './flac/flac-demuxer'; import { FlacDemuxer } from './flac/flac-demuxer';
@@ -281,7 +281,7 @@ export class Mp3InputFormat extends InputFormat {
currentPos = slice.filePos + id3V2Header.size; 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) { if (!firstResult) {
return false; 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 // 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: // 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) { if (!secondResult) {
return false; return false;
} }
@@ -444,7 +444,7 @@ export class AdtsInputFormat extends InputFormat {
if (slice instanceof Promise) slice = await slice; if (slice instanceof Promise) slice = await slice;
if (!slice) return false; if (!slice) return false;
const firstHeader = readFrameHeader(slice); const firstHeader = readAdtsFrameHeader(slice);
if (!firstHeader) { if (!firstHeader) {
return false; return false;
} }
@@ -453,7 +453,7 @@ export class AdtsInputFormat extends InputFormat {
if (slice instanceof Promise) slice = await slice; if (slice instanceof Promise) slice = await slice;
if (!slice) return false; if (!slice) return false;
const secondHeader = readFrameHeader(slice); const secondHeader = readAdtsFrameHeader(slice);
if (!secondHeader) { if (!secondHeader) {
return false; return false;
} }
+14
View File
@@ -118,6 +118,20 @@ export class Input<S extends Source = Source> implements Disposable {
return demuxer.computeDuration(); 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. */ /** Returns the list of all tracks of this input file. */
async getTracks() { async getTracks() {
const demuxer = await this._getDemuxer(); const demuxer = await this._getDemuxer();
+4 -1
View File
@@ -1235,7 +1235,10 @@ export class IsobmffDemuxer extends Demuxer {
const objectTypeIndication = readU8(slice); const objectTypeIndication = readU8(slice);
if (objectTypeIndication === 0x40 || objectTypeIndication === 0x67) { if (objectTypeIndication === 0x40 || objectTypeIndication === 0x67) {
track.info.codec = 'aac'; track.info.codec = 'aac';
track.info.aacCodecInfo = { isMpeg2: objectTypeIndication === 0x67 }; track.info.aacCodecInfo = {
isMpeg2: objectTypeIndication === 0x67,
objectType: null,
};
} else if (objectTypeIndication === 0x69 || objectTypeIndication === 0x6b) { } else if (objectTypeIndication === 0x69 || objectTypeIndication === 0x6b) {
track.info.codec = 'mp3'; track.info.codec = 'mp3';
} else if (objectTypeIndication === 0xdd) { } else if (objectTypeIndication === 0xdd) {
+1
View File
@@ -1057,6 +1057,7 @@ export class MatroskaDemuxer extends Demuxer {
this.currentTrack.info.codec = 'aac'; this.currentTrack.info.codec = 'aac';
this.currentTrack.info.aacCodecInfo = { this.currentTrack.info.aacCodecInfo = {
isMpeg2: this.currentTrack.codecId.includes('MPEG2'), isMpeg2: this.currentTrack.codecId.includes('MPEG2'),
objectType: null,
}; };
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate; this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
} else if (this.currentTrack.codecId === CODEC_STRING_MAP.mp3) { } else if (this.currentTrack.codecId === CODEC_STRING_MAP.mp3) {
+4 -4
View File
@@ -14,7 +14,7 @@ import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
import { PacketRetrievalOptions } from '../media-sink'; import { PacketRetrievalOptions } from '../media-sink';
import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, UNDETERMINED_LANGUAGE } from '../misc'; import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, UNDETERMINED_LANGUAGE } from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; 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 { import {
ID3_V1_TAG_SIZE, ID3_V1_TAG_SIZE,
ID3_V2_HEADER_SIZE, ID3_V2_HEADER_SIZE,
@@ -22,7 +22,7 @@ import {
parseId3V2Tag, parseId3V2Tag,
readId3V2Header, readId3V2Header,
} from '../id3'; } from '../id3';
import { readNextFrameHeader } from './mp3-reader'; import { readNextMp3FrameHeader } from './mp3-reader';
import { readAscii, readBytes, Reader, readU32Be } from '../reader'; import { readAscii, readBytes, Reader, readU32Be } from '../reader';
type Sample = { type Sample = {
@@ -36,7 +36,7 @@ export class Mp3Demuxer extends Demuxer {
reader: Reader; reader: Reader;
metadataPromise: Promise<void> | null = null; 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 loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos
metadataTags: MetadataTags | null = null; 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) { if (!result) {
this.lastSampleLoaded = true; this.lastSampleLoaded = true;
return; return;
+2 -2
View File
@@ -13,7 +13,7 @@ import { Output, OutputAudioTrack } from '../output';
import { Mp3OutputFormat } from '../output-format'; import { Mp3OutputFormat } from '../output-format';
import { EncodedPacket } from '../packet'; import { EncodedPacket } from '../packet';
import { Writer } from '../writer'; 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 { Mp3Writer, XingFrameData } from './mp3-writer';
import { Id3V2Writer } from '../id3'; import { Id3V2Writer } from '../id3';
@@ -65,7 +65,7 @@ export class Mp3Muxer extends Muxer {
} }
const word = view.getUint32(0, false); const word = view.getUint32(0, false);
const header = readFrameHeader(word, null).header; const header = readMp3FrameHeader(word, null).header;
if (!header) { if (!header) {
throw new Error('Invalid MP3 header in sample.'); throw new Error('Invalid MP3 header in sample.');
} }
+4 -4
View File
@@ -6,11 +6,11 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * 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'; import { Reader, readU32Be } from '../reader';
export const readNextFrameHeader = async (reader: Reader, startPos: number, until: number | null): Promise<{ export const readNextMp3FrameHeader = async (reader: Reader, startPos: number, until: number | null): Promise<{
header: FrameHeader; header: Mp3FrameHeader;
startPos: number; startPos: number;
} | null> => { } | null> => {
let currentPos = startPos; let currentPos = startPos;
@@ -22,7 +22,7 @@ export const readNextFrameHeader = async (reader: Reader, startPos: number, unti
const word = readU32Be(slice); 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) { if (result.header) {
return { header: result.header, startPos: currentPos }; return { header: result.header, startPos: currentPos };
} }
File diff suppressed because it is too large Load Diff
+8
View File
@@ -20,6 +20,10 @@ export class Reader {
throw new InputDisposedError(); throw new InputDisposedError();
} }
if (start < 0) {
return null;
}
if (this.fileSize !== null && start + length > this.fileSize) { if (this.fileSize !== null && start + length > this.fileSize) {
return null; return null;
} }
@@ -49,6 +53,10 @@ export class Reader {
throw new InputDisposedError(); throw new InputDisposedError();
} }
if (start < 0) {
return null;
}
if (this.fileSize !== null) { if (this.fileSize !== null) {
return this.requestSlice( return this.requestSlice(
start, start,
+46
View File
@@ -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);
});
+349
View File
@@ -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);
}
});
BIN
View File
Binary file not shown.
Binary file not shown.
+1
View File
@@ -7,5 +7,6 @@
"noEmit": false "noEmit": false
}, },
"include": ["vitest.config.ts", "./test/**/*"], "include": ["vitest.config.ts", "./test/**/*"],
"exclude": ["./test/public/**/*"],
"references": [{ "path": "./src" }] "references": [{ "path": "./src" }]
} }