From 3619ba99e781e4f9e89dc9ca3139b722e40082c3 Mon Sep 17 00:00:00 2001 From: Garrett Singer Date: Thu, 21 Nov 2019 16:30:12 -0500 Subject: [PATCH] feat: add VHS codec parsing and translation functions --- src/codecs.js | 130 ++++++++++++++++++++++++ test/codecs.test.js | 242 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 src/codecs.js create mode 100644 test/codecs.test.js diff --git a/src/codecs.js b/src/codecs.js new file mode 100644 index 0000000..0fbdcca --- /dev/null +++ b/src/codecs.js @@ -0,0 +1,130 @@ +/** + * Replace the old apple-style `avc1.
.
` codec string with the standard + * `avc1.` + * + * @param {string} codec + * Codec string to translate + * @return {string} + * The translated codec string + */ +export const translateLegacyCodec = function(codec) { + if (!codec) { + return codec; + } + + return codec.replace(/avc1\.(\d+)\.(\d+)/i, function(orig, profile, avcLevel) { + const profileHex = ('00' + Number(profile).toString(16)).slice(-2); + const avcLevelHex = ('00' + Number(avcLevel).toString(16)).slice(-2); + + return 'avc1.' + profileHex + '00' + avcLevelHex; + }); +}; + +/** + * Replace the old apple-style `avc1.
.
` codec strings with the standard + * `avc1.` + * + * @param {string[]} codecs + * An array of codec strings to translate + * @return {string[]} + * The translated array of codec strings + */ +export const translateLegacyCodecs = function(codecs) { + return codecs.map(translateLegacyCodec); +}; + +/** + * Replace codecs in the codec string with the old apple-style `avc1.
.
` to the + * standard `avc1.`. + * + * @param {string} codecString + * The codec string + * @return {string} + * The codec string with old apple-style codecs replaced + * + * @private + */ +export const mapLegacyAvcCodecs = function(codecString) { + return codecString.replace(/avc1\.(\d+)\.(\d+)/i, (match) => { + return translateLegacyCodecs([match])[0]; + }); +}; + +/** + * @typedef {Object} ParsedCodecInfo + * @property {number} codecCount + * Number of codecs parsed + * @property {string} [videoCodec] + * Parsed video codec (if found) + * @property {string} [videoObjectTypeIndicator] + * Video object type indicator (if found) + * @property {string|null} audioProfile + * Audio profile + */ + +/** + * Parses a codec string to retrieve the number of codecs specified, the video codec and + * object type indicator, and the audio profile. + * + * @param {string} [codecs] + * The codec string to parse + * @return {ParsedCodecInfo} + * Parsed codec info + */ +export const parseCodecs = function(codecs = '') { + const result = { + codecCount: 0 + }; + + result.codecCount = codecs.split(',').length; + result.codecCount = result.codecCount || 2; + + // parse the video codec + const parsed = (/(^|\s|,)+(avc[13])([^ ,]*)/i).exec(codecs); + + if (parsed) { + result.videoCodec = parsed[2]; + result.videoObjectTypeIndicator = parsed[3]; + } + + // parse the last field of the audio codec + result.audioProfile = + (/(^|\s|,)+mp4a.[0-9A-Fa-f]+\.([0-9A-Fa-f]+)/i).exec(codecs); + result.audioProfile = result.audioProfile && result.audioProfile[2]; + + return result; +}; + +/** + * Returns a ParsedCodecInfo object for the default alternate audio playlist if there is + * a default alternate audio playlist for the provided audio group. + * + * @param {Object} master + * The master playlist + * @param {string} audioGroupId + * ID of the audio group for which to find the default codec info + * @return {ParsedCodecInfo} + * Parsed codec info + */ +export const audioProfileFromDefault = (master, audioGroupId) => { + if (!master.mediaGroups.AUDIO || !audioGroupId) { + return null; + } + + const audioGroup = master.mediaGroups.AUDIO[audioGroupId]; + + if (!audioGroup) { + return null; + } + + for (const name in audioGroup) { + const audioType = audioGroup[name]; + + if (audioType.default && audioType.playlists) { + // codec should be the same for all playlists within the audio type + return parseCodecs(audioType.playlists[0].attributes.CODECS).audioProfile; + } + } + + return null; +}; diff --git a/test/codecs.test.js b/test/codecs.test.js new file mode 100644 index 0000000..b651332 --- /dev/null +++ b/test/codecs.test.js @@ -0,0 +1,242 @@ +import QUnit from 'qunit'; +import { + mapLegacyAvcCodecs, + translateLegacyCodecs, + parseCodecs, + audioProfileFromDefault +} from '../src/codecs'; + +QUnit.module('Legacy Codecs'); + +QUnit.test('maps legacy AVC codecs', function(assert) { + assert.equal( + mapLegacyAvcCodecs('avc1.deadbeef'), + 'avc1.deadbeef', + 'does nothing for non legacy pattern' + ); + assert.equal( + mapLegacyAvcCodecs('avc1.dead.beef, mp4a.something'), + 'avc1.dead.beef, mp4a.something', + 'does nothing for non legacy pattern' + ); + assert.equal( + mapLegacyAvcCodecs('avc1.dead.beef,mp4a.something'), + 'avc1.dead.beef,mp4a.something', + 'does nothing for non legacy pattern' + ); + assert.equal( + mapLegacyAvcCodecs('mp4a.something,avc1.dead.beef'), + 'mp4a.something,avc1.dead.beef', + 'does nothing for non legacy pattern' + ); + assert.equal( + mapLegacyAvcCodecs('mp4a.something, avc1.dead.beef'), + 'mp4a.something, avc1.dead.beef', + 'does nothing for non legacy pattern' + ); + assert.equal( + mapLegacyAvcCodecs('avc1.42001e'), + 'avc1.42001e', + 'does nothing for non legacy pattern' + ); + assert.equal( + mapLegacyAvcCodecs('avc1.4d0020,mp4a.40.2'), + 'avc1.4d0020,mp4a.40.2', + 'does nothing for non legacy pattern' + ); + assert.equal( + mapLegacyAvcCodecs('mp4a.40.2,avc1.4d0020'), + 'mp4a.40.2,avc1.4d0020', + 'does nothing for non legacy pattern' + ); + assert.equal( + mapLegacyAvcCodecs('mp4a.40.40'), + 'mp4a.40.40', + 'does nothing for non video codecs' + ); + + assert.equal( + mapLegacyAvcCodecs('avc1.66.30'), + 'avc1.42001e', + 'translates legacy video codec alone' + ); + assert.equal( + mapLegacyAvcCodecs('avc1.66.30, mp4a.40.2'), + 'avc1.42001e, mp4a.40.2', + 'translates legacy video codec when paired with audio' + ); + assert.equal( + mapLegacyAvcCodecs('mp4a.40.2, avc1.66.30'), + 'mp4a.40.2, avc1.42001e', + 'translates video codec when specified second' + ); +}); + +QUnit.test('translates legacy codecs', function(assert) { + assert.deepEqual( + translateLegacyCodecs(['avc1.66.30', 'avc1.66.30']), + ['avc1.42001e', 'avc1.42001e'], + 'translates legacy avc1.66.30 codec' + ); + + assert.deepEqual( + translateLegacyCodecs(['avc1.42C01E', 'avc1.42C01E']), + ['avc1.42C01E', 'avc1.42C01E'], + 'does not translate modern codecs' + ); + + assert.deepEqual( + translateLegacyCodecs(['avc1.42C01E', 'avc1.66.30']), + ['avc1.42C01E', 'avc1.42001e'], + 'only translates legacy codecs when mixed' + ); + + assert.deepEqual( + translateLegacyCodecs(['avc1.4d0020', 'avc1.100.41', 'avc1.77.41', + 'avc1.77.32', 'avc1.77.31', 'avc1.77.30', + 'avc1.66.30', 'avc1.66.21', 'avc1.42C01e']), + ['avc1.4d0020', 'avc1.640029', 'avc1.4d0029', + 'avc1.4d0020', 'avc1.4d001f', 'avc1.4d001e', + 'avc1.42001e', 'avc1.420015', 'avc1.42C01e'], + 'translates a whole bunch' + ); +}); + +QUnit.module('parseCodecs'); + +QUnit.test('parses video only codec string', function(assert) { + assert.deepEqual( + parseCodecs('avc1.42001e'), + { + codecCount: 1, + videoCodec: 'avc1', + videoObjectTypeIndicator: '.42001e', + audioProfile: null + }, + 'parsed video only codec string' + ); +}); + +QUnit.test('parses audio only codec string', function(assert) { + assert.deepEqual( + parseCodecs('mp4a.40.2'), + { + codecCount: 1, + audioProfile: '2' + }, + 'parsed audio only codec string' + ); +}); + +QUnit.test('parses video and audio codec string', function(assert) { + assert.deepEqual( + parseCodecs('avc1.42001e, mp4a.40.2'), + { + codecCount: 2, + videoCodec: 'avc1', + videoObjectTypeIndicator: '.42001e', + audioProfile: '2' + }, + 'parsed video and audio codec string' + ); +}); + +QUnit.module('audioProfileFromDefault'); + +QUnit.test('returns falsey when no audio group ID', function(assert) { + assert.notOk( + audioProfileFromDefault( + { mediaGroups: { AUDIO: {} } }, + '', + ), + 'returns falsey when no audio group ID' + ); +}); + +QUnit.test('returns falsey when no matching audio group', function(assert) { + assert.notOk( + audioProfileFromDefault( + { + mediaGroups: { + AUDIO: { + au1: { + en: { + default: false, + playlists: [{ + attributes: { CODECS: 'mp4a.40.2' } + }] + }, + es: { + default: true, + playlists: [{ + attributes: { CODECS: 'mp4a.40.5' } + }] + } + } + } + } + }, + 'au2' + ), + 'returned falsey when no matching audio group' + ); +}); + +QUnit.test('returns falsey when no default for audio group', function(assert) { + assert.notOk( + audioProfileFromDefault( + { + mediaGroups: { + AUDIO: { + au1: { + en: { + default: false, + playlists: [{ + attributes: { CODECS: 'mp4a.40.2' } + }] + }, + es: { + default: false, + playlists: [{ + attributes: { CODECS: 'mp4a.40.5' } + }] + } + } + } + } + }, + 'au1' + ), + 'returned falsey when no default for audio group' + ); +}); + +QUnit.test('returns audio profile for default in audio group', function(assert) { + assert.deepEqual( + audioProfileFromDefault( + { + mediaGroups: { + AUDIO: { + au1: { + en: { + default: false, + playlists: [{ + attributes: { CODECS: 'mp4a.40.2' } + }] + }, + es: { + default: true, + playlists: [{ + attributes: { CODECS: 'mp4a.40.5' } + }] + } + } + } + } + }, + 'au1' + ), + '5', + 'returned parsed codec audio profile' + ); +});