diff --git a/src/DataStream.js b/src/DataStream.js index c367cbd7..11d592e9 100644 --- a/src/DataStream.js +++ b/src/DataStream.js @@ -20,6 +20,9 @@ var DataStream = function(arrayBuffer, byteOffset, endianness) { } this.position = 0; this.endianness = endianness == null ? DataStream.LITTLE_ENDIAN : endianness; + + // Behavior flags set by parsers + this.behavior = 0; }; DataStream.prototype = {}; diff --git a/src/box.js b/src/box.js index 9c65bb68..47acd063 100644 --- a/src/box.js +++ b/src/box.js @@ -7,6 +7,9 @@ var BoxParser = { ERR_NOT_ENOUGH_DATA : 0, OK : 1, + // Behavior set when parsing a QTFF file for when same Box/Atom is used differently + BEHAVIOR_QTFF: 0x01, + // Boxes to be created with default parsing BASIC_BOXES: [ "mdat", "idat", "free", "skip", "meco", "strk" ], FULL_BOXES: [ "hmhd", "nmhd", "iods", "xml ", "bxml", "ipro", "mere" ], diff --git a/src/parsing/data.js b/src/parsing/data.js new file mode 100644 index 00000000..869a6c44 --- /dev/null +++ b/src/parsing/data.js @@ -0,0 +1,99 @@ +/* + * itif data types + * https://developer.apple.com/documentation/quicktime-file-format/well-known_types + */ +var ItifTypes = { + RESERVED: 0, + UTF8: 1, + UTF16: 2, + SJIS: 3, + UTF8_SORT: 4, + UTF16_SORT: 5, + JPEG: 13, + PNG: 14, + BE_SIGNED_INT: 21, + BE_UNSIGNED_INT: 22, + BE_FLOAT32: 23, + BE_FLOAT64: 24, + BMP: 27, + QT_ATOM: 28, + BE_SIGNED_INT8: 65, + BE_SIGNED_INT16: 66, + BE_SIGNED_INT32: 67, + BE_FLOAT32_POINT: 70, + BE_FLOAT32_DIMENSIONS: 71, + BE_FLOAT32_RECT: 72, + BE_SIGNED_INT64: 74, + BE_UNSIGNED_INT8: 75, + BE_UNSIGNED_INT16: 76, + BE_UNSIGNED_INT32: 77, + BE_UNSIGNED_INT64: 78, + BE_FLOAT64_AFFINE_TRANSFORM: 79, +}; + +/* + * Parses the types above. Only implement the ones we actually + * have real world test data for. Add a test case, as you implement them. + */ +function parseItifData(type, data) { + if (type == ItifTypes.UTF8) { + return new TextDecoder("utf-8").decode(data); + } + + var view = new DataView(data.buffer); + if (type == ItifTypes.BE_UNSIGNED_INT) { + if (data.length == 1) { + return view.getUint8(0); + } else if (data.length == 2) { + return view.getUint16(0, false); + } else if (data.length == 4) { + return view.getUint32(0, false); + } else if (data.length == 8) { + return view.getBigUint64(0, false); + } else { + throw new Error("Unsupported ITIF_TYPE_BE_UNSIGNED_INT length " + data.length); + } + } else if (type == ItifTypes.BE_SIGNED_INT) { + if (data.length == 1) { + return view.getInt8(0); + } else if (data.length == 2) { + return view.getInt16(0, false); + } else if (data.length == 4) { + return view.getInt32(0, false); + } else if (data.length == 8) { + return view.getBigInt64(0, false); + } else { + throw new Error("Unsupported ITIF_TYPE_BE_SIGNED_INT length " + data.length); + } + } else if (type == ItifTypes.BE_FLOAT32) { + return view.getFloat32(0, false); + } + + Log.warn("BoxParser", "Unsupported or unimplemented itif data type: " + type); + return undefined; +} + +/* + * The QTFF data Atom is typically in an ilst Box. + * https://developer.apple.com/documentation/quicktime-file-format/data_atom + */ +BoxParser.createBoxCtor("data", function(stream) { + this.type = stream.readUint32(); + this.country = stream.readUint16(); + if (this.country > 255) { + stream.position -= 2; + this.countryString = stream.readString(2); + } + this.language = stream.readUint16(); + if (this.language > 255) { + stream.position -= 2; + this.parseLanguage(stream); + } + this.raw = stream.readUint8Array(this.size - this.hdr_size - 8); + this.value = parseItifData(this.type, this.raw); +}); + +if (typeof exports !== 'undefined') { + exports.ItifTypes = ItifTypes; +} + diff --git a/src/parsing/ftyp.js b/src/parsing/ftyp.js index 5864d62b..d9edd3b3 100644 --- a/src/parsing/ftyp.js +++ b/src/parsing/ftyp.js @@ -10,5 +10,9 @@ BoxParser.createBoxCtor("ftyp", function(stream) { toparse -= 4; i++; } + + // Certain Boxes/Atoms have different behavior when parsing QTFF files + if (this.major_brand.indexOf("qt") == 0) + stream.behavior |= BoxParser.BEHAVIOR_QTFF; }); diff --git a/src/parsing/ilst.js b/src/parsing/ilst.js new file mode 100644 index 00000000..c0ae76a9 --- /dev/null +++ b/src/parsing/ilst.js @@ -0,0 +1,18 @@ +/* + * The QTFF ilst Box typically follows a keys Box within a meta Box. + * https://developer.apple.com/documentation/quicktime-file-format/metadata_item_list_atom + */ +BoxParser.createBoxCtor("ilst", function(stream) { + var total = this.size - this.hdr_size; + this.boxes = { }; + while (total > 0) { + var size = stream.readUint32(); + + /* The index into the keys box */ + var index = stream.readUint32(); + var res = BoxParser.parseOneBox(stream, false, size - 8); + if (res.code == BoxParser.OK) + this.boxes[index] = res.box; + total -= size; + } +}); diff --git a/src/parsing/keys.js b/src/parsing/keys.js new file mode 100644 index 00000000..aa406b7f --- /dev/null +++ b/src/parsing/keys.js @@ -0,0 +1,14 @@ +/* + * The QTFF keys Atom is typically in a meta Box. + * https://developer.apple.com/documentation/quicktime-file-format/metadata_item_keys_atom + * key indexes are 1-based and so we store them in a Object, not an array.. + */ +BoxParser.createFullBoxCtor("keys", function(stream) { + this.count = stream.readUint32(); + this.keys = {}; + for (var i = 0; i < this.count; i++) { + var len = stream.readUint32(); + this.keys[i + 1] = stream.readString(len - 4); + } +}); + diff --git a/src/parsing/meta.js b/src/parsing/meta.js index d1bc76b6..a5f9623e 100644 --- a/src/parsing/meta.js +++ b/src/parsing/meta.js @@ -1,4 +1,9 @@ -BoxParser.createFullBoxCtor("meta", function(stream) { +// meta is a FullBox in MPEG-4 and a ContainerBox in QTFF +BoxParser.createContainerBoxCtor("meta", function(stream) { this.boxes = []; + + // The QTFF "meta" box does not have a flags/version header + if (!(stream.behavior & BoxParser.BEHAVIOR_QTFF)) + BoxParser.FullBox.prototype.parseFullHeader.call(this, stream); BoxParser.ContainerBox.prototype.parse.call(this, stream); }); diff --git a/test/qunit-qtff.js b/test/qunit-qtff.js new file mode 100644 index 00000000..92ae8730 --- /dev/null +++ b/test/qunit-qtff.js @@ -0,0 +1,102 @@ +// QTFF .MOV file format tests +QUnit.module("QTFF"); + +// Creates new copy of object with only the property keys also in template +function prunedObject(object, template) { + var pruned = { }; + for (var key in template) { + if (key in object) + pruned[key] = object[key]; + } + return pruned; +} + +QUnit.asyncTest( "QTFF meta", function(assert) { + var timeout = window.setTimeout(function() { assert.ok(false, "Timeout"); QUnit.start(); }, TIMEOUT_MS); + var mp4boxfile = MP4Box.createFile(); + mp4boxfile.onReady = function(info) { + window.clearTimeout(timeout); + assert.ok(true, "moov found!" ); + + var metas = mp4boxfile.getBoxes("meta"); + assert.ok(metas, "metas is not null"); + assert.strictEqual(metas.length, 2, "two metas fonud"); + assert.strictEqual(metas[0].type, "meta", "Correct meta box"); + assert.strictEqual(metas[1].type, "meta", "Correct meta box"); + + /* A few fields in the first meta */ + assert.strictEqual(metas[0].boxes[0].type, "hdlr", "Correct hdlr box"); + assert.strictEqual(metas[0].boxes[1].type, "keys", "Correct keys box"); + assert.strictEqual(metas[0].boxes[2].type, "ilst", "Correct ilst box"); + + assert.strictEqual(metas[0].boxes[1].count, 2); + [ + "mdtacom.apple.quicktime.camera.lens_model", + "mdtacom.apple.quicktime.camera.focal_length.35mm_equivalent" + ].map(function(val, i) { + assert.strictEqual(metas[0].boxes[1].keys[i + 1], val, "key correct: " + (i + 1)); + }); + + assert.strictEqual(Object.keys(metas[0].boxes[2].boxes).length, 2); + [ + { + type: ItifTypes.UTF8, value: "iPhone 14 Pro back camera 9mm f/2.8", + country: 17477, countryString: "DE", language: 5575, languageString: "eng", + }, + { + type: ItifTypes.BE_SIGNED_INT, value: 75, + country: 17477, countryString: "DE", language: 5575, languageString: "eng", + } + ].map(function(val, i) { + var box = prunedObject(metas[0].boxes[2].boxes[i + 1], val); + assert.deepEqual(box, val, "ilst values correct:" + (i + 1)); + }); + + /* This file has a second meta with lots more data */ + assert.strictEqual(metas[1].boxes[0].type, "hdlr", "Correct hdlr box"); + assert.strictEqual(metas[1].boxes[1].type, "keys", "Correct keys box"); + assert.strictEqual(metas[1].boxes[2].type, "ilst", "Correct ilst box"); + + assert.strictEqual(metas[1].boxes[1].count, 11, "Correct number of keys"); + [ + "mdtacom.apple.quicktime.location.accuracy.horizontal", + "mdtacom.apple.quicktime.live-photo.auto", + "mdtacom.apple.quicktime.full-frame-rate-playback-intent", + "mdtacom.apple.quicktime.live-photo.vitality-score", + "mdtacom.apple.quicktime.live-photo.vitality-scoring-version", + "mdtacom.apple.quicktime.location.ISO6709", + "mdtacom.apple.quicktime.make", + "mdtacom.apple.quicktime.model", + "mdtacom.apple.quicktime.software", + "mdtacom.apple.quicktime.creationdate", + "mdtacom.apple.quicktime.content.identifier", + ].map(function(val, i) { + assert.strictEqual(metas[1].boxes[1].keys[i + 1], val, "key correct: " + (i + 1)); + }); + + assert.strictEqual(Object.keys(metas[1].boxes[2].boxes).length, 11); + [ + { type: ItifTypes.UTF8, country: 0, language: 0, value: "7.176223" }, + { type: ItifTypes.BE_UNSIGNED_INT, country: 0, language: 0, value: 1 }, + { type: ItifTypes.BE_SIGNED_INT, country: 0, language: 0, value: 1n }, + { type: ItifTypes.BE_FLOAT32, country: 0, language: 0, value: 0.9398496150970459 }, + { type: ItifTypes.BE_SIGNED_INT, country: 0, language: 0, value: 4n }, + { type: ItifTypes.UTF8, country: 0, language: 0, value: "+32.0532+076.7050+2083.109/" }, + { type: ItifTypes.UTF8, country: 0, language: 0, value: "Apple" }, + { type: ItifTypes.UTF8, country: 0, language: 0, value: "iPhone 14 Pro" }, + { type: ItifTypes.UTF8, country: 0, language: 0, value: "18.0" }, + { type: ItifTypes.UTF8, country: 0, language: 0, value: "2024-10-08T14:52:09+0530" }, + { type: ItifTypes.UTF8, country: 0, language: 0, value: "C83404C7-130C-474E-B5E7-9865353C2DE7" }, + ].map(function(val, i) { + var box = prunedObject(metas[1].boxes[2].boxes[i + 1], val); + assert.deepEqual(box, val, "ilst values correct: " + (i + 1)); + }); + } + + var url = mediaTestBaseUrl + 'mov/iphone.mov'; + getFile(url, function (buffer) { + mp4boxfile.appendBuffer(buffer); + QUnit.start(); + }); +}); + diff --git a/test/qunit.html b/test/qunit.html index 084662b9..cadbeb1e 100644 --- a/test/qunit.html +++ b/test/qunit.html @@ -12,6 +12,7 @@ +