From e1a2b1605ee67bc96fce26557dee426ceb72cd40 Mon Sep 17 00:00:00 2001 From: Igor Sheko Date: Tue, 14 Jul 2026 22:28:43 +0100 Subject: [PATCH] feat: add subtitle codec string support (WebVTT wvtt, TTML stpp) WebVTT (wvtt) is parameterless and joins the SimpleCodecInfo registry. TTML/timed-text (stpp) is structured: stpp[.[.]] (e.g. stpp.ttml.im1t = IMSC1 text). StppInfo decodes the mode and the TTML profile designator (IMSC1/2, TTML1/2, EBU-TT, DECE) to a description, preserving unknown profiles verbatim. Format verified against paulhiggs/codec-string. Wired into codecInfoFactory via the stpp prefix (wvtt via isSimpleCodec). --- README.md | 10 ++- src/codec/simple/enums.ts | 2 + src/codec/simple/simple-codec-info.spec.ts | 3 +- src/codec/stpp/enums.ts | 35 ++++++++++ src/codec/stpp/index.ts | 3 + src/codec/stpp/stpp-info.spec.ts | 73 ++++++++++++++++++++ src/codec/stpp/stpp-info.ts | 78 ++++++++++++++++++++++ src/index.spec.ts | 15 ++++- src/index.ts | 5 ++ 9 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 src/codec/stpp/enums.ts create mode 100644 src/codec/stpp/index.ts create mode 100644 src/codec/stpp/stpp-info.spec.ts create mode 100644 src/codec/stpp/stpp-info.ts diff --git a/README.md b/README.md index 7c4cd83..504a0a0 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Useful for reading codec parameters out of a `codecs=` string, checking support | MPEG-H 3D Audio (mha1/mhm1) | ✅ | ✅ | | Parameterless (DTS, VC-1, Opus, FLAC, Vorbis, ALAC, PCM) | ✅ | ✅ | | Uncompressed (uncv/unci) | ✅ | ✅ | +| Subtitles (WebVTT wvtt, TTML stpp) | ✅ | ✅ | ## Install @@ -102,7 +103,7 @@ 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`, `cavs`, `mha1`/`mha2`/`mhm1`/`mhm2`, `uncv`/`unci`) and returns the matching + `mp4a`/`mp4v`, `avs3`, `av3a`, `cavs`, `mha1`/`mha2`/`mhm1`/`mhm2`, `uncv`/`unci`, `stpp`) 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 @@ -130,11 +131,14 @@ console.log(info.toString()); // 'avc1.640028' - `avs3` — namespace exporting `Avs3VideoInfo` (`avs3..`) and `Avs3AudioInfo` (`av3a.`) for the AVS3 video/audio standard. - `avs2` — namespace exporting `Avs2AudioInfo` for AVS2 audio (`cavs.`). +- `stpp` — namespace exporting `StppInfo` for TTML / timed-text subtitles + (`stpp[.[.]]`, e.g. `stpp.ttml.im1t`), with the TTML profile decoded to a + description. WebVTT (`wvtt`) is parameterless and handled by `SimpleCodecInfo`. - `mpegh` — namespace exporting `MpeghInfo` (MPEG-H 3D Audio, `mha1`/`mha2`/`mhm1`/`mhm2` + a `profileLevelId`, e.g. `mhm1.0c`). - `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`). + parameterless 4CCs (DTS `dtsc`/`dtse`/…, `vc-1`, `opus`, `flac`, `vorbis`, `alac`, `wvtt` (WebVTT), + 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`, diff --git a/src/codec/simple/enums.ts b/src/codec/simple/enums.ts index 252cf71..d0079b6 100644 --- a/src/codec/simple/enums.ts +++ b/src/codec/simple/enums.ts @@ -16,6 +16,8 @@ export const SIMPLE_CODECS: Readonly> = { 'flac': 'FLAC', 'vorbis': 'Vorbis', 'alac': 'Apple Lossless (ALAC)', + // WebVTT subtitles (parameterless; TTML/stpp is handled separately as it carries a profile) + 'wvtt': 'WebVTT', // PCM variants 'ipcm': 'Uncompressed PCM', 'fpcm': 'Floating-point PCM', diff --git a/src/codec/simple/simple-codec-info.spec.ts b/src/codec/simple/simple-codec-info.spec.ts index befdeb4..55bf663 100644 --- a/src/codec/simple/simple-codec-info.spec.ts +++ b/src/codec/simple/simple-codec-info.spec.ts @@ -5,7 +5,7 @@ describe('SimpleCodecInfo', () => { it.each([ 'opus', 'flac', 'vorbis', 'alac', 'dtsc', 'dtse', 'dtsh', 'dtsl', 'dtsx', 'dtsy', - 'vc-1', + 'vc-1', 'wvtt', 'twos', 'sowt', 'lpcm', 'ipcm', 'fpcm', 'in24', 'in32', 'fl32', 'fl64', ])('parses and round-trips %s', (str) => { expect(SimpleCodecInfo.fromString(str).toString()).toBe(str); @@ -33,6 +33,7 @@ describe('SimpleCodecInfo', () => { ['vc-1', 'SMPTE VC-1'], ['twos', 'PCM (big-endian)'], ['sowt', 'PCM (little-endian)'], + ['wvtt', 'WebVTT'], ])('%s -> %s', (str, name) => { expect(SimpleCodecInfo.fromString(str).toHumanReadable().codec).toBe(name); }); diff --git a/src/codec/stpp/enums.ts b/src/codec/stpp/enums.ts new file mode 100644 index 0000000..74a7930 --- /dev/null +++ b/src/codec/stpp/enums.ts @@ -0,0 +1,35 @@ +// TTML / XML timed-text subtitles, sample entry stpp (ISO/IEC 14496-30). Codec string: +// stpp[.[.]] e.g. stpp.ttml.im1t (IMSC1 text), stpp.ttml.etx1 (EBU-TT v1.0) +// The only defined mode is "ttml"; the profile is a registered TTML profile designator. + +export type StppFourCC = 'stpp'; +export const STPP_FOUR_CCS: readonly StppFourCC[] = ['stpp']; + +const STPP_TTML_PROFILES: Readonly> = { + 'im1t': 'IMSC1 text', + 'im1i': 'IMSC1 image', + 'im2t': 'IMSC2 text', + 'im2i': 'IMSC2 image', + 'tt1f': 'TTML1 full', + 'tt1p': 'TTML1 presentation', + 'tt1s': 'TTML1 simple delivery (US closed captions)', + 'tt1t': 'TTML1 transformation', + 'tt2f': 'TTML2 full', + 'tt2p': 'TTML2 presentation', + 'tt2t': 'TTML2 transformation', + 'etx1': 'EBU Subtitling Format v1.0', + 'etx2': 'EBU Subtitling Format v1.1', + 'etx3': 'EBU Subtitling Format v1.2', + 'etd1': 'EBU-TT Distribution v1.0', + 'etd2': 'EBU-TT Distribution v1.0.1', + 'etl1': 'EBU-TT Live', + 'ede1': 'IRT EBU-TT-D', + 'cfi1': 'DECE Image Subtitle Profile', + 'cft1': 'DECE Test Subtitle Profile', +}; + +export function hProfile(mode: string | undefined, profile: string | undefined): string { + if (profile === undefined) return 'none'; + if (mode === 'ttml') return STPP_TTML_PROFILES[profile.toLowerCase()] ?? 'unknown'; + return 'unknown'; +} diff --git a/src/codec/stpp/index.ts b/src/codec/stpp/index.ts new file mode 100644 index 0000000..ed5e818 --- /dev/null +++ b/src/codec/stpp/index.ts @@ -0,0 +1,3 @@ +export {STPP_FOUR_CCS, hProfile} from './enums'; +export type {StppFourCC} from './enums'; +export * from './stpp-info'; diff --git a/src/codec/stpp/stpp-info.spec.ts b/src/codec/stpp/stpp-info.spec.ts new file mode 100644 index 0000000..9131ea1 --- /dev/null +++ b/src/codec/stpp/stpp-info.spec.ts @@ -0,0 +1,73 @@ +import {StppInfo} from "./stpp-info"; + +describe('StppInfo', () => { + describe('parse / round-trip', () => { + it.each([ + 'stpp', + 'stpp.ttml', + 'stpp.ttml.im1t', + 'stpp.ttml.etx1', + 'stpp.ttml.tt2f', + 'stpp.ttml.abcd', + ])('parses and round-trips %s', (str) => { + expect(StppInfo.fromString(str).toString()).toBe(str); + }); + + it('decodes the mode and profile', () => { + const info = StppInfo.fromString('stpp.ttml.im1t'); + expect(info.mode).toBe('ttml'); + expect(info.profile).toBe('im1t'); + }); + + it('parses the bare and mode-only forms', () => { + expect(StppInfo.fromString('stpp').mode).toBeUndefined(); + const modeOnly = StppInfo.fromString('stpp.ttml'); + expect(modeOnly.mode).toBe('ttml'); + expect(modeOnly.profile).toBeUndefined(); + }); + }); + + describe('human-readable', () => { + it.each([ + ['stpp.ttml.im1t', 'IMSC1 text'], + ['stpp.ttml.im1i', 'IMSC1 image'], + ['stpp.ttml.tt2f', 'TTML2 full'], + ['stpp.ttml.etx1', 'EBU Subtitling Format v1.0'], + ['stpp.ttml.abcd', 'unknown'], + ])('%s -> %s', (str, description) => { + expect(StppInfo.fromString(str).toHumanReadable().description).toBe(description); + }); + + it('reports the bare form', () => { + expect(StppInfo.fromString('stpp').toHumanReadable()).toEqual({ + fourCC: 'stpp', + mode: 'none', + profile: 'none', + description: 'none', + }); + }); + }); + + describe('build', () => { + it('assembles a codec string from parts', () => { + const info = new StppInfo(); + info.mode = 'ttml'; + info.profile = 'im1t'; + expect(info.toString()).toBe('stpp.ttml.im1t'); + }); + + it('requires a mode before a profile', () => { + expect(() => { new StppInfo().profile = 'im1t'; }).toThrow('requires a mode'); + }); + }); + + describe('invalid input', () => { + it('rejects an empty mode', () => { + expect(() => StppInfo.fromString('stpp..im1t')).toThrow('Invalid stpp mode'); + }); + + it('rejects an unknown 4CC', () => { + expect(() => StppInfo.fromString('sbtt.ttml')).toThrow('Unknown codec'); + }); + }); +}); diff --git a/src/codec/stpp/stpp-info.ts b/src/codec/stpp/stpp-info.ts new file mode 100644 index 0000000..89c62f6 --- /dev/null +++ b/src/codec/stpp/stpp-info.ts @@ -0,0 +1,78 @@ +import {CodecInfo} from "../codec-info"; +import {hProfile} from "./enums"; + +// TTML / timed-text (stpp) codec information. Codec string: stpp[.[.]]. +export class StppInfo extends CodecInfo { + codecName = 'stpp'; + + private _mode: string | undefined = undefined; + private _profile: string | undefined = undefined; + + // The subtitle mode, e.g. "ttml" (undefined for a bare "stpp"). + get mode(): string | undefined { + return this._mode; + } + + set mode(mode: string | undefined) { + if (mode !== undefined && !/^[a-zA-Z0-9]+$/.test(mode)) { + throw new Error('stpp mode must be an alphanumeric token'); + } + this._mode = mode; + if (mode === undefined) { + this._profile = undefined; + } + } + + // The TTML profile designator, e.g. "im1t" (undefined when absent). + get profile(): string | undefined { + return this._profile; + } + + set profile(profile: string | undefined) { + if (profile === undefined) { + this._profile = undefined; + return; + } + if (this._mode === undefined) { + throw new Error('stpp profile requires a mode (e.g. "ttml") to be set first'); + } + if (!/^[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*$/.test(profile)) { + throw new Error('stpp profile must be an alphanumeric designator'); + } + this._profile = profile; + } + + static fromString(codecString: string): StppInfo { + const parts = codecString.split('.'); + if (parts[0] !== 'stpp') { + throw new Error('Unknown codec'); + } + const info = new StppInfo(); + if (parts[1] !== undefined) { + if (!/^[a-zA-Z0-9]+$/.test(parts[1])) { + throw new Error('Invalid stpp mode'); + } + info.mode = parts[1]; + } + if (parts.length > 2) { + info.profile = parts.slice(2).join('.'); + } + return info; + } + + toString(): string { + const parts = ['stpp']; + if (this._mode !== undefined) parts.push(this._mode); + if (this._profile !== undefined) parts.push(this._profile); + return parts.join('.'); + } + + toHumanReadable() { + return { + fourCC: 'stpp' as const, + mode: this._mode ?? 'none', + profile: this._profile ?? 'none', + description: hProfile(this._mode, this._profile), + } as const; + } +} diff --git a/src/index.spec.ts b/src/index.spec.ts index 7d47bdc..6979825 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, uncv, avs2} from './index'; +import {codecInfoFactory, version, vpx, av1, h264, h265, h266, lcevc, apv, evc, lhevc, mp4, avs3, mpegh, simple, uncv, avs2, stpp} from './index'; describe('codecInfoFactory', () => { it('dispatches vp8 strings to Vp8Info', () => { @@ -124,6 +124,19 @@ describe('codecInfoFactory', () => { expect((info as avs2.Avs2AudioInfo).audioCodecId).toBe(1); }); + it('dispatches stpp strings to StppInfo', () => { + const info = codecInfoFactory('stpp.ttml.im1t'); + expect(info).toBeInstanceOf(stpp.StppInfo); + expect(info.codecName).toBe('stpp'); + expect((info as stpp.StppInfo).profile).toBe('im1t'); + }); + + it('dispatches wvtt to SimpleCodecInfo', () => { + const info = codecInfoFactory('wvtt'); + expect(info).toBeInstanceOf(simple.SimpleCodecInfo); + expect((info as simple.SimpleCodecInfo).toHumanReadable().codec).toBe('WebVTT'); + }); + 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 4c652dd..0625064 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import {MpeghInfo} from "./codec/mpegh"; import {SimpleCodecInfo, isSimpleCodec} from "./codec/simple"; import {UncvInfo} from "./codec/uncv"; import {Avs2AudioInfo} from "./codec/avs2"; +import {StppInfo} from "./codec/stpp"; export * as vpx from "./codec/vpx"; export * as av1 from "./codec/av1"; @@ -29,6 +30,7 @@ export * as mpegh from "./codec/mpegh"; export * as simple from "./codec/simple"; export * as uncv from "./codec/uncv"; export * as avs2 from "./codec/avs2"; +export * as stpp from "./codec/stpp"; export * from './codec/codec-info'; export const version = '__lib_version__'; // Version will be injected on the build @@ -79,6 +81,9 @@ export const codecInfoFactory = (codecString: string) => { if (codecString.startsWith('uncv') || codecString.startsWith('unci')) { return UncvInfo.fromString(codecString); } + if (codecString.startsWith('stpp')) { + return StppInfo.fromString(codecString); + } if (isSimpleCodec(codecString)) { return SimpleCodecInfo.fromString(codecString); }