Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
421 lines
14 KiB
JavaScript
Executable file
421 lines
14 KiB
JavaScript
Executable file
/* 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
|
|
};
|
|
|
|
})();
|