diff --git a/README.md b/README.md index c534a2b..472c931 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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..`) and `Avs3AudioInfo` + (`av3a.`) for the AVS3 video/audio standard. - Shared ISO/IEC 23001-8:2016 colour enums (`ColourPrimaries`, `TransferCharacteristics`, `MatrixCoefficients`, `VideoFullRangeFlag`) are re-exported from both namespaces. diff --git a/src/codec/avs3/avs3-audio-info.spec.ts b/src/codec/avs3/avs3-audio-info.spec.ts new file mode 100644 index 0000000..3ebb205 --- /dev/null +++ b/src/codec/avs3/avs3-audio-info.spec.ts @@ -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'); + }); + }); +}); diff --git a/src/codec/avs3/avs3-audio-info.ts b/src/codec/avs3/avs3-audio-info.ts new file mode 100644 index 0000000..d618abd --- /dev/null +++ b/src/codec/avs3/avs3-audio-info.ts @@ -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. (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.'); + } + 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; + } +} diff --git a/src/codec/avs3/avs3-video-info.spec.ts b/src/codec/avs3/avs3-video-info.spec.ts new file mode 100644 index 0000000..b8b1ce2 --- /dev/null +++ b/src/codec/avs3/avs3-video-info.spec.ts @@ -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'); + }); + }); +}); diff --git a/src/codec/avs3/avs3-video-info.ts b/src/codec/avs3/avs3-video-info.ts new file mode 100644 index 0000000..d25e209 --- /dev/null +++ b/src/codec/avs3/avs3-video-info.ts @@ -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.. (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..'); + } + 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; + } +} diff --git a/src/codec/avs3/enums.ts b/src/codec/avs3/enums.ts new file mode 100644 index 0000000..838ac3e --- /dev/null +++ b/src/codec/avs3/enums.ts @@ -0,0 +1,38 @@ +// AVS3 (China AVS working group). Codec strings: +// avs3.. (video, hex bytes) e.g. avs3.20.10, avs3.32.50 +// av3a. (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'; + } +} diff --git a/src/codec/avs3/index.ts b/src/codec/avs3/index.ts new file mode 100644 index 0000000..458845f --- /dev/null +++ b/src/codec/avs3/index.ts @@ -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'; diff --git a/src/index.spec.ts b/src/index.spec.ts index 47849e1..62ecefa 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -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', () => { @@ -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'); diff --git a/src/index.ts b/src/index.ts index 6401c61..a984c2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -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 @@ -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'); } \ No newline at end of file