diff --git a/samples/capture-to-file/capture-to-file.html b/samples/capture-to-file/capture-to-file.html new file mode 100755 index 00000000..2aa82b56 --- /dev/null +++ b/samples/capture-to-file/capture-to-file.html @@ -0,0 +1,134 @@ + + + + + + + WebCodecs API demo: Encoding and Decoding + + + + + + + + + + + + + diff --git a/samples/capture-to-file/webm-writer2.js b/samples/capture-to-file/webm-writer2.js new file mode 100755 index 00000000..ccf64a0b --- /dev/null +++ b/samples/capture-to-file/webm-writer2.js @@ -0,0 +1,1162 @@ +/** + * A tool for presenting an ArrayBuffer as a stream for writing some simple data + * types. + * + * By Nicholas Sherlock, with updates from jimbankoski + * + * - make it work off frames with timestamps from webcodecs + * - make it write via Native File IO apis instead of FileWriter + * - remove alpha and transparency + * - + * + * 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; +} +}()); +'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; + + if (destination && + destination.constructor.name === 'FileSystemWritableFileStream') { + 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(async 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.seek(newEntry.offset) + .then(() => {fileWriter.write(new Blob([newEntry.data]))}) + .then(() => {resolve(); + }) + }); + } 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); +} +})(); +/** + * 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; +} + +/** + * @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; +} + + +// 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 === -1) { + // Write the reserved all-one-bits marker to note that the size of this + // element is unknown/unbounded + buffer.writeByte(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 !== -1) { + 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 frame data + * @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 = 5000000, DEFAULT_TRACK_NUMBER = 1, + writtenHeader = false, videoWidth = 0, videoHeight = 0, + firstTimestampEver = true, earliestTimestamp = 0, + + + /** + * + * @type {Frame[]} + */ + clusterFrameBuffer = [], clusterStartTime = 0, clusterDuration = 0, + lastTimeCode = 0, + + optionDefaults = { + 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) + codec: 'VP8', // Codec to write to webm file + + }, + + 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; + } + + + /** + * 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 microseconds (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 + } + ]; + + 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': 0x83, // TrackType + 'data': 1 + }, + { + 'id': 0xe0, // Video + 'data': videoProperties + }, + { + 'id': 0x9c, // FlagLacing + 'data': 0 + }, + { + 'id': 0x22b59c, // Language + 'data': 'und' + }, + { + 'id': 0xb9, // FlagEnabled + 'data': 1 + }, + { + 'id': 0x88, // FlagDefault + 'data': 1 + }, + { + 'id': 0x55aa, // FlagForced + 'data': 0 + }, + + { + 'id': 0x86, // CodecID + 'data': 'V_' + options.codec + }, /* + (options.codec == 'VP8' ? + { + 'id': 0x63A2, // Codec private data + 'data': [] + } : + { + 'id': 0x63A2, // Codec private data for vp9 + 'data': [ + { + 'id': 1, // vp9 Profile + 'size': 1, + 'data': 0 + }, + { + 'id': 2, // Feature level + 'size': 1, + 'data': 10 + }, + { + 'id': 3, // bitdepth level + 'size': 1, + 'data': 8 + }, + { + 'id': 4, // color sampling + 'size': 1, + 'data': 0 + } + ] + }), + { + 'id': 0x258688, // CodecName + 'data': options.codec + },*/ + ] + }] + }; + + ebmlSegment = { + 'id': 0x18538067, // Segment + 'size': -1, // Unbounded size + '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 SimpleBlock element to hold the given frame. + * + * @param {Frame} frame + * + * @return A SimpleBlock EBML element. + */ + function createSimpleBlockForframe(frame) { + let bufferStream = new ArrayBufferDataStream(1 + 2 + 1); + + if (!(frame.trackNumber > 0 && frame.trackNumber < 127)) { + throw new Error('TrackNumber must be > 0 and < 127'); + } + + bufferStream.writeEBMLVarInt( + frame.trackNumber); // Always 1 byte since we limit the range of + // trackNumber + bufferStream.writeU16BE(frame.timecode); + + // Flags byte + bufferStream.writeByte( + (frame.type == "key" ? 1 : 0) << 7 // frame + ); + + return { + 'id': 0xA3, // SimpleBlock + 'data': [bufferStream.getAsDataArray(), frame.frame] + }; + } + + /** + * 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.byteLength; + } + + 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(createSimpleBlockForframe(clusterFrameBuffer[i])); + } + + writeEBML(buffer, blobBuffer.pos, cluster); + blobBuffer.write(buffer.getAsDataArray()); + + addCuePoint( + DEFAULT_TRACK_NUMBER, Math.round(clusterStartTime), cluster.offset); + + clusterFrameBuffer = []; + clusterDuration = 0; + } + + function validateOptions() { + } + + /** + * + * @param {Frame} frame + */ + function addFrameToCluster(frame) { + frame.trackNumber = DEFAULT_TRACK_NUMBER; + var time = frame.intime / 1000; + if (firstTimestampEver) { + earliestTimestamp = time; + time = 0; + firstTimestampEver = false; + } else { + time = time - earliestTimestamp; + } + lastTimeCode = time; + if (clusterDuration == 0) clusterStartTime = time; + + // Frame timecodes are relative to the start of their cluster: + // frame.timecode = Math.round(clusterDuration); + frame.timecode = Math.round(time - clusterStartTime); + + clusterFrameBuffer.push(frame); + clusterDuration = frame.timecode + 1; + + 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(lastTimeCode); + + // And write that through to the file + blobBuffer.seek(segmentDuration.dataOffset); + 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. + * + */ + this.addFrame = function(frame) { + if (!writtenHeader) { + videoWidth = options.width; + videoHeight = options.height; + writeHeader(); + } + if (frame.constructor.name == 'EncodedVideoChunk') { + addFrameToCluster({ + frame: new Uint8Array(frame.data), + intime: frame.timestamp, + }); + return; + } + }; + + /** + * 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(); + } + firstTimestampEver = true; + + flushClusterFrameBuffer(); + + writeCues(); + rewriteSeekHead(); + rewriteDuration(); + + 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/samples/index.html b/samples/index.html index 181960b8..72789f35 100644 --- a/samples/index.html +++ b/samples/index.html @@ -23,9 +23,9 @@

Animat

Webcam in worker

Reading a VideoFrame Stream coming from a webcam, in a worker context.

-
-

Sample Demo 4

-

A sample that shows cool WebCodecs stuff.

+
+

Capture To File

+

Reading from camera, compressing via webcodecs and creating a webm file on disk.