/**
 * 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";

var ArrayBufferDataStream;

(function(){
    /*
     * Create an ArrayBuffer of the given length and present it as a writable stream with methods
     * for writing data in different formats.
     */
    ArrayBufferDataStream = function(length) {
        this.data = new Uint8Array(length);
        this.pos = 0;
    };
    
    ArrayBufferDataStream.prototype.seek = function(offset) {
        this.pos = offset;
    };

    ArrayBufferDataStream.prototype.writeBytes = function(arr) {
        for (var 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) {
        var 
            bytes = new Uint8Array(new Float64Array([d]).buffer);
        
        for (var i = bytes.length - 1; i >= 0; i--) {
            this.writeByte(bytes[i]);
        }
    };

    ArrayBufferDataStream.prototype.writeFloatBE = function(d) {
        var 
            bytes = new Uint8Array(new Float32Array([d]).buffer);
        
        for (var i = bytes.length - 1; i >= 0; i--) {
            this.writeByte(bytes[i]);
        }
    };

    /**
     * Write an ASCII string to the stream
     */
    ArrayBufferDataStream.prototype.writeString = function(s) {
        for (var 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 RuntimeException("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 RuntimeException("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 RuntimeException("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 "ArrayBufferDataStream's pos lies beyond end of buffer";
        }
    };
}());"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 BlobBuffer(fileWriter) {
    var
        buffer = [],
        writePromise = Promise.resolve();
    
    // 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) {
            var 
                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) {
        var 
            result = data.byteLength || data.length || data.size;
        
        if (!Number.isInteger(result)) {
            throw "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 "Offset may not be negative";
        }
        
        if (isNaN(offset)) {
            throw "Offset may not be NaN";
        }
        
        if (offset > this.length) {
            throw "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) {
        var
            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 (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 (var i = 0; i < buffer.length; i++) {
                    var
                        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 RuntimeException("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 (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() {
                var 
                    result = [];
                
                for (var i = 0; i < buffer.length; i++) {
                    result.push(buffer[i].data);
                }
                
                return new Blob(result, {mimeType:mimeType});
            });
        }
        
        return writePromise;
    };
}/**
 * 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";

var 
    WebMWriter;

(function() {
    function extend(base, top) {
        var 
            target = {};
        
        [base, top].forEach(function(obj) {
            for (var 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. 
     * 
     * Returns the binary string, or false if the URL could not be decoded.
     */
    function decodeBase64WebPDataURL(url) {
        if (typeof url !== "string" || !url.match(/^data:image\/webp;base64,/i)) {
            return false;
        }
        
        return window.atob(url.substring("data:image\/webp;base64,".length));
    }
    
    /**
     * Convert a raw binary string (one character = one output byte) to an ArrayBuffer
     */
    function stringToArrayBuffer(string) {
        var 
            buffer = new ArrayBuffer(string.length),
            int8Array = new Uint8Array(buffer);
        
        for (var i = 0; i < string.length; i++) {
            int8Array[i] = string.charCodeAt(i);
        }
        
        return buffer;
    }
    
    /**
     * Convert the given canvas to a WebP encoded image and return the image data as a string.
     */
    function renderAsWebP(canvas, quality) {
        var
            frame = canvas.toDataURL('image/webp', {quality: quality});
        
        return decodeBase64WebPDataURL(frame);
    }
    
    function extractKeyframeFromWebP(webP) {
        // Assume that Chrome will generate a Simple Lossy WebP which has this header:
        var
            keyframeStartIndex = webP.indexOf('VP8 ');
        
        if (keyframeStartIndex == -1) {
            throw "Failed to identify beginning of keyframe in WebP image";
        }
        
        // Skip the header and the 4 bytes that encode the length of the VP8 chunk
        keyframeStartIndex += 'VP8 '.length + 4;
        
        return webP.substring(keyframeStartIndex);
    }
    
    // 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. 
     * 
     * The buffer's first byte is at bufferFileOffset 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.
     */
    function writeEBML(buffer, bufferFileOffset, ebml) {
        // Is the ebml an array of sibling elements?
        if (Array.isArray(ebml)) {
            for (var 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
                
                var
                    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 "Bad EBML datatype " + typeof ebml.data;
            }
        } else {
            throw "Bad EBML datatype " + typeof ebml.data;
        }
    }
    
    WebMWriter = function(options) {
        var
            MAX_CLUSTER_DURATION_MSEC = 5000,
            DEFAULT_TRACK_NUMBER = 1,
        
            writtenHeader = false,
            videoWidth, videoHeight,
            
            clusterFrameBuffer = [],
            clusterStartTime = 0,
            clusterDuration = 0,
            
            optionDefaults = {
                quality: 0.95,       // WebM image quality from 0.0 (worst) to 1.0 (best)
                fileWriter: null,    // FileWriter in order to stream to a file 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
            },
            
            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,
            segmentDuration = {
                "id": 0x4489, // Duration
                "data": new EBMLFloat64(0)
            },
            
            seekHead,
            
            cues = [],
            
            blobBuffer = new BlobBuffer(options.fileWriter);

        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() {
            var
                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 (var name in seekPoints) {
                var 
                    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();
            
            var
                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
                    ]
                },
                
                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": [
                                        {
                                            "id": 0xb0, // PixelWidth
                                            "data": videoWidth
                                        },
                                        {
                                            "id": 0xba, // PixelHeight
                                            "data": videoHeight
                                        }
                                    ]
                                }
                            ]
                        }
                    ]
                };
            
            ebmlSegment = {
                "id": 0x18538067, // Segment
                "size": -1, // Unbounded size
                "data": [
                    seekHead,
                    segmentInfo,
                    tracks,
                ]
            };
            
            var
                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);
        };
        
        /**
         * Create a SimpleBlock keyframe header using these fields:
         *     timecode    - Time of this keyframe
         *     trackNumber - Track number from 1 to 126 (inclusive)
         *     frame       - Raw frame data payload string
         * 
         * Returns an EBML element.
         */
        function createKeyframeBlock(keyframe) {
            var
                bufferStream = new ArrayBufferDataStream(1 + 2 + 1);
            
            if (!(keyframe.trackNumber > 0 && keyframe.trackNumber < 127)) {
                throw "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 a Cluster node using these fields:
         * 
         *    timecode    - Start time for the 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() {
            var 
                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
            var
                rawImageSize = 0;
            
            for (var i = 0; i < clusterFrameBuffer.length; i++) {
                rawImageSize += clusterFrameBuffer[i].frame.length;
            }
            
            var
                buffer = new ArrayBufferDataStream(rawImageSize + clusterFrameBuffer.length * 32), // Estimate 32 bytes per SimpleBlock header

                cluster = createCluster({
                    timecode: Math.round(clusterStartTime),
                });
                
            for (var i = 0; i < clusterFrameBuffer.length; i++) {
                cluster.data.push(createKeyframeBlock(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 "Missing required frameDuration or frameRate setting";
                }
            }
        }
        
        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() {
            var
                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() {
            var
                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);
        }
        
        /**
         * Add a frame to the video. Currently the frame must be a Canvas element.
         */
        this.addFrame = function(canvas) {
            if (writtenHeader) {
                if (canvas.width != videoWidth || canvas.height != videoHeight) {
                    throw "Frame size differs from previous frames";
                }
            } else {
                videoWidth = canvas.width;
                videoHeight = canvas.height;

                writeHeader();
                writtenHeader = true;
            }

            var
                webP = renderAsWebP(canvas, {quality: options.quality});
            
            if (!webP) {
                throw "Couldn't decode WebP frame, does the browser support WebP?";
            }
            
            addFrameToCluster({
                frame: extractKeyframeFromWebP(webP),
                duration: options.frameDuration
            });
        };
        
        /**
         * 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() {
            flushClusterFrameBuffer();
            
            writeCues();
            rewriteSeekHead();
            rewriteDuration();
            
            return blobBuffer.complete('video/webm');
        };
        
        this.getWrittenSize = function() {
            return blobBuffer.length;
        };

        options = extend(optionDefaults, options || {});
        validateOptions();
    }
})();