Skip to content

Commit

Permalink
feat: add VHS codec parsing and translation functions (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
gesinger authored Dec 5, 2019
1 parent d3ebd3f commit 4fe0e22
Show file tree
Hide file tree
Showing 2 changed files with 372 additions and 0 deletions.
130 changes: 130 additions & 0 deletions src/codecs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Replace the old apple-style `avc1.<dd>.<dd>` codec string with the standard
* `avc1.<hhhhhh>`
*
* @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.<dd>.<dd>` codec strings with the standard
* `avc1.<hhhhhh>`
*
* @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.<dd>.<dd>` to the
* standard `avc1.<hhhhhh>`.
*
* @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;
};
242 changes: 242 additions & 0 deletions test/codecs.test.js
Original file line number Diff line number Diff line change
@@ -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'
);
});

0 comments on commit 4fe0e22

Please sign in to comment.