// https://github.com/antimatter15/whammy/tree/4effe219137e48787f1e82c8bbc64fbf7b4cfeeb /* var vid = new Whammy.Video(); vid.add(canvas or data url) vid.compile() */ window.Whammy = (function(){ // in this case, frames has a very specific meaning, which will be // detailed once i finish writing the code function toWebM(frames, outputAsArray){ var info = checkFrames(frames); //max duration by cluster in milliseconds var CLUSTER_MAX_DURATION = 30000; var EBML = [ { "id": 0x1a45dfa3, // EBML "data": [ { "data": 1, "id": 0x4286 // EBMLVersion }, { "data": 1, "id": 0x42f7 // EBMLReadVersion }, { "data": 4, "id": 0x42f2 // EBMLMaxIDLength }, { "data": 8, "id": 0x42f3 // EBMLMaxSizeLength }, { "data": "webm", "id": 0x4282 // DocType }, { "data": 2, "id": 0x4287 // DocTypeVersion }, { "data": 2, "id": 0x4285 // DocTypeReadVersion } ] }, { "id": 0x18538067, // Segment "data": [ { "id": 0x1549a966, // Info "data": [ { "data": 1e6, //do things in millisecs (num of nanosecs for duration scale) "id": 0x2ad7b1 // TimecodeScale }, { "data": "whammy", "id": 0x4d80 // MuxingApp }, { "data": "whammy", "id": 0x5741 // WritingApp }, { "data": doubleToString(info.duration), "id": 0x4489 // Duration } ] }, { "id": 0x1654ae6b, // Tracks "data": [ { "id": 0xae, // TrackEntry "data": [ { "data": 1, "id": 0xd7 // TrackNumber }, { "data": 1, "id": 0x73c5 // TrackUID }, { "data": 0, "id": 0x9c // FlagLacing }, { "data": "und", "id": 0x22b59c // Language }, { "data": "V_VP8", "id": 0x86 // CodecID }, { "data": "VP8", "id": 0x258688 // CodecName }, { "data": 1, "id": 0x83 // TrackType }, { "id": 0xe0, // Video "data": [ { "data": info.width, "id": 0xb0 // PixelWidth }, { "data": info.height, "id": 0xba // PixelHeight } ] } ] } ] }, { "id": 0x1c53bb6b, // Cues "data": [ //cue insertion point ] } //cluster insertion point ] } ]; var segment = EBML[1]; var cues = segment.data[2]; //Generate clusters (max duration) var frameNumber = 0; var clusterTimecode = 0; while(frameNumber < frames.length){ var cuePoint = { "id": 0xbb, // CuePoint "data": [ { "data": Math.round(clusterTimecode), "id": 0xb3 // CueTime }, { "id": 0xb7, // CueTrackPositions "data": [ { "data": 1, "id": 0xf7 // CueTrack }, { "data": 0, // to be filled in when we know it "size": 8, "id": 0xf1 // CueClusterPosition } ] } ] }; cues.data.push(cuePoint); var clusterFrames = []; var clusterDuration = 0; do { clusterFrames.push(frames[frameNumber]); clusterDuration += frames[frameNumber].duration; frameNumber++; }while(frameNumber < frames.length && clusterDuration < CLUSTER_MAX_DURATION); var clusterCounter = 0; var cluster = { "id": 0x1f43b675, // Cluster "data": [ { "data": Math.round(clusterTimecode), "id": 0xe7 // Timecode } ].concat(clusterFrames.map(function(webp){ var block = makeSimpleBlock({ discardable: 0, frame: webp.data.slice(4), invisible: 0, keyframe: 1, lacing: 0, trackNum: 1, timecode: Math.round(clusterCounter) }); clusterCounter += webp.duration; return { data: block, id: 0xa3 }; })) } //Add cluster to segment segment.data.push(cluster); clusterTimecode += clusterDuration; } //First pass to compute cluster positions var position = 0; for(var i = 0; i < segment.data.length; i++){ if (i >= 3) { cues.data[i-3].data[1].data[1].data = position; } var data = generateEBML([segment.data[i]], outputAsArray); position += data.size || data.byteLength || data.length; if (i != 2) { // not cues //Save results to avoid having to encode everything twice segment.data[i] = data; } } return generateEBML(EBML, outputAsArray) } // sums the lengths of all the frames and gets the duration, woo function checkFrames(frames){ var width = frames[0].width, height = frames[0].height, duration = frames[0].duration; for(var i = 1; i < frames.length; i++){ if(frames[i].width != width) throw "Frame " + (i + 1) + " has a different width"; if(frames[i].height != height) throw "Frame " + (i + 1) + " has a different height"; if(frames[i].duration < 0 || frames[i].duration > 0x7fff) throw "Frame " + (i + 1) + " has a weird duration (must be between 0 and 32767)"; duration += frames[i].duration; } return { duration: duration, width: width, height: height }; } function numToBuffer(num){ var parts = []; while(num > 0){ parts.push(num & 0xff) num = num >> 8 } return new Uint8Array(parts.reverse()); } function numToFixedBuffer(num, size){ var parts = new Uint8Array(size); for(var i = size - 1; i >= 0; i--){ parts[i] = num & 0xff; num = num >> 8; } return parts; } function strToBuffer(str){ // return new Blob([str]); var arr = new Uint8Array(str.length); for(var i = 0; i < str.length; i++){ arr[i] = str.charCodeAt(i) } return arr; // this is slower // return new Uint8Array(str.split('').map(function(e){ // return e.charCodeAt(0) // })) } //sorry this is ugly, and sort of hard to understand exactly why this was done // at all really, but the reason is that there's some code below that i dont really // feel like understanding, and this is easier than using my brain. function bitsToBuffer(bits){ var data = []; var pad = (bits.length % 8) ? (new Array(1 + 8 - (bits.length % 8))).join('0') : ''; bits = pad + bits; for(var i = 0; i < bits.length; i+= 8){ data.push(parseInt(bits.substr(i,8),2)) } return new Uint8Array(data); } function generateEBML(json, outputAsArray){ var ebml = []; for(var i = 0; i < json.length; i++){ if (!('id' in json[i])){ //already encoded blob or byteArray ebml.push(json[i]); continue; } var data = json[i].data; if(typeof data == 'object') data = generateEBML(data, outputAsArray); if(typeof data == 'number') data = ('size' in json[i]) ? numToFixedBuffer(data, json[i].size) : bitsToBuffer(data.toString(2)); if(typeof data == 'string') data = strToBuffer(data); if(data.length){ var z = z; } var len = data.size || data.byteLength || data.length; var zeroes = Math.ceil(Math.ceil(Math.log(len)/Math.log(2))/8); var size_str = len.toString(2); var padded = (new Array((zeroes * 7 + 7 + 1) - size_str.length)).join('0') + size_str; var size = (new Array(zeroes)).join('0') + '1' + padded; //i actually dont quite understand what went on up there, so I'm not really //going to fix this, i'm probably just going to write some hacky thing which //converts that string into a buffer-esque thing ebml.push(numToBuffer(json[i].id)); ebml.push(bitsToBuffer(size)); ebml.push(data) } //output as blob or byteArray if(outputAsArray){ //convert ebml to an array var buffer = toFlatArray(ebml) return new Uint8Array(buffer); }else{ return new Blob(ebml, {type: "video/webm"}); } } function toFlatArray(arr, outBuffer){ if(outBuffer == null){ outBuffer = []; } for(var i = 0; i < arr.length; i++){ if(typeof arr[i] == 'object'){ //an array toFlatArray(arr[i], outBuffer) }else{ //a simple element outBuffer.push(arr[i]); } } return outBuffer; } //OKAY, so the following two functions are the string-based old stuff, the reason they're //still sort of in here, is that they're actually faster than the new blob stuff because //getAsFile isn't widely implemented, or at least, it doesn't work in chrome, which is the // only browser which supports get as webp //Converting between a string of 0010101001's and binary back and forth is probably inefficient //TODO: get rid of this function function toBinStr_old(bits){ var data = ''; var pad = (bits.length % 8) ? (new Array(1 + 8 - (bits.length % 8))).join('0') : ''; bits = pad + bits; for(var i = 0; i < bits.length; i+= 8){ data += String.fromCharCode(parseInt(bits.substr(i,8),2)) } return data; } function generateEBML_old(json){ var ebml = ''; for(var i = 0; i < json.length; i++){ var data = json[i].data; if(typeof data == 'object') data = generateEBML_old(data); if(typeof data == 'number') data = toBinStr_old(data.toString(2)); var len = data.length; var zeroes = Math.ceil(Math.ceil(Math.log(len)/Math.log(2))/8); var size_str = len.toString(2); var padded = (new Array((zeroes * 7 + 7 + 1) - size_str.length)).join('0') + size_str; var size = (new Array(zeroes)).join('0') + '1' + padded; ebml += toBinStr_old(json[i].id.toString(2)) + toBinStr_old(size) + data; } return ebml; } //woot, a function that's actually written for this project! //this parses some json markup and makes it into that binary magic //which can then get shoved into the matroska comtainer (peaceably) function makeSimpleBlock(data){ var flags = 0; if (data.keyframe) flags |= 128; if (data.invisible) flags |= 8; if (data.lacing) flags |= (data.lacing << 1); if (data.discardable) flags |= 1; if (data.trackNum > 127) { throw "TrackNumber > 127 not supported"; } var out = [data.trackNum | 0x80, data.timecode >> 8, data.timecode & 0xff, flags].map(function(e){ return String.fromCharCode(e) }).join('') + data.frame; return out; } // here's something else taken verbatim from weppy, awesome rite? function parseWebP(riff){ var VP8 = riff.RIFF[0].WEBP[0]; var frame_start = VP8.indexOf('\x9d\x01\x2a'); //A VP8 keyframe starts with the 0x9d012a header for(var i = 0, c = []; i < 4; i++) c[i] = VP8.charCodeAt(frame_start + 3 + i); var width, horizontal_scale, height, vertical_scale, tmp; //the code below is literally copied verbatim from the bitstream spec tmp = (c[1] << 8) | c[0]; width = tmp & 0x3FFF; horizontal_scale = tmp >> 14; tmp = (c[3] << 8) | c[2]; height = tmp & 0x3FFF; vertical_scale = tmp >> 14; return { width: width, height: height, data: VP8, riff: riff } } // i think i'm going off on a riff by pretending this is some known // idiom which i'm making a casual and brilliant pun about, but since // i can't find anything on google which conforms to this idiomatic // usage, I'm assuming this is just a consequence of some psychotic // break which makes me make up puns. well, enough riff-raff (aha a // rescue of sorts), this function was ripped wholesale from weppy function parseRIFF(string){ var offset = 0; var chunks = {}; while (offset < string.length) { var id = string.substr(offset, 4); chunks[id] = chunks[id] || []; if (id == 'RIFF' || id == 'LIST') { var len = parseInt(string.substr(offset + 4, 4).split('').map(function(i){ var unpadded = i.charCodeAt(0).toString(2); return (new Array(8 - unpadded.length + 1)).join('0') + unpadded }).join(''),2); var data = string.substr(offset + 4 + 4, len); offset += 4 + 4 + len; chunks[id].push(parseRIFF(data)); } else if (id == 'WEBP') { // Use (offset + 8) to skip past "VP8 "/"VP8L"/"VP8X" field after "WEBP" chunks[id].push(string.substr(offset + 8)); offset = string.length; } else { // Unknown chunk type; push entire payload chunks[id].push(string.substr(offset + 4)); offset = string.length; } } return chunks; } // here's a little utility function that acts as a utility for other functions // basically, the only purpose is for encoding "Duration", which is encoded as // a double (considerably more difficult to encode than an integer) function doubleToString(num){ return [].slice.call( new Uint8Array( ( new Float64Array([num]) //create a float64 array ).buffer) //extract the array buffer , 0) // convert the Uint8Array into a regular array .map(function(e){ //since it's a regular array, we can now use map return String.fromCharCode(e) // encode all the bytes individually }) .reverse() //correct the byte endianness (assume it's little endian for now) .join('') // join the bytes in holy matrimony as a string } function WhammyVideo(speed, quality){ // a more abstract-ish API this.frames = []; this.duration = 1000 / speed; this.quality = quality || 0.8; } WhammyVideo.prototype.add = function(frame, duration){ if(typeof duration != 'undefined' && this.duration) throw "you can't pass a duration if the fps is set"; if(typeof duration == 'undefined' && !this.duration) throw "if you don't have the fps set, you ned to have durations here."; if(frame.canvas){ //CanvasRenderingContext2D frame = frame.canvas; } if(frame.toDataURL){ frame = frame.toDataURL('image/webp', this.quality) }else if(typeof frame != "string"){ throw "frame must be a a HTMLCanvasElement, a CanvasRenderingContext2D or a DataURI formatted string" } if (!(/^data:image\/webp;base64,/ig).test(frame)) { throw "Input must be formatted properly as a base64 encoded DataURI of type image/webp"; } this.frames.push({ image: frame, duration: duration || this.duration }) } WhammyVideo.prototype.compile = function(outputAsArray){ return new toWebM(this.frames.map(function(frame){ var webp = parseWebP(parseRIFF(atob(frame.image.slice(23)))); webp.duration = frame.duration; return webp; }), outputAsArray) } return { Video: WhammyVideo, fromImageArray: function(images, fps, outputAsArray){ return toWebM(images.map(function(image){ var webp = parseWebP(parseRIFF(atob(image.slice(23)))) webp.duration = 1000 / fps; return webp; }), outputAsArray) }, toWebM: toWebM // expose methods of madness } })()