From 091c4aa8266d83b6ea7c1d04a1da20680be7abaf Mon Sep 17 00:00:00 2001 From: Miguel Angel Mulero Martinez Date: Wed, 29 Dec 2021 15:37:02 +0100 Subject: [PATCH] Fix double dialog export video --- gulpfile.js | 58 +- index.html | 6 +- .../webm-writer/ArrayBufferDataStream.js | 209 ++++ js/vendor/webm-writer/BlobBuffer.js | 230 +++++ js/vendor/webm-writer/Readme.md | 84 ++ js/vendor/webm-writer/WebMWriter.js | 961 ++++++++++++++++++ package.json | 3 +- yarn.lock | 5 - 8 files changed, 1491 insertions(+), 65 deletions(-) create mode 100644 js/vendor/webm-writer/ArrayBufferDataStream.js create mode 100644 js/vendor/webm-writer/BlobBuffer.js create mode 100644 js/vendor/webm-writer/Readme.md create mode 100644 js/vendor/webm-writer/WebMWriter.js diff --git a/gulpfile.js b/gulpfile.js index ef11fb3b..e4371a92 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -214,63 +214,11 @@ function clean_cache() { function dist() { var distSources = [ // CSS files - './css/header_dialog.css', - './css/jquery.nouislider.min.css', - './css/keys_dialog.css', - './css/main.css', - './css/user_settings_dialog.css', + './css/**/*', // JavaScript - './index.js', - './js/cache.js', - './js/complex.js', - './js/configuration.js', - './js/craft_2d.js', - './js/craft_3d.js', - './js/datastream.js', - './js/decoders.js', - './js/expo.js', - './js/flightlog.js', - './js/flightlog_fielddefs.js', - './js/flightlog_fields_presenter.js', - './js/flightlog_index.js', - './js/flightlog_parser.js', - './js/flightlog_video_renderer.js', - './js/graph_config.js', - './js/graph_config_dialog.js', - './js/graph_legend.js', - './js/workspace_selection.js', - './js/graph_spectrum.js', - './js/graph_spectrum_calc.js', - './js/graph_spectrum_plot.js', - './js/grapher.js', - './js/sticks.js', - './js/gui.js', - './js/header_dialog.js', - './js/imu.js', - './js/keys_dialog.js', - './js/laptimer.js', - './js/localization.js', - './js/main.js', - './js/pref_storage.js', - './js/real.js', - './js/release_checker.js', - './js/seekbar.js', - './js/tools.js', - './js/user_settings_dialog.js', - './js/video_export_dialog.js', - './js/csv-exporter.js', - './js/webworkers/csv-export-worker.js', - './js/vendor/FileSaver.js', - './js/vendor/jquery-1.11.3.min.js', - './js/vendor/jquery-ui-1.11.4.min.js', - './js/vendor/jquery.ba-throttle-debounce.js', - './js/vendor/jquery.nouislider.all.min.js', - './js/vendor/modernizr-2.6.2-respond-1.1.0.min.js', - './js/vendor/semver.js', - './js/vendor/three.js', - './js/vendor/three.min.js', - './js/screenshot.js', + './*.js', + './js/**/*', // everything else './package.json', // For NW.js diff --git a/index.html b/index.html index 54cbb958..505a9ef7 100644 --- a/index.html +++ b/index.html @@ -3075,9 +3075,9 @@ - - - + + + diff --git a/js/vendor/webm-writer/ArrayBufferDataStream.js b/js/vendor/webm-writer/ArrayBufferDataStream.js new file mode 100644 index 00000000..7e146229 --- /dev/null +++ b/js/vendor/webm-writer/ArrayBufferDataStream.js @@ -0,0 +1,209 @@ +/** + * A tool for presenting an ArrayBuffer as a stream for writing some simple data types. + * + * By Nicholas Sherlock + * + * Released under the WTFPLv2 https://en.wikipedia.org/wiki/WTFPL + */ + +"use strict"; + +(function(){ + /* + * Create an ArrayBuffer of the given length and present it as a writable stream with methods + * for writing data in different formats. + */ + let ArrayBufferDataStream = function(length) { + this.data = new Uint8Array(length); + this.pos = 0; + }; + + ArrayBufferDataStream.prototype.seek = function(toOffset) { + this.pos = toOffset; + }; + + ArrayBufferDataStream.prototype.writeBytes = function(arr) { + for (let i = 0; i < arr.length; i++) { + this.data[this.pos++] = arr[i]; + } + }; + + ArrayBufferDataStream.prototype.writeByte = function(b) { + this.data[this.pos++] = b; + }; + + //Synonym: + ArrayBufferDataStream.prototype.writeU8 = ArrayBufferDataStream.prototype.writeByte; + + ArrayBufferDataStream.prototype.writeU16BE = function(u) { + this.data[this.pos++] = u >> 8; + this.data[this.pos++] = u; + }; + + ArrayBufferDataStream.prototype.writeDoubleBE = function(d) { + let + bytes = new Uint8Array(new Float64Array([d]).buffer); + + for (let i = bytes.length - 1; i >= 0; i--) { + this.writeByte(bytes[i]); + } + }; + + ArrayBufferDataStream.prototype.writeFloatBE = function(d) { + let + bytes = new Uint8Array(new Float32Array([d]).buffer); + + for (let i = bytes.length - 1; i >= 0; i--) { + this.writeByte(bytes[i]); + } + }; + + /** + * Write an ASCII string to the stream + */ + ArrayBufferDataStream.prototype.writeString = function(s) { + for (let i = 0; i < s.length; i++) { + this.data[this.pos++] = s.charCodeAt(i); + } + }; + + /** + * Write the given 32-bit integer to the stream as an EBML variable-length integer using the given byte width + * (use measureEBMLVarInt). + * + * No error checking is performed to ensure that the supplied width is correct for the integer. + * + * @param i Integer to be written + * @param width Number of bytes to write to the stream + */ + ArrayBufferDataStream.prototype.writeEBMLVarIntWidth = function(i, width) { + switch (width) { + case 1: + this.writeU8((1 << 7) | i); + break; + case 2: + this.writeU8((1 << 6) | (i >> 8)); + this.writeU8(i); + break; + case 3: + this.writeU8((1 << 5) | (i >> 16)); + this.writeU8(i >> 8); + this.writeU8(i); + break; + case 4: + this.writeU8((1 << 4) | (i >> 24)); + this.writeU8(i >> 16); + this.writeU8(i >> 8); + this.writeU8(i); + break; + case 5: + /* + * JavaScript converts its doubles to 32-bit integers for bitwise operations, so we need to do a + * division by 2^32 instead of a right-shift of 32 to retain those top 3 bits + */ + this.writeU8((1 << 3) | ((i / 4294967296) & 0x7)); + this.writeU8(i >> 24); + this.writeU8(i >> 16); + this.writeU8(i >> 8); + this.writeU8(i); + break; + default: + throw new Error("Bad EBML VINT size " + width); + } + }; + + /** + * Return the number of bytes needed to encode the given integer as an EBML VINT. + */ + ArrayBufferDataStream.prototype.measureEBMLVarInt = function(val) { + if (val < (1 << 7) - 1) { + /* Top bit is set, leaving 7 bits to hold the integer, but we can't store 127 because + * "all bits set to one" is a reserved value. Same thing for the other cases below: + */ + return 1; + } else if (val < (1 << 14) - 1) { + return 2; + } else if (val < (1 << 21) - 1) { + return 3; + } else if (val < (1 << 28) - 1) { + return 4; + } else if (val < 34359738367) { // 2 ^ 35 - 1 (can address 32GB) + return 5; + } else { + throw new Error("EBML VINT size not supported " + val); + } + }; + + ArrayBufferDataStream.prototype.writeEBMLVarInt = function(i) { + this.writeEBMLVarIntWidth(i, this.measureEBMLVarInt(i)); + }; + + /** + * Write the given unsigned 32-bit integer to the stream in big-endian order using the given byte width. + * No error checking is performed to ensure that the supplied width is correct for the integer. + * + * Omit the width parameter to have it determined automatically for you. + * + * @param u Unsigned integer to be written + * @param width Number of bytes to write to the stream + */ + ArrayBufferDataStream.prototype.writeUnsignedIntBE = function(u, width) { + if (width === undefined) { + width = this.measureUnsignedInt(u); + } + + // Each case falls through: + switch (width) { + case 5: + this.writeU8(Math.floor(u / 4294967296)); // Need to use division to access >32 bits of floating point var + case 4: + this.writeU8(u >> 24); + case 3: + this.writeU8(u >> 16); + case 2: + this.writeU8(u >> 8); + case 1: + this.writeU8(u); + break; + default: + throw new Error("Bad UINT size " + width); + } + }; + + /** + * Return the number of bytes needed to hold the non-zero bits of the given unsigned integer. + */ + ArrayBufferDataStream.prototype.measureUnsignedInt = function(val) { + // Force to 32-bit unsigned integer + if (val < (1 << 8)) { + return 1; + } else if (val < (1 << 16)) { + return 2; + } else if (val < (1 << 24)) { + return 3; + } else if (val < 4294967296) { + return 4; + } else { + return 5; + } + }; + + /** + * Return a view on the portion of the buffer from the beginning to the current seek position as a Uint8Array. + */ + ArrayBufferDataStream.prototype.getAsDataArray = function() { + if (this.pos < this.data.byteLength) { + return this.data.subarray(0, this.pos); + } else if (this.pos == this.data.byteLength) { + return this.data; + } else { + throw new Error("ArrayBufferDataStream's pos lies beyond end of buffer"); + } + }; + + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = ArrayBufferDataStream; + } else { + window.ArrayBufferDataStream = ArrayBufferDataStream; + } +}()); \ No newline at end of file diff --git a/js/vendor/webm-writer/BlobBuffer.js b/js/vendor/webm-writer/BlobBuffer.js new file mode 100644 index 00000000..8a0b9cd3 --- /dev/null +++ b/js/vendor/webm-writer/BlobBuffer.js @@ -0,0 +1,230 @@ +"use strict"; + +/** + * Allows a series of Blob-convertible objects (ArrayBuffer, Blob, String, etc) to be added to a buffer. Seeking and + * overwriting of blobs is allowed. + * + * You can supply a FileWriter, in which case the BlobBuffer is just used as temporary storage before it writes it + * through to the disk. + * + * By Nicholas Sherlock + * + * Released under the WTFPLv2 https://en.wikipedia.org/wiki/WTFPL + */ +(function() { + let BlobBuffer = function(fs) { + return function(destination) { + let + buffer = [], + writePromise = Promise.resolve(), + fileWriter = null, + fd = null; + + /* PATCHED: Modified over the original library version. Is no compatible since some update of the Node.js app + if (destination && destination.constructor.name === "FileWriter") { + */ + if (destination) { + fileWriter = destination; + } else if (fs && destination) { + fd = destination; + } + + // Current seek offset + this.pos = 0; + + // One more than the index of the highest byte ever written + this.length = 0; + + // Returns a promise that converts the blob to an ArrayBuffer + function readBlobAsBuffer(blob) { + return new Promise(function (resolve, reject) { + let + reader = new FileReader(); + + reader.addEventListener("loadend", function () { + resolve(reader.result); + }); + + reader.readAsArrayBuffer(blob); + }); + } + + function convertToUint8Array(thing) { + return new Promise(function (resolve, reject) { + if (thing instanceof Uint8Array) { + resolve(thing); + } else if (thing instanceof ArrayBuffer || ArrayBuffer.isView(thing)) { + resolve(new Uint8Array(thing)); + } else if (thing instanceof Blob) { + resolve(readBlobAsBuffer(thing).then(function (buffer) { + return new Uint8Array(buffer); + })); + } else { + //Assume that Blob will know how to read this thing + resolve(readBlobAsBuffer(new Blob([thing])).then(function (buffer) { + return new Uint8Array(buffer); + })); + } + }); + } + + function measureData(data) { + let + result = data.byteLength || data.length || data.size; + + if (!Number.isInteger(result)) { + throw new Error("Failed to determine size of element"); + } + + return result; + } + + /** + * Seek to the given absolute offset. + * + * You may not seek beyond the end of the file (this would create a hole and/or allow blocks to be written in non- + * sequential order, which isn't currently supported by the memory buffer backend). + */ + this.seek = function (offset) { + if (offset < 0) { + throw new Error("Offset may not be negative"); + } + + if (isNaN(offset)) { + throw new Error("Offset may not be NaN"); + } + + if (offset > this.length) { + throw new Error("Seeking beyond the end of file is not allowed"); + } + + this.pos = offset; + }; + + /** + * Write the Blob-convertible data to the buffer at the current seek position. + * + * Note: If overwriting existing data, the write must not cross preexisting block boundaries (written data must + * be fully contained by the extent of a previous write). + */ + this.write = function (data) { + let + newEntry = { + offset: this.pos, + data: data, + length: measureData(data) + }, + isAppend = newEntry.offset >= this.length; + + this.pos += newEntry.length; + this.length = Math.max(this.length, this.pos); + + // After previous writes complete, perform our write + writePromise = writePromise.then(function () { + if (fd) { + return new Promise(function(resolve, reject) { + convertToUint8Array(newEntry.data).then(function(dataArray) { + let + totalWritten = 0, + buffer = Buffer.from(dataArray.buffer), + + handleWriteComplete = function(err, written, buffer) { + totalWritten += written; + + if (totalWritten >= buffer.length) { + resolve(); + } else { + // We still have more to write... + fs.write(fd, buffer, totalWritten, buffer.length - totalWritten, newEntry.offset + totalWritten, handleWriteComplete); + } + }; + + fs.write(fd, buffer, 0, buffer.length, newEntry.offset, handleWriteComplete); + }); + }); + } else if (fileWriter) { + return new Promise(function (resolve, reject) { + fileWriter.onwriteend = resolve; + + fileWriter.seek(newEntry.offset); + fileWriter.write(new Blob([newEntry.data])); + }); + } else if (!isAppend) { + // We might be modifying a write that was already buffered in memory. + + // Slow linear search to find a block we might be overwriting + for (let i = 0; i < buffer.length; i++) { + let + entry = buffer[i]; + + // If our new entry overlaps the old one in any way... + if (!(newEntry.offset + newEntry.length <= entry.offset || newEntry.offset >= entry.offset + entry.length)) { + if (newEntry.offset < entry.offset || newEntry.offset + newEntry.length > entry.offset + entry.length) { + throw new Error("Overwrite crosses blob boundaries"); + } + + if (newEntry.offset == entry.offset && newEntry.length == entry.length) { + // We overwrote the entire block + entry.data = newEntry.data; + + // We're done + return; + } else { + return convertToUint8Array(entry.data) + .then(function (entryArray) { + entry.data = entryArray; + + return convertToUint8Array(newEntry.data); + }).then(function (newEntryArray) { + newEntry.data = newEntryArray; + + entry.data.set(newEntry.data, newEntry.offset - entry.offset); + }); + } + } + } + // Else fall through to do a simple append, as we didn't overwrite any pre-existing blocks + } + + buffer.push(newEntry); + }); + }; + + /** + * Finish all writes to the buffer, returning a promise that signals when that is complete. + * + * If a FileWriter was not provided, the promise is resolved with a Blob that represents the completed BlobBuffer + * contents. You can optionally pass in a mimeType to be used for this blob. + * + * If a FileWriter was provided, the promise is resolved with null as the first argument. + */ + this.complete = function (mimeType) { + if (fd || fileWriter) { + writePromise = writePromise.then(function () { + return null; + }); + } else { + // After writes complete we need to merge the buffer to give to the caller + writePromise = writePromise.then(function () { + let + result = []; + + for (let i = 0; i < buffer.length; i++) { + result.push(buffer[i].data); + } + + return new Blob(result, {type: mimeType}); + }); + } + + return writePromise; + }; + }; + }; + + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = BlobBuffer(require('fs')); + } else { + window.BlobBuffer = BlobBuffer(null); + } +})(); diff --git a/js/vendor/webm-writer/Readme.md b/js/vendor/webm-writer/Readme.md new file mode 100644 index 00000000..03757578 --- /dev/null +++ b/js/vendor/webm-writer/Readme.md @@ -0,0 +1,84 @@ +# WebM Writer for Electron + +This is a WebM video encoder based on the ideas from [Whammy][]. It allows you to turn a series of +Canvas frames into a WebM video. + +This implementation allows you to create very large video files (exceeding the size of available memory), because it +can stream chunks immediately to a file on disk while the video is being constructed, +instead of needing to buffer the entire video in memory before saving can begin. Video sizes in excess of 4GB can be +written. The implementation currently tops out at 32GB, but this could be extended. + +When not streaming to disk, it can instead buffer the video in memory as a series of Blobs which are eventually +returned to the calling code as one composite Blob. This Blob can be displayed in a <video> element, transmitted +to a server, or used for some other purpose. Note that Chrome has a [Blob size limit][] of 500MB. + +Because this library relies on Chrome's WebP encoder to do the hard work for it, it can only run in a Chrome environment +(e.g. Chrome, Chromium, Electron), it can't run on vanilla Node. + +[Whammy]: https://github.com/antimatter15/whammy +[Blob size limit]: https://github.com/eligrey/FileSaver.js/ + +## Usage + +Add webm-writer to your project: + +``` +npm install --save webm-writer +``` + +Require and construct the writer, passing in any options you want to customize: + +```js +var + WebMWriter = require('webm-writer'), + + videoWriter = new WebMWriter({ + quality: 0.95, // WebM image quality from 0.0 (worst) to 0.99999 (best), 1.00 (VP8L lossless) is not supported + fileWriter: null, // FileWriter in order to stream to a file instead of buffering to memory (optional) + fd: null, // Node.js file handle to write to instead of buffering to memory (optional) + + // You must supply one of: + frameDuration: null, // Duration of frames in milliseconds + frameRate: null, // Number of frames per second + + transparent: false, // True if an alpha channel should be included in the video + alphaQuality: undefined, // Allows you to set the quality level of the alpha channel separately. + // If not specified this defaults to the same value as `quality`. + }); +``` + +Add as many Canvas frames as you like to build your video: + +```js +videoWriter.addFrame(canvas); +``` + +When you're done, you must call `complete()` to finish writing the video: + +```js +videoWriter.complete(); +``` + +`complete()` returns a Promise which resolves when writing is completed. + +If you didn't supply a `fd` in the options, the Promise will resolve to Blob which represents the video. You +could display this blob in an HTML5 <video> tag: + +```js +videoWriter.complete().then(function(webMBlob) { + $("video").attr("src", URL.createObjectURL(webMBlob)); +}); +``` + +There's an example which saves the video to an open file descriptor instead of to a Blob on this page: + +https://github.com/thenickdude/webm-writer-js/tree/master/test/electron + +## Transparent WebM support + +Transparent WebM files are supported, check out the example in https://github.com/thenickdude/webm-writer-js/tree/master/test/transparent. However, because I'm re-using Chrome's +WebP encoder to create the alpha channel, and the alpha channel is taken from the Y channel of a YUV-encoded WebP frame, +and Y values are clamped by Chrome to be in the range 22-240 instead of the full 0-255 range, the encoded video can +neither be fully opaque or fully transparent :(. + +Sorry, I wasn't able to find a workaround to get that to work. diff --git a/js/vendor/webm-writer/WebMWriter.js b/js/vendor/webm-writer/WebMWriter.js new file mode 100644 index 00000000..9118220b --- /dev/null +++ b/js/vendor/webm-writer/WebMWriter.js @@ -0,0 +1,961 @@ +/** + * WebM video encoder for Google Chrome. This implementation is suitable for creating very large video files, because + * it can stream Blobs directly to a FileWriter without buffering the entire video in memory. + * + * When FileWriter is not available or not desired, it can buffer the video in memory as a series of Blobs which are + * eventually returned as one composite Blob. + * + * By Nicholas Sherlock. + * + * Based on the ideas from Whammy: https://github.com/antimatter15/whammy + * + * Released under the WTFPLv2 https://en.wikipedia.org/wiki/WTFPL + */ + +"use strict"; + +(function() { + function extend(base, top) { + let + target = {}; + + [base, top].forEach(function(obj) { + for (let prop in obj) { + if (Object.prototype.hasOwnProperty.call(obj, prop)) { + target[prop] = obj[prop]; + } + } + }); + + return target; + } + + /** + * Decode a Base64 data URL into a binary string. + * + * @return {String} The binary string + */ + function decodeBase64WebPDataURL(url) { + if (typeof url !== "string" || !url.match(/^data:image\/webp;base64,/i)) { + throw new Error("Failed to decode WebP Base64 URL"); + } + + return window.atob(url.substring("data:image\/webp;base64,".length)); + } + + /** + * Convert the given canvas to a WebP encoded image and return the image data as a string. + * + * @return {String} + */ + function renderAsWebP(canvas, quality) { + let + frame = typeof canvas === 'string' && /^data:image\/webp/.test(canvas) + ? canvas + : canvas.toDataURL('image/webp', quality); + + return decodeBase64WebPDataURL(frame); + } + + /** + * @param {String} string + * @returns {number} + */ + function byteStringToUint32LE(string) { + let + a = string.charCodeAt(0), + b = string.charCodeAt(1), + c = string.charCodeAt(2), + d = string.charCodeAt(3); + + return (a | (b << 8) | (c << 16) | (d << 24)) >>> 0; + } + + /** + * Extract a VP8 keyframe from a WebP image file. + * + * @param {String} webP - Raw binary string + * + * @returns {{hasAlpha: boolean, frame: string}} + */ + function extractKeyframeFromWebP(webP) { + let + cursor = webP.indexOf('VP8', 12); // Start the search after the 12-byte file header + + if (cursor === -1) { + throw new Error("Bad image format, does this browser support WebP?"); + } + + let + hasAlpha = false; + + /* Cursor now is either directly pointing at a "VP8 " keyframe, or a "VP8X" extended format file header + * Seek through chunks until we find the "VP8 " chunk we're interested in + */ + while (cursor < webP.length - 8) { + let + chunkLength, fourCC; + + fourCC = webP.substring(cursor, cursor + 4); + cursor += 4; + + chunkLength = byteStringToUint32LE(webP.substring(cursor, cursor + 4)); + cursor += 4; + + switch (fourCC) { + case "VP8 ": + return { + frame: webP.substring(cursor, cursor + chunkLength), + hasAlpha: hasAlpha + }; + + case "ALPH": + hasAlpha = true; + /* But we otherwise ignore the content of the alpha chunk, since we don't have a decoder for it + * and it isn't VP8-compatible + */ + break; + } + + cursor += chunkLength; + + if ((chunkLength & 0x01) !== 0) { + cursor++; + // Odd-length chunks have 1 byte of trailing padding that isn't included in their length + } + } + + throw new Error("Failed to find VP8 keyframe in WebP image, is this image mistakenly encoded in the Lossless WebP format?"); + } + + const + EBML_SIZE_UNKNOWN = -1, + EBML_SIZE_UNKNOWN_5_BYTES = -2; + + // Just a little utility so we can tag values as floats for the EBML encoder's benefit + function EBMLFloat32(value) { + this.value = value; + } + + function EBMLFloat64(value) { + this.value = value; + } + + /** + * Write the given EBML object to the provided ArrayBufferStream. + * + * @param buffer + * @param {Number} bufferFileOffset - The buffer's first byte is at this position inside the video file. + * This is used to complete offset and dataOffset fields in each EBML structure, + * indicating the file offset of the first byte of the EBML element and + * its data payload. + * @param {*} ebml + */ + function writeEBML(buffer, bufferFileOffset, ebml) { + // Is the ebml an array of sibling elements? + if (Array.isArray(ebml)) { + for (let i = 0; i < ebml.length; i++) { + writeEBML(buffer, bufferFileOffset, ebml[i]); + } + // Is this some sort of raw data that we want to write directly? + } else if (typeof ebml === "string") { + buffer.writeString(ebml); + } else if (ebml instanceof Uint8Array) { + buffer.writeBytes(ebml); + } else if (ebml.id){ + // We're writing an EBML element + ebml.offset = buffer.pos + bufferFileOffset; + + buffer.writeUnsignedIntBE(ebml.id); // ID field + + // Now we need to write the size field, so we must know the payload size: + + if (Array.isArray(ebml.data)) { + // Writing an array of child elements. We won't try to measure the size of the children up-front + + let + sizePos, dataBegin, dataEnd; + + if (ebml.size === EBML_SIZE_UNKNOWN) { + // Write the reserved all-one-bits marker to note that the size of this element is unknown/unbounded + buffer.writeByte(0xFF); + } else if (ebml.size === EBML_SIZE_UNKNOWN_5_BYTES) { + sizePos = buffer.pos; + + // VINT_DATA is all-ones, so this is the reserved "unknown length" marker: + buffer.writeBytes([0x0F, 0xFF, 0xFF, 0xFF, 0xFF]); + } else { + sizePos = buffer.pos; + + /* Write a dummy size field to overwrite later. 4 bytes allows an element maximum size of 256MB, + * which should be plenty (we don't want to have to buffer that much data in memory at one time + * anyway!) + */ + buffer.writeBytes([0, 0, 0, 0]); + } + + dataBegin = buffer.pos; + + ebml.dataOffset = dataBegin + bufferFileOffset; + writeEBML(buffer, bufferFileOffset, ebml.data); + + if (ebml.size !== EBML_SIZE_UNKNOWN && ebml.size !== EBML_SIZE_UNKNOWN_5_BYTES) { + dataEnd = buffer.pos; + + ebml.size = dataEnd - dataBegin; + + buffer.seek(sizePos); + buffer.writeEBMLVarIntWidth(ebml.size, 4); // Size field + + buffer.seek(dataEnd); + } + } else if (typeof ebml.data === "string") { + buffer.writeEBMLVarInt(ebml.data.length); // Size field + ebml.dataOffset = buffer.pos + bufferFileOffset; + buffer.writeString(ebml.data); + } else if (typeof ebml.data === "number") { + // Allow the caller to explicitly choose the size if they wish by supplying a size field + if (!ebml.size) { + ebml.size = buffer.measureUnsignedInt(ebml.data); + } + + buffer.writeEBMLVarInt(ebml.size); // Size field + ebml.dataOffset = buffer.pos + bufferFileOffset; + buffer.writeUnsignedIntBE(ebml.data, ebml.size); + } else if (ebml.data instanceof EBMLFloat64) { + buffer.writeEBMLVarInt(8); // Size field + ebml.dataOffset = buffer.pos + bufferFileOffset; + buffer.writeDoubleBE(ebml.data.value); + } else if (ebml.data instanceof EBMLFloat32) { + buffer.writeEBMLVarInt(4); // Size field + ebml.dataOffset = buffer.pos + bufferFileOffset; + buffer.writeFloatBE(ebml.data.value); + } else if (ebml.data instanceof Uint8Array) { + buffer.writeEBMLVarInt(ebml.data.byteLength); // Size field + ebml.dataOffset = buffer.pos + bufferFileOffset; + buffer.writeBytes(ebml.data); + } else { + throw new Error("Bad EBML datatype " + typeof ebml.data); + } + } else { + throw new Error("Bad EBML datatype " + typeof ebml.data); + } + } + + /** + * @typedef {Object} Frame + * @property {string} frame - Raw VP8 keyframe data + * @property {string} alpha - Raw VP8 keyframe with alpha represented as luminance + * @property {Number} duration + * @property {Number} trackNumber - From 1 to 126 (inclusive) + * @property {Number} timecode + */ + + /** + * @typedef {Object} Cluster + * @property {Number} timecode - Start time for the cluster + */ + + /** + * @param ArrayBufferDataStream - Imported library + * @param BlobBuffer - Imported library + * + * @returns WebMWriter + * + * @constructor + */ + let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) { + return function(options) { + let + MAX_CLUSTER_DURATION_MSEC = 5000, + DEFAULT_TRACK_NUMBER = 1, + + writtenHeader = false, + videoWidth = 0, videoHeight = 0, + + /** + * @type {[HTMLCanvasElement]} + */ + alphaBuffer = null, + + /** + * @type {[CanvasRenderingContext2D]} + */ + alphaBufferContext = null, + + /** + * @type {[ImageData]} + */ + alphaBufferData = null, + + /** + * + * @type {Frame[]} + */ + clusterFrameBuffer = [], + clusterStartTime = 0, + clusterDuration = 0, + + optionDefaults = { + quality: 0.95, // WebM image quality from 0.0 (worst) to 0.99999 (best), 1.00 (WebP lossless) is not supported + + transparent: false, // True if an alpha channel should be included in the video + alphaQuality: undefined, // Allows you to set the quality level of the alpha channel separately. + // If not specified this defaults to the same value as `quality`. + + fileWriter: null, // Chrome FileWriter in order to stream to a file instead of buffering to memory (optional) + fd: null, // Node.JS file descriptor to write to instead of buffering (optional) + + // You must supply one of: + frameDuration: null, // Duration of frames in milliseconds + frameRate: null, // Number of frames per second + }, + + seekPoints = { + Cues: {id: new Uint8Array([0x1C, 0x53, 0xBB, 0x6B]), positionEBML: null}, + SegmentInfo: {id: new Uint8Array([0x15, 0x49, 0xA9, 0x66]), positionEBML: null}, + Tracks: {id: new Uint8Array([0x16, 0x54, 0xAE, 0x6B]), positionEBML: null}, + }, + + ebmlSegment, // Root element of the EBML document + + segmentDuration = { + "id": 0x4489, // Duration + "data": new EBMLFloat64(0) + }, + + seekHead, + + cues = [], + + blobBuffer = new BlobBuffer(options.fileWriter || options.fd); + + function fileOffsetToSegmentRelative(fileOffset) { + return fileOffset - ebmlSegment.dataOffset; + } + + /** + * Extracts the transparency channel from the supplied canvas and uses it to create a VP8 alpha channel bitstream. + * + * @param {HTMLCanvasElement} source + * + * @return {HTMLCanvasElement} + */ + function convertAlphaToGrayscaleImage(source) { + if (alphaBuffer === null || alphaBuffer.width !== source.width || alphaBuffer.height !== source.height) { + alphaBuffer = document.createElement("canvas"); + alphaBuffer.width = source.width; + alphaBuffer.height = source.height; + + alphaBufferContext = alphaBuffer.getContext("2d"); + alphaBufferData = alphaBufferContext.createImageData(alphaBuffer.width, alphaBuffer.height); + } + + let + sourceContext = source.getContext("2d"), + sourceData = sourceContext.getImageData(0, 0, source.width, source.height).data, + destData = alphaBufferData.data, + dstCursor = 0, + srcEnd = source.width * source.height * 4; + + for (let srcCursor = 3 /* Since pixel byte order is RGBA */; srcCursor < srcEnd; srcCursor += 4) { + let + alpha = sourceData[srcCursor]; + + // Turn the original alpha channel into a brightness value (ends up being the Y in YUV) + destData[dstCursor++] = alpha; + destData[dstCursor++] = alpha; + destData[dstCursor++] = alpha; + destData[dstCursor++] = 255; + } + + alphaBufferContext.putImageData(alphaBufferData, 0, 0); + + return alphaBuffer; + } + + /** + * Create a SeekHead element with descriptors for the points in the global seekPoints array. + * + * 5 bytes of position values are reserved for each node, which lie at the offset point.positionEBML.dataOffset, + * to be overwritten later. + */ + function createSeekHead() { + let + seekPositionEBMLTemplate = { + "id": 0x53AC, // SeekPosition + "size": 5, // Allows for 32GB video files + "data": 0 // We'll overwrite this when the file is complete + }, + + result = { + "id": 0x114D9B74, // SeekHead + "data": [] + }; + + for (let name in seekPoints) { + let + seekPoint = seekPoints[name]; + + seekPoint.positionEBML = Object.create(seekPositionEBMLTemplate); + + result.data.push({ + "id": 0x4DBB, // Seek + "data": [ + { + "id": 0x53AB, // SeekID + "data": seekPoint.id + }, + seekPoint.positionEBML + ] + }); + } + + return result; + } + + /** + * Write the WebM file header to the stream. + */ + function writeHeader() { + seekHead = createSeekHead(); + + let + ebmlHeader = { + "id": 0x1a45dfa3, // EBML + "data": [ + { + "id": 0x4286, // EBMLVersion + "data": 1 + }, + { + "id": 0x42f7, // EBMLReadVersion + "data": 1 + }, + { + "id": 0x42f2, // EBMLMaxIDLength + "data": 4 + }, + { + "id": 0x42f3, // EBMLMaxSizeLength + "data": 8 + }, + { + "id": 0x4282, // DocType + "data": "webm" + }, + { + "id": 0x4287, // DocTypeVersion + "data": 2 + }, + { + "id": 0x4285, // DocTypeReadVersion + "data": 2 + } + ] + }, + + segmentInfo = { + "id": 0x1549a966, // Info + "data": [ + { + "id": 0x2ad7b1, // TimecodeScale + "data": 1e6 // Times will be in miliseconds (1e6 nanoseconds per step = 1ms) + }, + { + "id": 0x4d80, // MuxingApp + "data": "webm-writer-js", + }, + { + "id": 0x5741, // WritingApp + "data": "webm-writer-js" + }, + segmentDuration // To be filled in later + ] + }, + + videoProperties = [ + { + "id": 0xb0, // PixelWidth + "data": videoWidth + }, + { + "id": 0xba, // PixelHeight + "data": videoHeight + } + ]; + + if (options.transparent) { + videoProperties.push( + { + "id": 0x53C0, // AlphaMode + "data": 1 + } + ); + } + + let + tracks = { + "id": 0x1654ae6b, // Tracks + "data": [ + { + "id": 0xae, // TrackEntry + "data": [ + { + "id": 0xd7, // TrackNumber + "data": DEFAULT_TRACK_NUMBER + }, + { + "id": 0x73c5, // TrackUID + "data": DEFAULT_TRACK_NUMBER + }, + { + "id": 0x9c, // FlagLacing + "data": 0 + }, + { + "id": 0x22b59c, // Language + "data": "und" + }, + { + "id": 0x86, // CodecID + "data": "V_VP8" + }, + { + "id": 0x258688, // CodecName + "data": "VP8" + }, + { + "id": 0x83, // TrackType + "data": 1 + }, + { + "id": 0xe0, // Video + "data": videoProperties + } + ] + } + ] + }; + + ebmlSegment = { + "id": 0x18538067, // Segment + "size": EBML_SIZE_UNKNOWN_5_BYTES, // We'll seek back and fill this in at completion + "data": [ + seekHead, + segmentInfo, + tracks, + ] + }; + + let + bufferStream = new ArrayBufferDataStream(256); + + writeEBML(bufferStream, blobBuffer.pos, [ebmlHeader, ebmlSegment]); + blobBuffer.write(bufferStream.getAsDataArray()); + + // Now we know where these top-level elements lie in the file: + seekPoints.SegmentInfo.positionEBML.data = fileOffsetToSegmentRelative(segmentInfo.offset); + seekPoints.Tracks.positionEBML.data = fileOffsetToSegmentRelative(tracks.offset); + + writtenHeader = true; + } + + /** + * Create a BlockGroup element to hold the given keyframe (used when alpha support is required) + * + * @param {Frame} keyframe + * + * @return A BlockGroup EBML element + */ + function createBlockGroupForTransparentKeyframe(keyframe) { + let + block, blockAdditions, + + bufferStream = new ArrayBufferDataStream(1 + 2 + 1); + + // Create a Block to hold the image data: + + if (!(keyframe.trackNumber > 0 && keyframe.trackNumber < 127)) { + throw new Error("TrackNumber must be > 0 and < 127"); + } + + bufferStream.writeEBMLVarInt(keyframe.trackNumber); // Always 1 byte since we limit the range of trackNumber + bufferStream.writeU16BE(keyframe.timecode); + bufferStream.writeByte(0); // Flags byte + + block = { + "id": 0xA1, // Block + "data": [ + bufferStream.getAsDataArray(), + keyframe.frame + ] + }; + + blockAdditions = { + "id": 0x75A1, // BlockAdditions + "data": [ + { + "id": 0xA6, // BlockMore + "data": [ + { + "id": 0xEE, // BlockAddID + "data": 1 // Means "BlockAdditional has a codec-defined meaning, pass it to the codec" + }, + { + "id": 0xA5, // BlockAdditional + "data": keyframe.alpha // The actual alpha channel image + } + ] + } + ] + }; + + return { + "id": 0xA0, // BlockGroup + "data": [ + block, + blockAdditions + ] + }; + } + + /** + * Create a SimpleBlock element to hold the given keyframe. + * + * @param {Frame} keyframe + * + * @return A SimpleBlock EBML element. + */ + function createSimpleBlockForKeyframe(keyframe) { + let + bufferStream = new ArrayBufferDataStream(1 + 2 + 1); + + if (!(keyframe.trackNumber > 0 && keyframe.trackNumber < 127)) { + throw new Error("TrackNumber must be > 0 and < 127"); + } + + bufferStream.writeEBMLVarInt(keyframe.trackNumber); // Always 1 byte since we limit the range of trackNumber + bufferStream.writeU16BE(keyframe.timecode); + + // Flags byte + bufferStream.writeByte( + 1 << 7 // Keyframe + ); + + return { + "id": 0xA3, // SimpleBlock + "data": [ + bufferStream.getAsDataArray(), + keyframe.frame + ] + }; + } + + /** + * Create either a SimpleBlock or BlockGroup (if alpha is required) for the given keyframe. + * + * @param {Frame} keyframe + */ + function createContainerForKeyframe(keyframe) { + if (keyframe.alpha) { + return createBlockGroupForTransparentKeyframe(keyframe); + } + + return createSimpleBlockForKeyframe(keyframe); + } + + /** + * Create a Cluster EBML node. + * + * @param {Cluster} cluster + * + * Returns an EBML element. + */ + function createCluster(cluster) { + return { + "id": 0x1f43b675, + "data": [ + { + "id": 0xe7, // Timecode + "data": Math.round(cluster.timecode) + } + ] + }; + } + + function addCuePoint(trackIndex, clusterTime, clusterFileOffset) { + cues.push({ + "id": 0xBB, // Cue + "data": [ + { + "id": 0xB3, // CueTime + "data": clusterTime + }, + { + "id": 0xB7, // CueTrackPositions + "data": [ + { + "id": 0xF7, // CueTrack + "data": trackIndex + }, + { + "id": 0xF1, // CueClusterPosition + "data": fileOffsetToSegmentRelative(clusterFileOffset) + } + ] + } + ] + }); + } + + /** + * Write a Cues element to the blobStream using the global `cues` array of CuePoints (use addCuePoint()). + * The seek entry for the Cues in the SeekHead is updated. + */ + function writeCues() { + let + ebml = { + "id": 0x1C53BB6B, + "data": cues + }, + + cuesBuffer = new ArrayBufferDataStream(16 + cues.length * 32); // Pretty crude estimate of the buffer size we'll need + + writeEBML(cuesBuffer, blobBuffer.pos, ebml); + blobBuffer.write(cuesBuffer.getAsDataArray()); + + // Now we know where the Cues element has ended up, we can update the SeekHead + seekPoints.Cues.positionEBML.data = fileOffsetToSegmentRelative(ebml.offset); + } + + /** + * Flush the frames in the current clusterFrameBuffer out to the stream as a Cluster. + */ + function flushClusterFrameBuffer() { + if (clusterFrameBuffer.length === 0) { + return; + } + + // First work out how large of a buffer we need to hold the cluster data + let + rawImageSize = 0; + + for (let i = 0; i < clusterFrameBuffer.length; i++) { + rawImageSize += clusterFrameBuffer[i].frame.length + (clusterFrameBuffer[i].alpha ? clusterFrameBuffer[i].alpha.length : 0); + } + + let + buffer = new ArrayBufferDataStream(rawImageSize + clusterFrameBuffer.length * 64), // Estimate 64 bytes per block header + + cluster = createCluster({ + timecode: Math.round(clusterStartTime), + }); + + for (let i = 0; i < clusterFrameBuffer.length; i++) { + cluster.data.push(createContainerForKeyframe(clusterFrameBuffer[i])); + } + + writeEBML(buffer, blobBuffer.pos, cluster); + blobBuffer.write(buffer.getAsDataArray()); + + addCuePoint(DEFAULT_TRACK_NUMBER, Math.round(clusterStartTime), cluster.offset); + + clusterFrameBuffer = []; + clusterStartTime += clusterDuration; + clusterDuration = 0; + } + + function validateOptions() { + // Derive frameDuration setting if not already supplied + if (!options.frameDuration) { + if (options.frameRate) { + options.frameDuration = 1000 / options.frameRate; + } else { + throw new Error("Missing required frameDuration or frameRate setting"); + } + } + + // Avoid 1.0 (lossless) because it creates VP8L lossless frames that WebM doesn't support + options.quality = Math.max(Math.min(options.quality, 0.99999), 0); + + if (options.alphaQuality === undefined) { + options.alphaQuality = options.quality; + } else { + options.alphaQuality = Math.max(Math.min(options.alphaQuality, 0.99999), 0); + } + } + + /** + * + * @param {Frame} frame + */ + function addFrameToCluster(frame) { + frame.trackNumber = DEFAULT_TRACK_NUMBER; + + // Frame timecodes are relative to the start of their cluster: + frame.timecode = Math.round(clusterDuration); + + clusterFrameBuffer.push(frame); + + clusterDuration += frame.duration; + + if (clusterDuration >= MAX_CLUSTER_DURATION_MSEC) { + flushClusterFrameBuffer(); + } + } + + /** + * Rewrites the SeekHead element that was initially written to the stream with the offsets of top level elements. + * + * Call once writing is complete (so the offset of all top level elements is known). + */ + function rewriteSeekHead() { + let + seekHeadBuffer = new ArrayBufferDataStream(seekHead.size), + oldPos = blobBuffer.pos; + + // Write the rewritten SeekHead element's data payload to the stream (don't need to update the id or size) + writeEBML(seekHeadBuffer, seekHead.dataOffset, seekHead.data); + + // And write that through to the file + blobBuffer.seek(seekHead.dataOffset); + blobBuffer.write(seekHeadBuffer.getAsDataArray()); + + blobBuffer.seek(oldPos); + } + + /** + * Rewrite the Duration field of the Segment with the newly-discovered video duration. + */ + function rewriteDuration() { + let + buffer = new ArrayBufferDataStream(8), + oldPos = blobBuffer.pos; + + // Rewrite the data payload (don't need to update the id or size) + buffer.writeDoubleBE(clusterStartTime); + + // And write that through to the file + blobBuffer.seek(segmentDuration.dataOffset); + blobBuffer.write(buffer.getAsDataArray()); + + blobBuffer.seek(oldPos); + } + + /** + * Rewrite the size field of the Segment. + */ + function rewriteSegmentLength() { + let + buffer = new ArrayBufferDataStream(10), + oldPos = blobBuffer.pos; + + // We just need to rewrite the ID and Size fields of the root Segment: + buffer.writeUnsignedIntBE(ebmlSegment.id); + buffer.writeEBMLVarIntWidth(blobBuffer.pos - ebmlSegment.dataOffset, 5); + + // And write that on top of the original: + blobBuffer.seek(ebmlSegment.offset); + blobBuffer.write(buffer.getAsDataArray()); + + blobBuffer.seek(oldPos); + } + + /** + * Add a frame to the video. + * + * @param {HTMLCanvasElement|String} frame - A Canvas element that contains the frame, or a WebP string + * you obtained by calling toDataUrl() on an image yourself. + * + * @param {HTMLCanvasElement|String} [alpha] - For transparent video, instead of including the alpha channel + * in your provided `frame`, you can instead provide it separately + * here. The alpha channel of this alpha canvas will be ignored, + * encode your alpha information into this canvas' grayscale + * brightness instead. + * + * This is useful because it allows you to paint the colours + * you need into your `frame` even in regions which are fully + * transparent (which Canvas doesn't normally let you influence). + * This allows you to control the colour of the fringing seen + * around objects on transparent backgrounds. + * + * @param {Number} [overrideFrameDuration] - Set a duration for this frame (in milliseconds) that differs + * from the default + */ + this.addFrame = function(frame, alpha, overrideFrameDuration) { + if (!writtenHeader) { + videoWidth = frame.width || 0; + videoHeight = frame.height || 0; + + writeHeader(); + } + + let + keyframe = extractKeyframeFromWebP(renderAsWebP(frame, options.quality)), + frameDuration, frameAlpha = null; + + if (overrideFrameDuration) { + frameDuration = overrideFrameDuration; + } else if (typeof alpha == "number") { + frameDuration = alpha; + } else { + frameDuration = options.frameDuration; + } + + if (options.transparent) { + if (alpha instanceof HTMLCanvasElement || typeof alpha === "string") { + frameAlpha = alpha; + } else if (keyframe.hasAlpha) { + frameAlpha = convertAlphaToGrayscaleImage(frame); + } + } + + addFrameToCluster({ + frame: keyframe.frame, + duration: frameDuration, + alpha: frameAlpha ? extractKeyframeFromWebP(renderAsWebP(frameAlpha, options.alphaQuality)).frame : null + }); + }; + + /** + * Finish writing the video and return a Promise to signal completion. + * + * If the destination device was memory (i.e. options.fileWriter was not supplied), the Promise is resolved with + * a Blob with the contents of the entire video. + */ + this.complete = function() { + if (!writtenHeader) { + writeHeader(); + } + + flushClusterFrameBuffer(); + writeCues(); + + /* + * Now the file is at its final length and the position of all elements is known, seek back to the + * header and update pointers: + */ + + rewriteSeekHead(); + rewriteDuration(); + rewriteSegmentLength(); + + return blobBuffer.complete('video/webm'); + }; + + this.getWrittenSize = function() { + return blobBuffer.length; + }; + + options = extend(optionDefaults, options || {}); + validateOptions(); + }; + }; + + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = WebMWriter(require("./ArrayBufferDataStream"), require("./BlobBuffer")); + } else { + window.WebMWriter = WebMWriter(window.ArrayBufferDataStream, window.BlobBuffer); + } +})(); diff --git a/package.json b/package.json index 5f1666c2..7765492d 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,7 @@ "dependencies": { "bootstrap": "~3.4.1", "html2canvas": "^1.0.0-rc.5", - "lodash": "^4.17.21", - "webm-writer": "^0.3.0" + "lodash": "^4.17.21" }, "devDependencies": { "@quanle94/innosetup": "^6.0.2", diff --git a/yarn.lock b/yarn.lock index 69480d4b..7fa6087c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3957,11 +3957,6 @@ vinyl@^2.0.0, vinyl@^2.1.0: remove-trailing-separator "^1.0.1" replace-ext "^1.0.0" -webm-writer@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/webm-writer/-/webm-writer-0.3.0.tgz#aa35fbf4b06139798b1c258ddb3901221cf28561" - integrity sha512-p6gKhj0lI/hSJCte0lcNpzPXhLqXCnzoBAuVUlCz7LYwLMqeWTax1g4NNz3VWqkA0y/AYwJQAaZFn8qPzlBI4A== - which-module@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f"