From 7e04b2b0f544498e5619ff3a8ada2d7d634b4d59 Mon Sep 17 00:00:00 2001 From: Igor Sheko Date: Tue, 14 Jul 2026 21:40:10 +0100 Subject: [PATCH] feat: add uncompressed video/image (uncv/unci) codec string support Parse and generate uncompressed codec strings (ISO/IEC 23001-17): [.] for the uncv (video) and unci (image) sample entries, where is a registered uncompressed-video profile 4CC (e.g. uncv.rgba, uncv.i420). Bare uncv is the generic form. Profile 4CCs are decoded to pixel-format descriptions (from mp4ra.org/registered-types/uncv-profiles); unknown profiles round-trip and report 'unknown'. Wired into codecInfoFactory via the uncv/unci prefixes. --- README.md | 13 +++-- src/codec/uncv/enums.ts | 31 ++++++++++++ src/codec/uncv/index.ts | 3 ++ src/codec/uncv/uncv-info.spec.ts | 83 ++++++++++++++++++++++++++++++++ src/codec/uncv/uncv-info.ts | 66 +++++++++++++++++++++++++ src/index.spec.ts | 10 +++- src/index.ts | 5 ++ 7 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 src/codec/uncv/enums.ts create mode 100644 src/codec/uncv/index.ts create mode 100644 src/codec/uncv/uncv-info.spec.ts create mode 100644 src/codec/uncv/uncv-info.ts diff --git a/README.md b/README.md index b220f43..5d7bd8e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Useful for reading codec parameters out of a `codecs=` string, checking support | AVS3 video/audio (avs3/av3a) | ✅ | ✅ | | MPEG-H 3D Audio (mha1/mhm1) | ✅ | ✅ | | Parameterless (DTS, VC-1, Opus, FLAC, Vorbis, ALAC, PCM) | ✅ | ✅ | +| Uncompressed (uncv/unci) | ✅ | ✅ | ## Install @@ -100,11 +101,11 @@ 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`, `avs3`, `av3a`, `mha1`/`mha2`/`mhm1`/`mhm2`) and returns the matching info object - (`Vp8Info`, `Vp9Info`, `Av1Info`, `H264Info`, `H265Info`, `H266Info`, `LcevcInfo`, `ApvInfo`, - `EvcInfo`, `LhevcInfo`, `Mp4Info`, `Avs3VideoInfo`, `Avs3AudioInfo`, `MpeghInfo`). Recognised - parameterless 4CCs (DTS, VC-1, Opus, FLAC, Vorbis, ALAC, PCM) return a `SimpleCodecInfo`. Throws - `Unknown codec` for anything else. + `mp4a`/`mp4v`, `avs3`, `av3a`, `mha1`/`mha2`/`mhm1`/`mhm2`, `uncv`/`unci`) and returns the matching + info object (`Vp8Info`, `Vp9Info`, `Av1Info`, `H264Info`, `H265Info`, `H266Info`, `LcevcInfo`, + `ApvInfo`, `EvcInfo`, `LhevcInfo`, `Mp4Info`, `Avs3VideoInfo`, `Avs3AudioInfo`, `MpeghInfo`, + `UncvInfo`). Recognised parameterless 4CCs (DTS, VC-1, Opus, FLAC, Vorbis, ALAC, PCM) return a + `SimpleCodecInfo`. 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. @@ -132,6 +133,8 @@ console.log(info.toString()); // 'avc1.640028' - `simple` — namespace exporting `SimpleCodecInfo` and the `SIMPLE_CODECS` registry for parameterless 4CCs (DTS `dtsc`/`dtse`/…, `vc-1`, `opus`, `flac`, `vorbis`, `alac`, and PCM variants such as `ipcm`/`fpcm`/`twos`/`sowt`). +- `uncv` — namespace exporting `UncvInfo` for uncompressed video/images (`uncv`/`unci`), with an + optional profile 4CC (`uncv.rgba`, `uncv.i420`, …) decoded to a pixel-format description. - Shared ISO/IEC 23001-8:2016 colour enums (`ColourPrimaries`, `TransferCharacteristics`, `MatrixCoefficients`, `VideoFullRangeFlag`) are re-exported from both namespaces. diff --git a/src/codec/uncv/enums.ts b/src/codec/uncv/enums.ts new file mode 100644 index 0000000..f55449a --- /dev/null +++ b/src/codec/uncv/enums.ts @@ -0,0 +1,31 @@ +// Uncompressed video / images (ISO/IEC 23001-17), sample entries uncv (video) and unci (image). +// Codec string: [.] where is a registered uncompressed-video profile +// 4CC (mp4ra.org/registered-types/uncv-profiles), e.g. uncv.rgba, uncv.i420. Bare uncv is the +// generic (non-profiled) form. + +export type UncvFourCC = 'uncv' | 'unci'; +export const UNCV_FOUR_CCS: readonly UncvFourCC[] = ['uncv', 'unci']; + +const UNCV_PROFILES: Readonly> = { + '2vuy': '8-bit YUV 4:2:2 (Cb Y0 Cr Y1)', + 'yuv2': '8-bit YUV 4:2:2 (Y0 Cb Y1 Cr)', + 'yvyu': '8-bit YUV 4:2:2 (Y0 Cr Y1 Cb)', + 'vyuy': '8-bit YUV 4:2:2 (Cr Y0 Cb Y1)', + 'yuv1': '8-bit YUV 4:1:1 (Y0 Y1 Cb Y2 Y3 Cr)', + 'v308': '8-bit YUV 4:4:4 (Cr Y Cb)', + 'v408': '8-bit YUVA 4:4:4:4 (Cb Y Cr A)', + 'v216': 'YUV 4:2:2 (10-16 bit)', + 'v210': '10-bit YUV 4:2:2', + 'v410': '10-bit YUV 4:4:4', + 'y210': '10-bit YUV 4:2:2 (little-endian)', + 'i420': 'YUV 4:2:0 8-bit planar', + 'nv12': 'YUV 4:2:0 8-bit semi-planar (Y CbCr)', + 'nv21': 'YUV 4:2:0 8-bit semi-planar (Y CrCb)', + 'rgb3': 'RGB 24-bit packed', + 'rgba': 'RGBA 32-bit packed', + 'abgr': 'ABGR 32-bit packed', +}; + +export function hProfile(profile: string): string { + return UNCV_PROFILES[profile.toLowerCase()] ?? 'unknown'; +} diff --git a/src/codec/uncv/index.ts b/src/codec/uncv/index.ts new file mode 100644 index 0000000..24e7c87 --- /dev/null +++ b/src/codec/uncv/index.ts @@ -0,0 +1,3 @@ +export {UNCV_FOUR_CCS, hProfile} from './enums'; +export type {UncvFourCC} from './enums'; +export * from './uncv-info'; diff --git a/src/codec/uncv/uncv-info.spec.ts b/src/codec/uncv/uncv-info.spec.ts new file mode 100644 index 0000000..de4ceff --- /dev/null +++ b/src/codec/uncv/uncv-info.spec.ts @@ -0,0 +1,83 @@ +import {UncvInfo} from "./uncv-info"; + +describe('UncvInfo', () => { + describe('parse / round-trip', () => { + it.each([ + 'uncv', + 'uncv.rgba', + 'uncv.i420', + 'uncv.2vuy', + 'uncv.v210', + 'unci.rgb3', + 'uncv.abcd', + ])('parses and round-trips %s', (str) => { + expect(UncvInfo.fromString(str).toString()).toBe(str); + }); + + it('decodes the fields', () => { + const info = UncvInfo.fromString('uncv.rgba'); + expect(info.fourCC).toBe('uncv'); + expect(info.profile).toBe('rgba'); + }); + + it('parses the bare generic form', () => { + const info = UncvInfo.fromString('uncv'); + expect(info.profile).toBeUndefined(); + }); + + it('sets codecName from the sample entry', () => { + expect(UncvInfo.fromString('unci.rgb3').codecName).toBe('unci'); + }); + }); + + describe('human-readable', () => { + it.each([ + ['uncv.rgba', 'rgba', 'RGBA 32-bit packed'], + ['uncv.i420', 'i420', 'YUV 4:2:0 8-bit planar'], + ['uncv.v210', 'v210', '10-bit YUV 4:2:2'], + ['uncv.abcd', 'abcd', 'unknown'], + ])('%s -> %s / %s', (str, profile, format) => { + const hr = UncvInfo.fromString(str).toHumanReadable(); + expect(hr.profile).toBe(profile); + expect(hr.format).toBe(format); + }); + + it('reports the generic form', () => { + expect(UncvInfo.fromString('uncv').toHumanReadable()).toEqual({ + fourCC: 'uncv', + profile: 'none', + format: 'generic', + }); + }); + }); + + describe('build', () => { + it('assembles a profiled codec string', () => { + const info = new UncvInfo(); + info.fourCC = 'uncv'; + info.profile = 'rgba'; + expect(info.toString()).toBe('uncv.rgba'); + }); + + it('assembles the generic form', () => { + expect(new UncvInfo().toString()).toBe('uncv'); + }); + + it('rejects a profile that is not four characters', () => { + expect(() => { new UncvInfo().profile = 'rgb'; }).toThrow('four-character code'); + }); + }); + + describe('invalid input', () => { + it.each(['uncv.rgba.x', 'uncv.rgb'])( + 'rejects malformed string %s', + (str) => { + expect(() => UncvInfo.fromString(str)).toThrow('Invalid uncv'); + }, + ); + + it('rejects an unknown 4CC', () => { + expect(() => UncvInfo.fromString('uncx.rgba')).toThrow('Unknown codec'); + }); + }); +}); diff --git a/src/codec/uncv/uncv-info.ts b/src/codec/uncv/uncv-info.ts new file mode 100644 index 0000000..2aa7f39 --- /dev/null +++ b/src/codec/uncv/uncv-info.ts @@ -0,0 +1,66 @@ +import {CodecInfo} from "../codec-info"; +import {UNCV_FOUR_CCS, UncvFourCC, hProfile} from "./enums"; + +// Uncompressed video/image codec information. Codec string: [.]. +export class UncvInfo extends CodecInfo { + codecName = 'uncv'; + + private _fourCC: UncvFourCC = 'uncv'; + private _profile: string | undefined = undefined; + + get fourCC(): UncvFourCC { + return this._fourCC; + } + + set fourCC(fourCC: UncvFourCC) { + if (!UNCV_FOUR_CCS.includes(fourCC)) { + throw new Error(`Invalid uncompressed sample entry: ${fourCC}`); + } + this._fourCC = fourCC; + this.codecName = fourCC; + } + + // The uncompressed-video profile 4CC (e.g. "rgba"), or undefined for the generic form. + get profile(): string | undefined { + return this._profile; + } + + set profile(profile: string | undefined) { + if (profile !== undefined && !/^[0-9a-zA-Z]{4}$/.test(profile)) { + throw new Error('uncv profile must be a four-character code'); + } + this._profile = profile; + } + + static fromString(codecString: string): UncvInfo { + const parts = codecString.split('.'); + const fourCC = parts[0] as UncvFourCC; + if (!UNCV_FOUR_CCS.includes(fourCC)) { + throw new Error('Unknown codec'); + } + if (parts.length > 2) { + throw new Error('Invalid uncv codec string, expected [.]'); + } + const info = new UncvInfo(); + info.fourCC = fourCC; + if (parts[1] !== undefined) { + if (!/^[0-9a-zA-Z]{4}$/.test(parts[1])) { + throw new Error('Invalid uncv profile, expected a four-character code'); + } + info.profile = parts[1]; + } + return info; + } + + toString(): string { + return this._profile !== undefined ? `${this._fourCC}.${this._profile}` : this._fourCC; + } + + toHumanReadable() { + return { + fourCC: this._fourCC, + profile: this._profile ?? 'none', + format: this._profile !== undefined ? hProfile(this._profile) : 'generic', + } as const; + } +} diff --git a/src/index.spec.ts b/src/index.spec.ts index 65cf473..4918547 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, avs3, mpegh, simple} from './index'; +import {codecInfoFactory, version, vpx, av1, h264, h265, h266, lcevc, apv, evc, lhevc, mp4, avs3, mpegh, simple, uncv} from './index'; describe('codecInfoFactory', () => { it('dispatches vp8 strings to Vp8Info', () => { @@ -109,6 +109,14 @@ describe('codecInfoFactory', () => { expect(codecInfoFactory('vc-1').codecName).toBe('vc-1'); }); + it('dispatches uncv/unci strings to UncvInfo', () => { + const info = codecInfoFactory('uncv.rgba'); + expect(info).toBeInstanceOf(uncv.UncvInfo); + expect(info.codecName).toBe('uncv'); + expect((info as uncv.UncvInfo).profile).toBe('rgba'); + expect(codecInfoFactory('uncv')).toBeInstanceOf(uncv.UncvInfo); + }); + 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 5e505d0..b15b909 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import {Mp4Info} from "./codec/mp4"; import {Avs3VideoInfo, Avs3AudioInfo} from "./codec/avs3"; import {MpeghInfo} from "./codec/mpegh"; import {SimpleCodecInfo, isSimpleCodec} from "./codec/simple"; +import {UncvInfo} from "./codec/uncv"; export * as vpx from "./codec/vpx"; export * as av1 from "./codec/av1"; @@ -25,6 +26,7 @@ export * as mp4 from "./codec/mp4"; export * as avs3 from "./codec/avs3"; export * as mpegh from "./codec/mpegh"; export * as simple from "./codec/simple"; +export * as uncv from "./codec/uncv"; export * from './codec/codec-info'; export const version = '__lib_version__'; // Version will be injected on the build @@ -69,6 +71,9 @@ export const codecInfoFactory = (codecString: string) => { if (codecString.startsWith('mha') || codecString.startsWith('mhm')) { return MpeghInfo.fromString(codecString); } + if (codecString.startsWith('uncv') || codecString.startsWith('unci')) { + return UncvInfo.fromString(codecString); + } if (isSimpleCodec(codecString)) { return SimpleCodecInfo.fromString(codecString); }