Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Useful for reading codec parameters out of a `codecs=` string, checking support
| APV | ✅ | ✅ |
| EVC | ✅ | ✅ |
| L-HEVC | ✅ | ✅ |
| MP4 (mp4a/mp4v — AAC, MP3, MPEG-4/2) | ✅ | ✅ |

## Install

Expand Down Expand Up @@ -95,9 +96,9 @@ console.log(info.toString()); // 'avc1.640028'
## API

- `codecInfoFactory(codecString)` — dispatches by prefix (`vp08`/`vp8`, `vp09`/`vp9`, `av01`,
`avc1`/`avc2`/`avc3`/`avc4`, `hev1`/`hvc1`, `vvc1`/`vvi1`, `lvc1`, `apv1`, `evc1`, `lhv1`/`lhe1`) and
returns a `Vp8Info`, `Vp9Info`, `Av1Info`, `H264Info`, `H265Info`, `H266Info`, `LcevcInfo`,
`ApvInfo`, `EvcInfo`, or `LhevcInfo`. Throws `Unknown codec` for anything else.
`avc1`/`avc2`/`avc3`/`avc4`, `hev1`/`hvc1`, `vvc1`/`vvi1`, `lvc1`, `apv1`, `evc1`, `lhv1`/`lhe1`,
`mp4a`/`mp4v`) and returns a `Vp8Info`, `Vp9Info`, `Av1Info`, `H264Info`, `H265Info`, `H266Info`,
`LcevcInfo`, `ApvInfo`, `EvcInfo`, `LhevcInfo`, or `Mp4Info`. Throws `Unknown codec` for anything else.
- `vpx` — namespace exporting `Vp8Info`, `Vp9Info`, `vpxInfoFactory`, and the `Vpx*` enums.
- `av1` — namespace exporting `Av1Info` and the `Av1*` enums.
- `h264` — namespace exporting `H264Info`, `AvcProfileIdc`, and the `hProfile`/`hLevel` helpers.
Expand All @@ -115,6 +116,9 @@ console.log(info.toString()); // 'avc1.640028'
- `lhevc` — namespace exporting `LhevcInfo` (Layered HEVC — SHVC / MV-HEVC, `lhv1`/`lhe1`). The HEVC
profile-tier-level is exposed via `.ptl` (an `H265Info`); the optional scalability field is preserved
verbatim in `.scalability`.
- `mp4` — namespace exporting `Mp4Info` and the OTI helpers (`hObjectTypeIndication`,
`hAudioObjectType`). Covers the RFC 6381 `mp4a`/`mp4v` ObjectTypeIndication scheme — AAC, MP3,
MPEG-4 Visual, MPEG-2 video/audio and more (`mp4a.40.2`, `mp4a.69`, `mp4v.20.9`, …).
- Shared ISO/IEC 23001-8:2016 colour enums (`ColourPrimaries`, `TransferCharacteristics`,
`MatrixCoefficients`, `VideoFullRangeFlag`) are re-exported from both namespaces.

Expand Down
54 changes: 54 additions & 0 deletions src/codec/mp4/enums.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// MP4 audio/video via the RFC 6381 ObjectTypeIndication (OTI) scheme:
// mp4a.<OTI>[.<audio object type>] e.g. mp4a.40.2 (AAC-LC), mp4a.69 / mp4a.6B (MP3)
// mp4v.<OTI>[.<profile-level>] e.g. mp4v.20.9 (MPEG-4 Visual SP L0), mp4v.61 (MPEG-2 Video)
// OTI is two uppercase hex digits; the optional third element is a decimal number.

export type Mp4FourCC = 'mp4a' | 'mp4v';
export const MP4_FOUR_CCS: readonly Mp4FourCC[] = ['mp4a', 'mp4v'];

// MP4 Registration Authority ObjectTypeIndication values (mp4ra.org/registered-types/object-types).
export function hObjectTypeIndication(oti: number): string {
switch (oti) {
case 0x20: return 'MPEG-4 Visual';
case 0x21: return 'AVC / H.264';
case 0x23: return 'HEVC / H.265';
case 0x60: return 'MPEG-2 Video Simple Profile';
case 0x61: return 'MPEG-2 Video Main Profile';
case 0x62: return 'MPEG-2 Video SNR Profile';
case 0x63: return 'MPEG-2 Video Spatial Profile';
case 0x64: return 'MPEG-2 Video High Profile';
case 0x65: return 'MPEG-2 Video 4:2:2 Profile';
case 0x66: return 'MPEG-2 AAC Main Profile';
case 0x67: return 'MPEG-2 AAC Low Complexity Profile';
case 0x68: return 'MPEG-2 AAC Scalable Sampling Rate Profile';
case 0x69: return 'MPEG-2 Audio Part 3 (MP3)';
case 0x6a: return 'MPEG-1 Video';
case 0x6b: return 'MPEG-1 Audio (MP3)';
case 0x6c: return 'JPEG';
case 0x6d: return 'PNG';
case 0x6e: return 'JPEG 2000';
case 0x40: return 'MPEG-4 Audio (AAC)';
case 0xa9: return 'DTS Coherent Acoustics';
case 0xaa: return 'DTS-HD High Resolution Audio';
case 0xab: return 'DTS-HD Master Audio';
case 0xac: return 'DTS Express';
default: return 'unknown';
}
}

// MPEG-4 Audio object types (ISO/IEC 14496-3) — the third element when OTI is 0x40.
export function hAudioObjectType(audioObjectType: number): string {
switch (audioObjectType) {
case 1: return 'AAC Main';
case 2: return 'AAC-LC';
case 3: return 'AAC SSR';
case 4: return 'AAC LTP';
case 5: return 'SBR (HE-AAC)';
case 6: return 'AAC Scalable';
case 7: return 'TwinVQ';
case 29: return 'PS (HE-AAC v2)';
case 34: return 'MP3 (MPEG-1 Layer III)';
case 42: return 'xHE-AAC (USAC)';
default: return 'unknown';
}
}
3 changes: 3 additions & 0 deletions src/codec/mp4/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export {MP4_FOUR_CCS, hObjectTypeIndication, hAudioObjectType} from './enums';
export type {Mp4FourCC} from './enums';
export * from './mp4-info';
99 changes: 99 additions & 0 deletions src/codec/mp4/mp4-info.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {Mp4Info} from "./mp4-info";

describe('Mp4Info', () => {
describe('parse / round-trip', () => {
it.each([
'mp4a.40.2', // AAC-LC
'mp4a.40.5', // HE-AAC
'mp4a.40.29', // HE-AAC v2
'mp4a.69', // MP3 (MPEG-2)
'mp4a.6B', // MP3 (MPEG-1)
'mp4a.A9', // DTS
'mp4v.20.9', // MPEG-4 Visual Simple Profile
'mp4v.61', // MPEG-2 Video Main Profile
])('parses and round-trips %s', (str) => {
expect(Mp4Info.fromString(str).toString()).toBe(str);
});

it('decodes the fields', () => {
const info = Mp4Info.fromString('mp4a.40.2');
expect(info.fourCC).toBe('mp4a');
expect(info.codecName).toBe('mp4a');
expect(info.objectTypeIndication).toBe(0x40);
expect(info.objectType).toBe(2);
});

it('leaves the third element undefined when absent', () => {
const info = Mp4Info.fromString('mp4a.6B');
expect(info.objectTypeIndication).toBe(0x6b);
expect(info.objectType).toBeUndefined();
});

it('sets codecName from the sample entry', () => {
expect(Mp4Info.fromString('mp4v.20.9').codecName).toBe('mp4v');
});

it('normalizes the OTI to two uppercase hex digits', () => {
expect(Mp4Info.fromString('mp4a.6b').toString()).toBe('mp4a.6B');
});
});

describe('human-readable', () => {
it.each([
['mp4a.40.2', 'MPEG-4 Audio (AAC)', 'AAC-LC'],
['mp4a.40.5', 'MPEG-4 Audio (AAC)', 'SBR (HE-AAC)'],
['mp4a.40.34', 'MPEG-4 Audio (AAC)', 'MP3 (MPEG-1 Layer III)'],
['mp4a.69', 'MPEG-2 Audio Part 3 (MP3)', 'none'],
['mp4v.20.9', 'MPEG-4 Visual', '9'],
['mp4v.61', 'MPEG-2 Video Main Profile', 'none'],
])('%s -> %s / %s', (str, codec, detail) => {
const hr = Mp4Info.fromString(str).toHumanReadable();
expect(hr.codec).toBe(codec);
expect(hr.detail).toBe(detail);
});

it('reports every field', () => {
expect(Mp4Info.fromString('mp4a.40.2').toHumanReadable()).toEqual({
fourCC: 'mp4a',
codec: 'MPEG-4 Audio (AAC)',
objectTypeIndication: 0x40,
detail: 'AAC-LC',
});
});
});

describe('build', () => {
it('assembles a three-element codec string', () => {
const info = new Mp4Info();
info.fourCC = 'mp4a';
info.objectTypeIndication = 0x40;
info.objectType = 2;
expect(info.toString()).toBe('mp4a.40.2');
});

it('assembles a two-element codec string', () => {
const info = new Mp4Info();
info.fourCC = 'mp4a';
info.objectTypeIndication = 0x6b;
expect(info.toString()).toBe('mp4a.6B');
});

it('rejects out-of-range fields', () => {
expect(() => { new Mp4Info().objectTypeIndication = 256; }).toThrow('objectTypeIndication');
expect(() => { new Mp4Info().objectType = -1; }).toThrow('objectType');
});
});

describe('invalid input', () => {
it.each(['mp4a', 'mp4a.40.2.3', 'mp4a.4', 'mp4a.GG', 'mp4a.40.X'])(
'rejects malformed string %s',
(str) => {
expect(() => Mp4Info.fromString(str)).toThrow(/Invalid MP4/);
},
);

it('rejects an unknown 4CC', () => {
expect(() => Mp4Info.fromString('mp4s.40.2')).toThrow('Unknown codec');
});
});
});
94 changes: 94 additions & 0 deletions src/codec/mp4/mp4-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {CodecInfo} from "../codec-info";
import {padStart} from "../pad-start";
import {MP4_FOUR_CCS, Mp4FourCC, hAudioObjectType, hObjectTypeIndication} from "./enums";

// MP4 (mp4a / mp4v) codec information based on the RFC 6381 ObjectTypeIndication scheme.
// Codec string: <fourCC>.<OTI>[.<detail>] — OTI two hex digits, optional decimal detail
// (audio object type for mp4a.40, video profile-level for mp4v.20).
export class Mp4Info extends CodecInfo {
codecName = 'mp4a';

private _fourCC: Mp4FourCC = 'mp4a';
private _objectTypeIndication = 0;
private _objectType: number | undefined = undefined;

get fourCC(): Mp4FourCC {
return this._fourCC;
}

set fourCC(fourCC: Mp4FourCC) {
if (!MP4_FOUR_CCS.includes(fourCC)) {
throw new Error(`Invalid MP4 sample entry: ${fourCC}`);
}
this._fourCC = fourCC;
this.codecName = fourCC;
}

get objectTypeIndication(): number {
return this._objectTypeIndication;
}

set objectTypeIndication(objectTypeIndication: number) {
if (!Number.isInteger(objectTypeIndication) || objectTypeIndication < 0 || objectTypeIndication > 255) {
throw new Error('objectTypeIndication must be a byte in the range 0-255');
}
this._objectTypeIndication = objectTypeIndication;
}

// The optional third element: audio object type (mp4a.40) or video profile-level (mp4v.20).
get objectType(): number | undefined {
return this._objectType;
}

set objectType(objectType: number | undefined) {
if (objectType !== undefined && (!Number.isInteger(objectType) || objectType < 0)) {
throw new Error('objectType must be a non-negative integer');
}
this._objectType = objectType;
}

static fromString(codecString: string): Mp4Info {
const parts = codecString.split('.');
const fourCC = parts[0] as Mp4FourCC;
if (!MP4_FOUR_CCS.includes(fourCC)) {
throw new Error('Unknown codec');
}
if (parts.length < 2 || parts.length > 3) {
throw new Error('Invalid MP4 codec string, expected <fourCC>.<OTI>[.<detail>]');
}
if (!/^[0-9a-fA-F]{2}$/.test(parts[1])) {
throw new Error('Invalid MP4 object type indication, expected two hex digits');
}
const info = new Mp4Info();
info.fourCC = fourCC;
info.objectTypeIndication = parseInt(parts[1], 16);
if (parts[2] !== undefined) {
if (!/^\d+$/.test(parts[2])) {
throw new Error('Invalid MP4 codec string detail, expected a decimal number');
}
info.objectType = parseInt(parts[2], 10);
}
return info;
}

toString(): string {
const oti = padStart(this._objectTypeIndication.toString(16).toUpperCase(), 2, '0');
const detail = this._objectType !== undefined ? `.${this._objectType}` : '';
return `${this._fourCC}.${oti}${detail}`;
}

toHumanReadable() {
let detail = 'none';
if (this._objectType !== undefined) {
detail = this._fourCC === 'mp4a' && this._objectTypeIndication === 0x40
? hAudioObjectType(this._objectType)
: String(this._objectType);
}
return {
fourCC: this._fourCC,
codec: hObjectTypeIndication(this._objectTypeIndication),
objectTypeIndication: this._objectTypeIndication,
detail,
} as const;
}
}
15 changes: 13 additions & 2 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {codecInfoFactory, version, vpx, av1, h264, h265, h266, lcevc, apv, evc, lhevc} from './index';
import {codecInfoFactory, version, vpx, av1, h264, h265, h266, lcevc, apv, evc, lhevc, mp4} from './index';

describe('codecInfoFactory', () => {
it('dispatches vp8 strings to Vp8Info', () => {
Expand Down Expand Up @@ -68,9 +68,20 @@ describe('codecInfoFactory', () => {
expect((info as lhevc.LhevcInfo).ptl.levelIdc).toBe(120);
});

it('dispatches mp4a/mp4v strings to Mp4Info', () => {
const audio = codecInfoFactory('mp4a.40.2');
expect(audio).toBeInstanceOf(mp4.Mp4Info);
expect(audio.codecName).toBe('mp4a');
expect((audio as mp4.Mp4Info).objectType).toBe(2);

const video = codecInfoFactory('mp4v.20.9');
expect(video).toBeInstanceOf(mp4.Mp4Info);
expect(video.codecName).toBe('mp4v');
});

it('throws on an unknown codec', () => {
expect(() => codecInfoFactory('mp4a.40.2')).toThrow('Unknown codec');
expect(() => codecInfoFactory('theora')).toThrow('Unknown codec');
expect(() => codecInfoFactory('tx3g')).toThrow('Unknown codec');
});
});

Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {LcevcInfo} from "./codec/lcevc";
import {ApvInfo} from "./codec/apv";
import {EvcInfo} from "./codec/evc";
import {LhevcInfo} from "./codec/lhevc";
import {Mp4Info} from "./codec/mp4";

export * as vpx from "./codec/vpx";
export * as av1 from "./codec/av1";
Expand All @@ -17,6 +18,7 @@ export * as lcevc from "./codec/lcevc";
export * as apv from "./codec/apv";
export * as evc from "./codec/evc";
export * as lhevc from "./codec/lhevc";
export * as mp4 from "./codec/mp4";
export * from './codec/codec-info';

export const version = '__lib_version__'; // Version will be injected on the build
Expand Down Expand Up @@ -49,5 +51,8 @@ export const codecInfoFactory = (codecString: string) => {
if (codecString.startsWith('lhv') || codecString.startsWith('lhe')) {
return LhevcInfo.fromString(codecString);
}
if (codecString.startsWith('mp4a') || codecString.startsWith('mp4v')) {
return Mp4Info.fromString(codecString);
}
throw new Error('Unknown codec');
}
Loading