Last active
July 27, 2023 04:39
-
-
Save josephrocca/0d6ea26370b897cb71ef9cb3b947fcd6 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// This is a version of this file: https://w3c.github.io/webcodecs/samples/capture-to-file/webm-writer2.js | |
// With these fixed applied: https://github.com/w3c/webcodecs/issues/332#issuecomment-1077442192 | |
/** | |
* 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 { | |
self.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 { | |
self.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 = 5000, 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. | |
*/ | |
let firstCueWritten = false; | |
function writeCues() { | |
if(firstCueWritten) return; | |
firstCueWritten = true; | |
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') { | |
let frameData = new Uint8Array(frame.byteLength); | |
frame.copyTo(frameData); | |
addFrameToCluster({ | |
frame: frameData, | |
intime: frame.timestamp, | |
type: frame.type, | |
}); | |
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 { | |
self.WebMWriter = | |
WebMWriter(self.ArrayBufferDataStream, self.BlobBuffer); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment