From bb0f40394e7b24a7d0d0dc9b71362f03837dd912 Mon Sep 17 00:00:00 2001 From: Jeff Schiller Date: Thu, 19 Oct 2023 11:41:02 -0700 Subject: [PATCH] codecs: Properly detect Matroska audio/video. Add codec support for AV1 video and DTS audio. Bump to 1.1.4. --- codecs/codecs.js | 27 +++- io/bitbuffer.js | 2 +- io/bitstream.js | 2 +- io/bytestream.js | 3 +- package-lock.json | 4 +- package.json | 2 +- tests/codecs.spec.js | 310 +++++++++++++++++++++++++------------------ 7 files changed, 213 insertions(+), 137 deletions(-) diff --git a/codecs/codecs.js b/codecs/codecs.js index 9d978fa..82c967d 100644 --- a/codecs/codecs.js +++ b/codecs/codecs.js @@ -53,6 +53,13 @@ const FORMAT_NAME_TO_SHORT_TYPE = { 'wav': 'audio/wav', } +// https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers#webm says that only +// the following codecs are supported for webm: +// - video: AV1, VP8, VP9 +// - audio: Opus, Vorbis +const WEBM_AUDIO_CODECS = [ 'opus', 'vorbis' ]; +const WEBM_VIDEO_CODECS = [ 'av1', 'vp8', 'vp9' ]; + /** * TODO: Reconcile this with file/sniffer.js findMimeType() which does signature matching. * @param {ProbeInfo} info @@ -94,9 +101,16 @@ export function getShortMIMEString(info) { case 'ogg': subType = 'ogg'; break; - // Should we detect .mkv files as x-matroska? case 'matroska,webm': - subType = 'webm'; + let isWebM = true; + for (const stream of info.streams) { + if ( (stream.codec_type === 'audio' && !WEBM_AUDIO_CODECS.includes(stream.codec_name)) + || (stream.codec_type === 'video' && !WEBM_VIDEO_CODECS.includes(stream.codec_name))) { + isWebM = false; + break; + } + } + subType = isWebM ? 'webm' : 'x-matroska'; break; default: throw `Cannot handle format ${formatName} yet. ` + @@ -132,11 +146,12 @@ export function getFullMIMEString(info) { default: switch (stream.codec_name) { case 'aac': codecFrags.add(getMP4ACodecString(stream)); break; - case 'vorbis': codecFrags.add('vorbis'); break; - case 'opus': codecFrags.add('opus'); break; // I'm going off of what Chromium calls this one, with the dash. case 'ac3': codecFrags.add('ac-3'); break; + case 'dts': codecFrags.add('dts'); break; case 'flac': codecFrags.add('flac'); break; + case 'opus': codecFrags.add('opus'); break; + case 'vorbis': codecFrags.add('vorbis'); break; default: throw `Could not handle audio codec_name ${stream.codec_name}, ` + `codec_tag_string ${stream.codec_tag_string} for file ${info.format.filename} yet. ` + @@ -153,10 +168,12 @@ export function getFullMIMEString(info) { case 'png': continue; default: switch (stream.codec_name) { + case 'av1': codecFrags.add('av1'); break; case 'h264': codecFrags.add(getAVC1CodecString(stream)); break; - case 'mpeg2video': codecFrags.add('mpeg2video'); break; // Skip mjpeg as a video stream for the codecs string. case 'mjpeg': break; + case 'mpeg2video': codecFrags.add('mpeg2video'); break; + case 'vp8': codecFrags.add('vp8'); break; case 'vp9': codecFrags.add(getVP09CodecString(stream)); break; default: throw `Could not handle video codec_name ${stream.codec_name}, ` + diff --git a/io/bitbuffer.js b/io/bitbuffer.js index 0a2ac18..6ea5c15 100644 --- a/io/bitbuffer.js +++ b/io/bitbuffer.js @@ -32,7 +32,7 @@ export class BitBuffer { */ constructor(numBytes, mtl = false) { if (typeof numBytes != typeof 1 || numBytes <= 0) { - throw "Error! ByteBuffer initialized with '" + numBytes + "'"; + throw "Error! BitBuffer initialized with '" + numBytes + "'"; } /** diff --git a/io/bitstream.js b/io/bitstream.js index f925c1c..6b688ff 100644 --- a/io/bitstream.js +++ b/io/bitstream.js @@ -51,7 +51,7 @@ export class BitStream { */ constructor(ab, mtl, opt_offset, opt_length) { if (!(ab instanceof ArrayBuffer)) { - throw 'Error! BitArray constructed with an invalid ArrayBuffer object'; + throw 'Error! BitStream constructed with an invalid ArrayBuffer object'; } const offset = opt_offset || 0; diff --git a/io/bytestream.js b/io/bytestream.js index 8cc275d..a18d7b2 100644 --- a/io/bytestream.js +++ b/io/bytestream.js @@ -22,7 +22,8 @@ export class ByteStream { */ constructor(ab, opt_offset, opt_length) { if (!(ab instanceof ArrayBuffer)) { - throw 'Error! BitArray constructed with an invalid ArrayBuffer object'; + console.error(typeof ab); + throw 'Error! ByteStream constructed with an invalid ArrayBuffer object'; } const offset = opt_offset || 0; diff --git a/package-lock.json b/package-lock.json index cb8a985..256b9fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codedread/bitjs", - "version": "1.1.2", + "version": "1.1.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@codedread/bitjs", - "version": "1.1.2", + "version": "1.1.4", "license": "MIT", "devDependencies": { "c8": "^7.12.0", diff --git a/package.json b/package.json index e818f25..6000965 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codedread/bitjs", - "version": "1.1.3", + "version": "1.1.4", "description": "Binary Tools for JavaScript", "homepage": "https://github.com/codedread/bitjs", "author": "Jeff Schiller", diff --git a/tests/codecs.spec.js b/tests/codecs.spec.js index 6b783c2..0c04aab 100644 --- a/tests/codecs.spec.js +++ b/tests/codecs.spec.js @@ -113,16 +113,30 @@ describe('codecs test suite', () => { it('detects WEBM video', () => { expect(getShortMIMEString({ format: { format_name: 'matroska,webm' }, - streams: [ { codec_type: 'video' } ], + streams: [ { codec_type: 'video', codec_name: 'vp8' } ], })).equals('video/webm'); }); it('detects WEBM audio', () => { expect(getShortMIMEString({ format: { format_name: 'matroska,webm' }, - streams: [ { codec_type: 'audio' } ], + streams: [ { codec_type: 'audio', codec_name: 'vorbis' } ], })).equals('audio/webm'); }); + + it('detects Matroska Video', () => { + expect(getShortMIMEString({ + format: { format_name: 'matroska,webm' }, + streams: [ { codec_type: 'video', codec_name: 'h264' } ], + })).equals('video/x-matroska'); + }); + + it('detects Matroska audio', () => { + expect(getShortMIMEString({ + format: { format_name: 'matroska,webm' }, + streams: [ { codec_type: 'audio', codec_name: 'dts' } ], + })).equals('audio/x-matroska'); + }); }); describe('getFullMIMEString()', () => { @@ -228,86 +242,193 @@ describe('codecs test suite', () => { }); }); - describe('VP09 / VP9', () => { - /** @type {ProbeInfo} */ - let info; - - beforeEach(() => { - info = { - format: { format_name: 'matroska,webm' }, - streams: [{ - codec_type: 'video', - codec_tag_string: 'vp09', - }], - }; - }); - - describe('Profile tests', () => { + describe('WebM' ,() => { + describe('AV1', () => { + /** @type {ProbeInfo} */ + let info; + beforeEach(() => { - info.streams[0].level = 20; + info = { + format: { format_name: 'matroska,webm' }, + streams: [{ codec_type: 'video', codec_name: 'av1' }], + }; }); - it('detects Profile 0', () => { - info.streams[0].profile = 'Profile 0'; - expect(getFullMIMEString(info)) - .to.be.a('string') - .and.satisfy(s => s.startsWith('video/webm; codecs="vp09.00.')); + it('outputs MIME string', () => { + expect(getShortMIMEString(info)).equals('video/webm'); + expect(getFullMIMEString(info)).equals('video/webm; codecs="av1"') }); }); - describe('Level tests', () => { + describe('VP8', () => { + /** @type {ProbeInfo} */ + let info; + beforeEach(() => { - info.streams[0].profile = 'Profile 0'; + info = { + format: { format_name: 'matroska,webm' }, + streams: [{ codec_type: 'video', codec_name: 'vp8' }], + }; }); - it('detects 2-digit hex level', () => { + it('outputs MIME string', () => { + expect(getShortMIMEString(info)).equals('video/webm'); + expect(getFullMIMEString(info)).equals('video/webm; codecs="vp8"') + }); + }); + + describe('VP09 / VP9', () => { + /** @type {ProbeInfo} */ + let info; + + beforeEach(() => { + info = { + format: { format_name: 'matroska,webm' }, + streams: [{ codec_type: 'video', codec_name: 'vp9', codec_tag_string: 'vp09' }], + }; + }); + + describe('Profile tests', () => { + beforeEach(() => { + info.streams[0].level = 20; + }); + + it('detects Profile 0', () => { + info.streams[0].profile = 'Profile 0'; + expect(getFullMIMEString(info)) + .to.be.a('string') + .and.satisfy(s => s.startsWith('video/webm; codecs="vp09.00.')); + }); + }); + + describe('Level tests', () => { + beforeEach(() => { + info.streams[0].profile = 'Profile 0'; + }); + + it('detects 2-digit hex level', () => { + info.streams[0].level = 21; // 0x15 + expect(getFullMIMEString(info)) + .to.be.a('string') + .and.satisfy(s => s.startsWith('video/webm; codecs="vp09.')) + .and.satisfy(s => { + const matches = s.match(/vp09\.[0-9]{2}\.([0-9A-F]{2})\.[0-9A-F]{2}/); + return matches && matches.length === 2 && matches[1] === '15'; + }); + }); + + it('detects level = -99', () => { + info.streams[0].level = -99; // I'm not sure what ffprobe means by this. + expect(getFullMIMEString(info)) + .to.be.a('string') + .and.equals('video/webm; codecs="vp9"'); + }); + }); + + it('detects codec_name=vp9 but no codec_tag_string', () => { + info.streams[0].codec_name = 'vp9'; + info.streams[0].codec_tag_string = '[0][0][0][0]'; + info.streams[0].profile = 'Profile 0'; info.streams[0].level = 21; // 0x15 expect(getFullMIMEString(info)) - .to.be.a('string') - .and.satisfy(s => s.startsWith('video/webm; codecs="vp09.')) - .and.satisfy(s => { - const matches = s.match(/vp09\.[0-9]{2}\.([0-9A-F]{2})\.[0-9A-F]{2}/); - return matches && matches.length === 2 && matches[1] === '15'; - }); - }); - - it('detects level = -99', () => { - info.streams[0].level = -99; // I'm not sure what ffprobe means by this. - expect(getFullMIMEString(info)) - .to.be.a('string') - .and.equals('video/webm; codecs="vp9"'); + .to.be.a('string') + .and.satisfy(s => s.startsWith('video/webm; codecs="vp09.00.15')); }); }); - it('detects codec_name=vp9 but no codec_tag_string', () => { - info.streams[0].codec_name = 'vp9'; - info.streams[0].codec_tag_string = '[0][0][0][0]'; - info.streams[0].profile = 'Profile 0'; - info.streams[0].level = 21; // 0x15 - expect(getFullMIMEString(info)) - .to.be.a('string') - .and.satisfy(s => s.startsWith('video/webm; codecs="vp09.00.15')); + describe('Vorbis', () => { + /** @type {ProbeInfo} */ + let info; + + beforeEach(() => { + info = { + format: { format_name: 'matroska,webm' }, + streams: [{ codec_type: 'audio', codec_name: 'vorbis' }], + }; + }); + + it('detects vorbis', () => { + expect(getFullMIMEString(info)) + .to.be.a('string') + .and.equals('audio/webm; codecs="vorbis"'); + }); }); + + describe('Opus', () => { + /** @type {ProbeInfo} */ + let info; + + beforeEach(() => { + info = { + format: { format_name: 'matroska,webm' }, + streams: [{ + codec_type: 'audio', + codec_name: 'opus', + }], + }; + }); + + it('detects opus', () => { + expect(getFullMIMEString(info)) + .to.be.a('string') + .and.equals('audio/webm; codecs="opus"'); + }); + }); }); - describe('MPEG2', () => { - /** @type {ProbeInfo} */ - let info; - - beforeEach(() => { - info = { - format: { format_name: 'matroska,webm' }, - streams: [{ - codec_type: 'video', - codec_name: 'mpeg2video', - }], - }; + describe('Matroska', () => { + describe('MPEG2 codec', () => { + /** @type {ProbeInfo} */ + let info; + + beforeEach(() => { + info = { + format: { format_name: 'matroska,webm' }, + streams: [{ codec_type: 'video', codec_name: 'mpeg2video' }], + }; + }); + + it('outputs full MIME string', () => { + expect(getFullMIMEString(info)) + .to.be.a('string') + .and.equals('video/x-matroska; codecs="mpeg2video"'); + }); }); + + describe('AC-3', () => { + /** @type {ProbeInfo} */ + let info; + + beforeEach(() => { + info = { + format: { format_name: 'matroska,webm' }, + streams: [{ codec_type: 'audio', codec_name: 'ac3' }], + }; + }); + + it('detects AC-3', () => { + expect(getFullMIMEString(info)) + .to.be.a('string') + .and.equals('audio/x-matroska; codecs="ac-3"'); + }); + }); + + describe('DTS', () => { + /** @type {ProbeInfo} */ + let info; - it('detects mpeg2video', () => { - expect(getFullMIMEString(info)) - .to.be.a('string') - .and.equals('video/webm; codecs="mpeg2video"'); + beforeEach(() => { + info = { + format: { format_name: 'matroska,webm' }, + streams: [{ codec_type: 'audio', codec_name: 'dts' }], + }; + }); + + it('outputs full MIME string', () => { + expect(getFullMIMEString(info)) + .to.be.a('string') + .and.equals('audio/x-matroska; codecs="dts"'); + }); }); }); @@ -393,69 +514,6 @@ describe('codecs test suite', () => { }); }); - describe('Vorbis', () => { - /** @type {ProbeInfo} */ - let info; - - beforeEach(() => { - info = { - format: { format_name: 'matroska,webm' }, - streams: [{ - codec_type: 'audio', - codec_name: 'vorbis', - }], - }; - }); - - it('detects vorbis', () => { - expect(getFullMIMEString(info)) - .to.be.a('string') - .and.equals('audio/webm; codecs="vorbis"'); - }); - }); - - describe('Opus', () => { - /** @type {ProbeInfo} */ - let info; - - beforeEach(() => { - info = { - format: { format_name: 'matroska,webm' }, - streams: [{ - codec_type: 'audio', - codec_name: 'opus', - }], - }; - }); - - it('detects opus', () => { - expect(getFullMIMEString(info)) - .to.be.a('string') - .and.equals('audio/webm; codecs="opus"'); - }); - }); - - describe('AC-3', () => { - /** @type {ProbeInfo} */ - let info; - - beforeEach(() => { - info = { - format: { format_name: 'matroska,webm' }, - streams: [{ - codec_type: 'audio', - codec_name: 'ac3', - }], - }; - }); - - it('detects AC-3', () => { - expect(getFullMIMEString(info)) - .to.be.a('string') - .and.equals('audio/webm; codecs="ac-3"'); - }); - }); - describe('AVI', () => { it('detects AVI', () => { /** @type {ProbeInfo} */