Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
421 lines
14 KiB
JavaScript
421 lines
14 KiB
JavaScript
/* exported MP4Metadata */
|
|
'use strict';
|
|
|
|
/**
|
|
* Parse files with MP4 metadata.
|
|
*
|
|
* Format information:
|
|
* https://developer.apple.com/library/mac/#documentation/QuickTime/QTFF/
|
|
* QTFFChap1/qtff1.html
|
|
* http://en.wikipedia.org/wiki/MPEG-4_Part_14
|
|
* http://atomicparsley.sourceforge.net/mpeg-4files.html
|
|
*/
|
|
var MP4Metadata = (function() {
|
|
// Map MP4 atom names to metadata property names
|
|
var MP4ATOMS = {
|
|
'\xa9alb' : 'album',
|
|
'\xa9art' : 'artist',
|
|
'\xa9ART' : 'artist',
|
|
'aART' : 'artist',
|
|
'\xa9nam' : 'title',
|
|
'trkn' : 'tracknum',
|
|
'disk' : 'discnum',
|
|
'covr' : 'picture'
|
|
};
|
|
|
|
// These MP4 atoms are stored as pairs of integers, so they get mapped to
|
|
// two metadata fields
|
|
var MP4INTPAIRATOMS = {
|
|
'trkn': 'trackcount',
|
|
'disk': 'disccount'
|
|
};
|
|
|
|
// These are 'ftyp' values that we recognize
|
|
// See http://www.mp4ra.org/filetype.html
|
|
// Also see gecko code in /toolkit/components/mediasniffer/nsMediaSniffer.cpp
|
|
// Gaia will accept the supported compatible brands in gecko as well
|
|
var MP4Types = {
|
|
'M4A ' : true, // iTunes audio. Note space in property name.
|
|
'M4B ' : true, // iTunes audio book. Note space.
|
|
'mp41' : true, // MP4 version 1
|
|
'mp42' : true, // MP4 version 2
|
|
'isom' : true, // ISO base media file format, version 1
|
|
'iso2' : true // ISO base media file format, version 2
|
|
};
|
|
|
|
// MP4 and 3GP containers both use ISO base media file format.
|
|
// Also see what audio codecs/formats are supported in 3GPP specification.
|
|
// Format information:
|
|
// https://en.wikipedia.org/wiki/ISO_base_media_file_format
|
|
// http://tools.ietf.org/html/rfc6381
|
|
// http://www.3gpp.org/ftp/Specs/html-info/26244.htm
|
|
//
|
|
var MP4Codecs = {
|
|
'mp4a' : true, // MPEG-4 audio
|
|
'samr' : true, // AMR narrow-band speech
|
|
'sawb' : true, // AMR wide-band speech
|
|
'sawp' : true // Extended AMR wide-band audio
|
|
};
|
|
|
|
/**
|
|
* Parse a file and return a Promise with the metadata.
|
|
*
|
|
* @param {BlobView} blobview The audio file to parse.
|
|
* @return {Promise} A Promise returning the parsed metadata object.
|
|
*/
|
|
function parse(blobview) {
|
|
// The MP4 file might be a video or it might be some kind of audio that we
|
|
// don't support.
|
|
if (!checkMP4Type(blobview, MP4Types)) {
|
|
return Promise.reject(new Error('Unknown MP4 file type'));
|
|
}
|
|
|
|
return findMoovAtom(blobview).then((result) => {
|
|
var metadata = {tag_format: 'mp4'};
|
|
if (!result) {
|
|
return metadata;
|
|
}
|
|
|
|
return parseMoovAtom(result.atom, result.size, metadata);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check the type of MP4 file to ensure we should play it. (MP4 files use
|
|
* 'ftyp' to identify the type of encoding. For more information, see:
|
|
* <http://www.ftyps.com/what.html>.)
|
|
*
|
|
* @param {BlobView} blobview The audio file to parse.
|
|
* @param {Object} types A list of types we support.
|
|
* @return {Boolean} True if the file should be parsed, false otherwise.
|
|
*/
|
|
function checkMP4Type(blobview, types) {
|
|
// The major brand is the four bytes right after 'ftyp'.
|
|
var majorbrand = blobview.getBinaryText(8, 4);
|
|
|
|
if (majorbrand in types) {
|
|
return true;
|
|
} else {
|
|
// Check the rest for the compatible brands. They are every four bytes
|
|
// after the version of major brand. Usually there are two optional
|
|
// compatible brands, but an arbitrary number of other compatible brands
|
|
// are also acceptable, so we will check all the compatible brands until
|
|
// the header ends.
|
|
var index = 16;
|
|
var size = blobview.getUint32(0);
|
|
|
|
while (index < size) {
|
|
var compatiblebrand = blobview.getBinaryText(index, 4);
|
|
index += 4;
|
|
if (compatiblebrand in types) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the "moov" atom in the MP4 container.
|
|
*
|
|
* @param {BlobView} atom The file we're parsing.
|
|
* @return {Promise} A promise that resolves when we've found the move atom.
|
|
*/
|
|
function findMoovAtom(atom) {
|
|
// XXX: I think I could probably restructure this somehow. The atoms or
|
|
// "boxes" we're reading and parsing here for a tree that I need to
|
|
// traverse. Maybe nextBox() and firstChildBox() functions would be
|
|
// helpful. Or even make these methods of BlobView? Not sure if it is worth
|
|
// the time to refactor, though... See also the approach in
|
|
// shared/js/get_video_rotation.js
|
|
return new Promise(function(resolve, reject) {
|
|
var offset = atom.sliceOffset + atom.viewOffset; // position in blob
|
|
var size = atom.readUnsignedInt();
|
|
var type = atom.readBinaryText(4);
|
|
|
|
if (size === 0) {
|
|
// A size of 0 means the rest of the file
|
|
size = atom.blob.size - offset;
|
|
} else if (size === 1) {
|
|
// A size of 1 means the size is in bytes 8-15
|
|
size = atom.readUnsignedInt() * 4294967296 + atom.readUnsignedInt();
|
|
}
|
|
|
|
if (type === 'moov') {
|
|
// Get the full contents of this atom
|
|
atom.getMore(offset, size, function(moov) {
|
|
resolve({atom: moov, size: size});
|
|
});
|
|
} else {
|
|
// Otherwise, get the start of the next atom and recurse
|
|
// to continue the search for the moov atom.
|
|
// If we're reached the end of the blob without finding
|
|
// anything, just call the metadata callback with no metadata
|
|
if (offset + size + 16 <= atom.blob.size) {
|
|
atom.getMore(offset + size, 16, function(moov) {
|
|
try {
|
|
findMoovAtom(moov).then(function(result) {
|
|
resolve(result);
|
|
});
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
} else {
|
|
resolve(null);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parse the moov atom. This function, and the ones that follow are all
|
|
* synchronous. We've read the entire moov atom, so we've got all the bytes
|
|
* we need and don't have to do an async read again.
|
|
*
|
|
* @param {BlobView} data The data for the moov atom.
|
|
* @param {Number} end The offset at which the moov atom ends.
|
|
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
|
* @return {Metadata} The completed metadata object.
|
|
*/
|
|
function parseMoovAtom(data, end, metadata) {
|
|
data.advance(8); // skip the size and type of this atom
|
|
|
|
// Find the udta and trak atoms within the moov atom
|
|
// There will only be one udta atom, but there may be multiple trak
|
|
// atoms. In that case, this is probably a movie file and we'll reject
|
|
// it when we find a track that is not an mp4 audio codec.
|
|
while (data.index < end) {
|
|
var size = data.readUnsignedInt();
|
|
var type = data.readBinaryText(4);
|
|
var nextindex = data.index + size - 8;
|
|
if (type === 'udta') { // Metadata is inside here
|
|
parseUdtaAtom(data, end, metadata);
|
|
} else if (type === 'trak') { // We find the audio format inside here
|
|
data.advance(-8); // skip back to beginning
|
|
if (seekChildAtoms(data, ['mdia', 'minf'])) {
|
|
if (searchChildAtom(data, 'vmhd')) {
|
|
throw Error('Found video track in MP4 container');
|
|
}
|
|
if (searchChildAtom(data, 'smhd')) {
|
|
if (seekChildAtoms(data, ['stbl', 'stsd'])) {
|
|
data.advance(20);
|
|
var codec = data.readBinaryText(4);
|
|
if (!(codec in MP4Codecs)) {
|
|
throw Error('Unsupported format in MP4 container: ' + codec);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// There is not enough information for us to identify the MP4
|
|
throw Error('Not enough metadata in MP4 container!');
|
|
}
|
|
}
|
|
data.seek(nextindex);
|
|
}
|
|
return metadata;
|
|
}
|
|
|
|
/**
|
|
* Find the specified hierarchy of atoms and stop at the last one's file
|
|
* offset.
|
|
*
|
|
* @param {BlobView} data The data for the moov atom.
|
|
* @param {Array} atoms The names of the atoms to find.
|
|
* @return {Boolean} True if we found the atoms, false otherwise.
|
|
*/
|
|
function seekChildAtoms(data, atoms) {
|
|
return atoms.every(function(atom) {
|
|
return seekChildAtom(data, atom);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find a child atom and stop at its file offset.
|
|
*
|
|
* @param {BlobView} data The data for the moov atom.
|
|
* @param {String} atom The name of the atom to find.
|
|
* @return {Boolean} True if we found the atom, false otherwise.
|
|
*/
|
|
function seekChildAtom(data, atom) {
|
|
var start = data.index;
|
|
var length = data.readUnsignedInt();
|
|
data.advance(4);
|
|
|
|
while (data.index < start + length) {
|
|
var size = data.readUnsignedInt();
|
|
var type = data.readBinaryText(4);
|
|
if (type === atom) {
|
|
data.advance(-8);
|
|
return true;
|
|
}
|
|
else {
|
|
data.advance(size - 8);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Find a child atom, but reset the file offset to the start after completion.
|
|
*
|
|
* @param {BlobView} data The data for the moov atom.
|
|
* @param {String} atom The name of the atom to find.
|
|
* @return {Boolean} True if we found the atom, false otherwise.
|
|
*/
|
|
function searchChildAtom(data, atom) {
|
|
var start = data.index;
|
|
var result = seekChildAtom(data, atom);
|
|
data.seek(start);
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Parse the udta atom.
|
|
*
|
|
* @param {BlobView} data The data for the moov atom.
|
|
* @param {Number} end The offset at which the moov atom ends.
|
|
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
|
*/
|
|
function parseUdtaAtom(data, end, metadata) {
|
|
// Find the meta atom within the udta atom
|
|
while (data.index < end) {
|
|
var size = data.readUnsignedInt();
|
|
var type = data.readBinaryText(4);
|
|
if (type === 'meta') {
|
|
parseMetaAtom(data, data.index + size - 8, metadata);
|
|
data.seek(end);
|
|
return;
|
|
} else {
|
|
data.advance(size - 8);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse the meta atom.
|
|
*
|
|
* @param {BlobView} data The data for the moov atom.
|
|
* @param {Number} end The offset at which the moov atom ends.
|
|
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
|
*/
|
|
function parseMetaAtom(data, end, metadata) {
|
|
// The meta atom apparently has a slightly different structure.
|
|
// Have to skip flag bytes before reading children atoms
|
|
data.advance(4);
|
|
|
|
// Find the ilst atom within the meta atom
|
|
while (data.index < end) {
|
|
var size = data.readUnsignedInt();
|
|
var type = data.readBinaryText(4);
|
|
if (type === 'ilst') {
|
|
parseIlstAtom(data, data.index + size - 8, metadata);
|
|
data.seek(end);
|
|
return;
|
|
}
|
|
else {
|
|
data.advance(size - 8);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse the ilst atom, which (finally) contains all our metadata atom.
|
|
*
|
|
* @param {BlobView} data The data for the moov atom.
|
|
* @param {Number} end The offset at which the moov atom ends.
|
|
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
|
*/
|
|
function parseIlstAtom(data, end, metadata) {
|
|
// Now read all child atoms of ilst, looking for metadata
|
|
// we care about
|
|
while (data.index < end) {
|
|
var size = data.readUnsignedInt();
|
|
var type = data.readBinaryText(4);
|
|
var next = data.index + size - 8;
|
|
var propname = MP4ATOMS[type];
|
|
if (propname) {
|
|
try {
|
|
var value = getMetadataValue(data, next, type);
|
|
// Track number and disc number are of the form "x/y".
|
|
if (type in MP4INTPAIRATOMS) {
|
|
metadata[propname] = value.number;
|
|
if (value.count) {
|
|
metadata[MP4INTPAIRATOMS[type]] = value.count;
|
|
}
|
|
} else {
|
|
metadata[propname] = value;
|
|
}
|
|
} catch (e) {
|
|
console.warn('skipping', type, ':', e);
|
|
}
|
|
}
|
|
data.seek(next);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the value for a single metadata atom.
|
|
*
|
|
* @param {BlobView} data The data for the moov atom.
|
|
* @param {Number} end The offset at which the moov atom ends.
|
|
* @param {String} atomtype The name of this atom.
|
|
* @return {Object} The parsed value.
|
|
*/
|
|
function getMetadataValue(data, end, atomtype) {
|
|
// Loop until we find a data atom
|
|
while (data.index < end) {
|
|
var size = data.readUnsignedInt();
|
|
var type = data.readBinaryText(4);
|
|
if (type !== 'data') {
|
|
data.advance(size - 8);
|
|
continue;
|
|
}
|
|
|
|
// We've found the data atom.
|
|
// Return its (first) value or throw an error.
|
|
var datatype = data.readUnsignedInt() & 0xFFFFFF;
|
|
data.advance(4); // Ignore locale
|
|
|
|
var datasize = size - 16; // the rest of the atom is the value
|
|
|
|
// Special case for track number and disk number
|
|
// They have two values: number and count.
|
|
if (atomtype in MP4INTPAIRATOMS) {
|
|
data.advance(2);
|
|
var number = data.readUnsignedShort();
|
|
var count = data.readUnsignedShort();
|
|
return { number: number, count: count };
|
|
}
|
|
|
|
switch (datatype) {
|
|
case 1: // utf8 text
|
|
return data.readUTF8Text(datasize);
|
|
case 13: // jpeg
|
|
return {
|
|
flavor: 'embedded',
|
|
start: data.sliceOffset + data.viewOffset + data.index,
|
|
end: data.sliceOffset + data.viewOffset + data.index + datasize,
|
|
type: 'image/jpeg'
|
|
};
|
|
case 14: // png
|
|
return {
|
|
flavor: 'embedded',
|
|
start: data.sliceOffset + data.viewOffset + data.index,
|
|
end: data.sliceOffset + data.viewOffset + data.index + datasize,
|
|
type: 'image/png'
|
|
};
|
|
default:
|
|
throw Error('unexpected type in data atom');
|
|
}
|
|
}
|
|
throw Error('no data atom found');
|
|
}
|
|
|
|
return {
|
|
parse: parse
|
|
};
|
|
|
|
})();
|