Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add VHS codec parsing and translation functions #5

Merged
merged 1 commit into from
Dec 5, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
);
});