diff options
| author | Jules Laplace <julescarbon@gmail.com> | 2018-05-21 21:18:35 +0200 |
|---|---|---|
| committer | Jules Laplace <julescarbon@gmail.com> | 2018-05-21 21:18:35 +0200 |
| commit | 4711e2988c91bdf0a70525ffe18040d1b8158b9a (patch) | |
| tree | b91f62bb63c9c04db1d1f32173539c6ce3bd9c4a /app/client/live | |
| parent | af3bed7b5b1b01531e3bb34c56612c37717e06b7 (diff) | |
add saving to disk
Diffstat (limited to 'app/client/live')
| -rw-r--r-- | app/client/live/index.js | 28 | ||||
| -rw-r--r-- | app/client/live/player.js | 70 | ||||
| -rw-r--r-- | app/client/live/reducer.js | 37 | ||||
| -rw-r--r-- | app/client/live/whammy.js | 567 |
4 files changed, 698 insertions, 4 deletions
diff --git a/app/client/live/index.js b/app/client/live/index.js index 3a5dbd4..ce0be14 100644 --- a/app/client/live/index.js +++ b/app/client/live/index.js @@ -8,6 +8,8 @@ import Slider from '../common/slider.component' import Select from '../common/select.component' import Button from '../common/button.component' +import { startRecording, stopRecording, saveFrame } from './player' + import * as liveActions from './actions' class App extends Component { @@ -20,7 +22,8 @@ class App extends Component { this.changeEpoch = this.changeEpoch.bind(this) this.changeSequence = this.changeSequence.bind(this) this.seek = this.seek.bind(this) - this.toggle = this.toggle.bind(this) + this.togglePlaying = this.togglePlaying.bind(this) + this.toggleRecording = this.toggleRecording.bind(this) } componentWillUpdate(nextProps) { if (nextProps.opt.checkpoint_name && nextProps.opt.checkpoint_name !== this.props.opt.checkpoint_name) { @@ -41,13 +44,20 @@ class App extends Component { const frame = Math.floor(percentage * (parseInt(this.props.frame.sequence_len) || 1) + 1) this.props.actions.seek(frame) } - toggle(){ + togglePlaying(){ if (this.props.opt.processing) { this.props.actions.pause() } else { this.props.actions.play() } } + toggleRecording(){ + if (this.props.opt.recording){ + stopRecording() + } else { + startRecording() + } + } render(){ return ( <div className='app'> @@ -89,10 +99,22 @@ class App extends Component { /> <Button title={'Processing: ' + (this.props.opt.processing ? 'yes' : 'no')} - onClick={this.toggle} + onClick={this.togglePlaying} > {this.props.opt.processing ? 'Pause' : 'Restart'} </Button> + <Button + title={'Record video'} + onClick={this.toggleRecording} + > + {this.props.opt.recording ? 'Recording' : 'Record'} + </Button> + <Button + title={'Save frame'} + onClick={saveFrame} + > + Save + </Button> </ParamGroup> </div> <div className='column'> diff --git a/app/client/live/player.js b/app/client/live/player.js new file mode 100644 index 0000000..b7088f5 --- /dev/null +++ b/app/client/live/player.js @@ -0,0 +1,70 @@ +import { store } from '../store' +import Whammy from './whammy' + +let fps = 0, last_frame +let recording = false, saving = false +let videoWriter + +export function startRecording(){ + videoWriter = new Whammy.Video(10) + recording = true + store.dispatch({ + type: 'START_RECORDING', + }) +} + +export function stopRecording(){ + recording = false + videoWriter.compile(false, function(blob){ + console.log(blob) + store.dispatch({ + type: 'SAVE_VIDEO', + blob: blob, + }) + }) +} + +export function saveFrame(){ + saving = true +} + +export function onFrame (data) { + const blob = new Blob([data.frame], { type: 'image/jpg' }) + const url = URL.createObjectURL(blob) + const img = new Image () + img.onload = function() { + last_frame = data.meta + URL.revokeObjectURL(url) + const canvas = document.querySelector('.player canvas') + const ctx = canvas.getContext('2d') + ctx.drawImage(img, 0, 0, canvas.width, canvas.height) + if (recording) { + console.log('record frame') + videoWriter.add(canvas) + } + if (saving) { + saving = false + canvas.toBlob(function(blob) { + store.dispatch({ + type: 'SAVE_FRAME', + blob: blob, + }) + }) + } + fps += 1 + } + img.src = url +} + +setInterval(() => { + store.dispatch({ + type: 'SET_FPS', + fps: fps, + }) + store.dispatch({ + type: 'CURRENT_FRAME', + meta: last_frame, + }) + fps = 0 +}, 1000) + diff --git a/app/client/live/reducer.js b/app/client/live/reducer.js index e056808..a043df3 100644 --- a/app/client/live/reducer.js +++ b/app/client/live/reducer.js @@ -1,11 +1,13 @@ import { combineReducers } from 'redux' +import moment from 'moment' +let FileSaver = require('file-saver') const liveInitialState = { loading: false, error: null, opt: {}, checkpoints: [], - checkpoint_dir: ['latest'], + epochs: ['latest'], sequences: [], fps: 0, frame: { i: 0, sequence_i: 0, sequence_len: '1' } @@ -68,6 +70,39 @@ const liveReducer = (state = liveInitialState, action) => { frame: action.meta } : state + case 'START_RECORDING': + return { + ...state, + opt: { + ...state.opt, + recording: true, + } + } + + case 'SAVE_FRAME': + FileSaver.saveAs( + action.blob, + state.opt.checkpoint_name + "_" + + state.opt.sequence + "_" + + moment().format("YYYYMMDD_HHmm") + ".png" + ) + return state + + case 'SAVE_VIDEO': + FileSaver.saveAs( + action.blob, + state.opt.checkpoint_name + "_" + + state.opt.sequence + "_" + + moment().format("YYYYMMDD_HHmm") + ".webm" + ) + return { + ...state, + opt: { + ...state.opt, + recording: false, + } + } + default: return state } diff --git a/app/client/live/whammy.js b/app/client/live/whammy.js new file mode 100644 index 0000000..0b460cc --- /dev/null +++ b/app/client/live/whammy.js @@ -0,0 +1,567 @@ +module.exports = (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 need to have durations here."; + if(frame.canvas){ //CanvasRenderingContext2D + frame = frame.canvas; + } + if(frame.toDataURL){ + // frame = frame.toDataURL('image/webp', this.quality); + // quickly store image data so we don't block cpu. encode in compile method. + frame = frame.getContext('2d').getImageData(0, 0, frame.width, frame.height); + }else if(typeof frame != "string"){ + throw "frame must be a a HTMLCanvasElement, a CanvasRenderingContext2D or a DataURI formatted string" + } + if (typeof frame === "string" && !(/^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 + }); + }; + + // deferred webp encoding. Draws image data to canvas, then encodes as dataUrl + WhammyVideo.prototype.encodeFrames = function(callback){ + + if(this.frames[0].image instanceof ImageData){ + + var frames = this.frames; + var tmpCanvas = document.createElement('canvas'); + var tmpContext = tmpCanvas.getContext('2d'); + tmpCanvas.width = this.frames[0].image.width; + tmpCanvas.height = this.frames[0].image.height; + + var encodeFrame = function(index){ + console.log('encodeFrame', index); + var frame = frames[index]; + tmpContext.putImageData(frame.image, 0, 0); + frame.image = tmpCanvas.toDataURL('image/webp', this.quality); + if(index < frames.length-1){ + setTimeout(function(){ encodeFrame(index + 1); }, 1); + }else{ + callback(); + } + }.bind(this); + + encodeFrame(0); + }else{ + callback(); + } + }; + + WhammyVideo.prototype.compile = function(outputAsArray, callback){ + + this.encodeFrames(function(){ + + var webm = new toWebM(this.frames.map(function(frame){ + var webp = parseWebP(parseRIFF(atob(frame.image.slice(23)))); + webp.duration = frame.duration; + return webp; + }), outputAsArray); + callback(webm); + + }.bind(this)); + }; + + 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 + } +})()
\ No newline at end of file |
