-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add VHS codec parsing and translation functions (#5)
- Loading branch information
Showing
2 changed files
with
372 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
); | ||
}); |