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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Useful for reading codec parameters out of a `codecs=` string, checking support
| EVC | ✅ | ✅ |
| L-HEVC | ✅ | ✅ |
| MP4 (mp4a/mp4v — AAC, MP3, MPEG-4/2) | ✅ | ✅ |
| AVS3 video/audio (avs3/av3a) | ✅ | ✅ |

## Install

Expand Down Expand Up @@ -97,8 +98,9 @@ console.log(info.toString()); // 'avc1.640028'

- `codecInfoFactory(codecString)` — dispatches by prefix (`vp08`/`vp8`, `vp09`/`vp9`, `av01`,
`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.
`mp4a`/`mp4v`, `avs3`, `av3a`) and returns a `Vp8Info`, `Vp9Info`, `Av1Info`, `H264Info`, `H265Info`,
`H266Info`, `LcevcInfo`, `ApvInfo`, `EvcInfo`, `LhevcInfo`, `Mp4Info`, `Avs3VideoInfo`, or
`Avs3AudioInfo`. 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 @@ -119,6 +121,8 @@ console.log(info.toString()); // 'avc1.640028'
- `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`, …).
- `avs3` — namespace exporting `Avs3VideoInfo` (`avs3.<profile>.<level>`) and `Avs3AudioInfo`
(`av3a.<codec_id>`) for the AVS3 video/audio standard.
- Shared ISO/IEC 23001-8:2016 colour enums (`ColourPrimaries`, `TransferCharacteristics`,
`MatrixCoefficients`, `VideoFullRangeFlag`) are re-exported from both namespaces.

Expand Down
50 changes: 50 additions & 0 deletions src/codec/avs3/avs3-audio-info.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {Avs3AudioInfo} from "./avs3-audio-info";

describe('Avs3AudioInfo', () => {
describe('parse / round-trip', () => {
it.each(['av3a.00', 'av3a.01', 'av3a.02'])('parses and round-trips %s', (str) => {
expect(Avs3AudioInfo.fromString(str).toString()).toBe(str);
});

it('decodes the audio codec id', () => {
const info = Avs3AudioInfo.fromString('av3a.01');
expect(info.fourCC).toBe('av3a');
expect(info.audioCodecId).toBe(1);
});
});

describe('human-readable', () => {
it.each([
['av3a.00', 'General Audio Coding'],
['av3a.01', 'Lossless Audio Coding'],
['av3a.02', 'Full Rate Audio Coding'],
])('%s -> %s', (str, codec) => {
expect(Avs3AudioInfo.fromString(str).toHumanReadable().codec).toBe(codec);
});
});

describe('build', () => {
it('assembles a codec string from parts', () => {
const info = new Avs3AudioInfo();
info.audioCodecId = 2;
expect(info.toString()).toBe('av3a.02');
});

it('rejects out-of-range bytes', () => {
expect(() => { new Avs3AudioInfo().audioCodecId = 256; }).toThrow('audio_codec_id');
});
});

describe('invalid input', () => {
it.each(['av3a', 'av3a.00.01', 'av3a.GG'])(
'rejects malformed string %s',
(str) => {
expect(() => Avs3AudioInfo.fromString(str)).toThrow('Invalid AVS3 audio codec string');
},
);

it('rejects an unknown 4CC', () => {
expect(() => Avs3AudioInfo.fromString('av4a.00')).toThrow('Unknown codec');
});
});
});
64 changes: 64 additions & 0 deletions src/codec/avs3/avs3-audio-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {CodecInfo} from "../codec-info";
import {padStart} from "../pad-start";
import {AVS3_AUDIO_FOUR_CCS, Avs3AudioFourCC, hAudioCodec} from "./enums";

function assertByte(value: number, name: string): number {
if (!Number.isInteger(value) || value < 0 || value > 255) {
throw new Error(`${name} must be a byte in the range 0-255`);
}
return value;
}

// AVS3 audio codec information. Codec string: av3a.<audio_codec_id> (one hex byte).
export class Avs3AudioInfo extends CodecInfo {
codecName = 'av3a';

private _fourCC: Avs3AudioFourCC = 'av3a';
private _audioCodecId = 0;

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

set fourCC(fourCC: Avs3AudioFourCC) {
if (!AVS3_AUDIO_FOUR_CCS.includes(fourCC)) {
throw new Error(`Invalid AVS3 audio sample entry: ${fourCC}`);
}
this._fourCC = fourCC;
}

get audioCodecId(): number {
return this._audioCodecId;
}

set audioCodecId(audioCodecId: number) {
this._audioCodecId = assertByte(audioCodecId, 'audio_codec_id');
}

static fromString(codecString: string): Avs3AudioInfo {
const parts = codecString.split('.');
const fourCC = parts[0] as Avs3AudioFourCC;
if (!AVS3_AUDIO_FOUR_CCS.includes(fourCC)) {
throw new Error('Unknown codec');
}
if (parts.length !== 2 || !/^[0-9a-fA-F]{1,2}$/.test(parts[1])) {
throw new Error('Invalid AVS3 audio codec string, expected av3a.<audio_codec_id>');
}
const info = new Avs3AudioInfo();
info.fourCC = fourCC;
info.audioCodecId = parseInt(parts[1], 16);
return info;
}

toString(): string {
return `${this._fourCC}.${padStart(this._audioCodecId.toString(16), 2, '0')}`;
}

toHumanReadable() {
return {
fourCC: this._fourCC,
codec: hAudioCodec(this._audioCodecId),
audioCodecId: this._audioCodecId,
} as const;
}
}
65 changes: 65 additions & 0 deletions src/codec/avs3/avs3-video-info.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {Avs3VideoInfo} from "./avs3-video-info";

describe('Avs3VideoInfo', () => {
describe('parse / round-trip', () => {
it.each([
'avs3.20.10',
'avs3.32.50',
'avs3.22.20',
'avs3.ab.cd',
])('parses and round-trips %s', (str) => {
expect(Avs3VideoInfo.fromString(str).toString()).toBe(str);
});

it('decodes the fields', () => {
const info = Avs3VideoInfo.fromString('avs3.20.10');
expect(info.fourCC).toBe('avs3');
expect(info.profileId).toBe(0x20);
expect(info.levelId).toBe(0x10);
});

it('normalizes hex to lowercase', () => {
expect(Avs3VideoInfo.fromString('avs3.AB.CD').toString()).toBe('avs3.ab.cd');
});
});

describe('human-readable', () => {
it.each([
['avs3.20.10', 'Main 8-bit', '2.0.15'],
['avs3.22.20', 'Main 10-bit', '4.0.30'],
['avs3.30.50', 'High 8-bit', '8.0.30'],
['avs3.32.50', 'High 10-bit', '8.0.30'],
])('%s -> %s / level %s', (str, profile, level) => {
const hr = Avs3VideoInfo.fromString(str).toHumanReadable();
expect(hr.profile).toBe(profile);
expect(hr.level).toBe(level);
});
});

describe('build', () => {
it('assembles a codec string from parts', () => {
const info = new Avs3VideoInfo();
info.profileId = 0x20;
info.levelId = 0x10;
expect(info.toString()).toBe('avs3.20.10');
});

it('rejects out-of-range bytes', () => {
expect(() => { new Avs3VideoInfo().profileId = 256; }).toThrow('profile_id');
expect(() => { new Avs3VideoInfo().levelId = -1; }).toThrow('level_id');
});
});

describe('invalid input', () => {
it.each(['avs3', 'avs3.20', 'avs3.20.10.5', 'avs3.GG.10'])(
'rejects malformed string %s',
(str) => {
expect(() => Avs3VideoInfo.fromString(str)).toThrow('Invalid AVS3 video codec string');
},
);

it('rejects an unknown 4CC', () => {
expect(() => Avs3VideoInfo.fromString('avs2.20.10')).toThrow('Unknown codec');
});
});
});
77 changes: 77 additions & 0 deletions src/codec/avs3/avs3-video-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {CodecInfo} from "../codec-info";
import {padStart} from "../pad-start";
import {AVS3_VIDEO_FOUR_CCS, Avs3VideoFourCC, hVideoLevel, hVideoProfile} from "./enums";

function assertByte(value: number, name: string): number {
if (!Number.isInteger(value) || value < 0 || value > 255) {
throw new Error(`${name} must be a byte in the range 0-255`);
}
return value;
}

// AVS3 video codec information. Codec string: avs3.<profile_id>.<level_id> (two hex bytes).
export class Avs3VideoInfo extends CodecInfo {
codecName = 'avs3';

private _fourCC: Avs3VideoFourCC = 'avs3';
private _profileId = 0;
private _levelId = 0;

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

set fourCC(fourCC: Avs3VideoFourCC) {
if (!AVS3_VIDEO_FOUR_CCS.includes(fourCC)) {
throw new Error(`Invalid AVS3 video sample entry: ${fourCC}`);
}
this._fourCC = fourCC;
}

get profileId(): number {
return this._profileId;
}

set profileId(profileId: number) {
this._profileId = assertByte(profileId, 'profile_id');
}

get levelId(): number {
return this._levelId;
}

set levelId(levelId: number) {
this._levelId = assertByte(levelId, 'level_id');
}

static fromString(codecString: string): Avs3VideoInfo {
const parts = codecString.split('.');
const fourCC = parts[0] as Avs3VideoFourCC;
if (!AVS3_VIDEO_FOUR_CCS.includes(fourCC)) {
throw new Error('Unknown codec');
}
if (parts.length !== 3 || !/^[0-9a-fA-F]{1,2}$/.test(parts[1]) || !/^[0-9a-fA-F]{1,2}$/.test(parts[2])) {
throw new Error('Invalid AVS3 video codec string, expected avs3.<profile_id>.<level_id>');
}
const info = new Avs3VideoInfo();
info.fourCC = fourCC;
info.profileId = parseInt(parts[1], 16);
info.levelId = parseInt(parts[2], 16);
return info;
}

toString(): string {
const hex = (value: number) => padStart(value.toString(16), 2, '0');
return `${this._fourCC}.${hex(this._profileId)}.${hex(this._levelId)}`;
}

toHumanReadable() {
return {
fourCC: this._fourCC,
profile: hVideoProfile(this._profileId),
profileId: this._profileId,
level: hVideoLevel(this._levelId),
levelId: this._levelId,
} as const;
}
}
38 changes: 38 additions & 0 deletions src/codec/avs3/enums.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// AVS3 (China AVS working group). Codec strings:
// avs3.<profile_id>.<level_id> (video, hex bytes) e.g. avs3.20.10, avs3.32.50
// av3a.<audio_codec_id> (audio, hex byte) e.g. av3a.00

export type Avs3VideoFourCC = 'avs3';
export const AVS3_VIDEO_FOUR_CCS: readonly Avs3VideoFourCC[] = ['avs3'];

export type Avs3AudioFourCC = 'av3a';
export const AVS3_AUDIO_FOUR_CCS: readonly Avs3AudioFourCC[] = ['av3a'];

export function hVideoProfile(profileId: number): string {
switch (profileId) {
case 0x20: return 'Main 8-bit';
case 0x22: return 'Main 10-bit';
case 0x30: return 'High 8-bit';
case 0x32: return 'High 10-bit';
default: return 'unknown';
}
}

// Only the level_id values with a confirmed mapping are named; others are reported by raw levelId.
export function hVideoLevel(levelId: number): string {
switch (levelId) {
case 0x10: return '2.0.15';
case 0x20: return '4.0.30';
case 0x50: return '8.0.30';
default: return 'unknown';
}
}

export function hAudioCodec(audioCodecId: number): string {
switch (audioCodecId) {
case 0x00: return 'General Audio Coding';
case 0x01: return 'Lossless Audio Coding';
case 0x02: return 'Full Rate Audio Coding';
default: return 'unknown';
}
}
10 changes: 10 additions & 0 deletions src/codec/avs3/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export {
AVS3_VIDEO_FOUR_CCS,
AVS3_AUDIO_FOUR_CCS,
hVideoProfile,
hVideoLevel,
hAudioCodec,
} from './enums';
export type {Avs3VideoFourCC, Avs3AudioFourCC} from './enums';
export * from './avs3-video-info';
export * from './avs3-audio-info';
15 changes: 14 additions & 1 deletion 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, mp4} from './index';
import {codecInfoFactory, version, vpx, av1, h264, h265, h266, lcevc, apv, evc, lhevc, mp4, avs3} from './index';

describe('codecInfoFactory', () => {
it('dispatches vp8 strings to Vp8Info', () => {
Expand Down Expand Up @@ -79,6 +79,19 @@ describe('codecInfoFactory', () => {
expect(video.codecName).toBe('mp4v');
});

it('dispatches avs3 strings to Avs3VideoInfo', () => {
const info = codecInfoFactory('avs3.20.10');
expect(info).toBeInstanceOf(avs3.Avs3VideoInfo);
expect(info.codecName).toBe('avs3');
expect((info as avs3.Avs3VideoInfo).profileId).toBe(0x20);
});

it('dispatches av3a strings to Avs3AudioInfo', () => {
const info = codecInfoFactory('av3a.01');
expect(info).toBeInstanceOf(avs3.Avs3AudioInfo);
expect(info.codecName).toBe('av3a');
});

it('throws on an unknown codec', () => {
expect(() => codecInfoFactory('theora')).toThrow('Unknown codec');
expect(() => codecInfoFactory('tx3g')).toThrow('Unknown codec');
Expand Down
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {ApvInfo} from "./codec/apv";
import {EvcInfo} from "./codec/evc";
import {LhevcInfo} from "./codec/lhevc";
import {Mp4Info} from "./codec/mp4";
import {Avs3VideoInfo, Avs3AudioInfo} from "./codec/avs3";

export * as vpx from "./codec/vpx";
export * as av1 from "./codec/av1";
Expand All @@ -19,6 +20,7 @@ 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 * as avs3 from "./codec/avs3";
export * from './codec/codec-info';

export const version = '__lib_version__'; // Version will be injected on the build
Expand Down Expand Up @@ -54,5 +56,11 @@ export const codecInfoFactory = (codecString: string) => {
if (codecString.startsWith('mp4a') || codecString.startsWith('mp4v')) {
return Mp4Info.fromString(codecString);
}
if (codecString.startsWith('avs3')) {
return Avs3VideoInfo.fromString(codecString);
}
if (codecString.startsWith('av3a')) {
return Avs3AudioInfo.fromString(codecString);
}
throw new Error('Unknown codec');
}
Loading