diff --git a/package-lock.json b/package-lock.json index 20f146b..ce34c78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5293,8 +5293,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -5315,14 +5314,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5337,20 +5334,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -5467,8 +5461,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -5480,7 +5473,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -5495,7 +5487,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -5503,14 +5494,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -5529,7 +5518,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -5610,8 +5598,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -5623,7 +5610,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -5709,8 +5695,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -5746,7 +5731,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5766,7 +5750,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5810,14 +5793,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, @@ -5869,6 +5850,11 @@ "assert-plus": "^1.0.0" } }, + "gif-writer": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/gif-writer/-/gif-writer-0.9.3.tgz", + "integrity": "sha1-0nbwlRBKMqC557tl4Mn9QPs1bQ8=" + }, "glob": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", @@ -11626,6 +11612,16 @@ "errno": "~0.1.7" } }, + "worker-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz", + "integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==", + "dev": true, + "requires": { + "loader-utils": "^1.0.0", + "schema-utils": "^0.4.0" + } + }, "wrap-ansi": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", diff --git a/package.json b/package.json index 74ed178..4fdec37 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "lint": "vue-cli-service lint" }, "dependencies": { + "gif-writer": "^0.9.3", "register-service-worker": "^1.6.2", "vue": "^2.6.6", "vuex": "^3.0.1" @@ -21,6 +22,7 @@ "babel-eslint": "^10.0.1", "eslint": "^5.8.0", "eslint-plugin-vue": "^5.0.0", - "vue-template-compiler": "^2.5.21" + "vue-template-compiler": "^2.5.21", + "worker-loader": "^2.0.0" } } diff --git a/src/components/encoding.vue b/src/components/encoding.vue index 3889001..383f2e1 100644 --- a/src/components/encoding.vue +++ b/src/components/encoding.vue @@ -27,10 +27,6 @@ export default { }, mounted: function () { this.makeLoading() - window.setTimeout(() => { - this.$store.commit('stopEncoding') - this.$store.commit('startDownloading') - }, 2000) }, destroyed: function () { window.clearTimeout(this.interval) diff --git a/src/services/capture.js b/src/services/capture.js new file mode 100644 index 0000000..caedde8 --- /dev/null +++ b/src/services/capture.js @@ -0,0 +1,73 @@ +import { + makeRectangle, + crop +} from './rectangle.js' + +const FRAMES_PER_SECOND = 10 +const WIDTH = 200 +const HEIGHT = WIDTH + +const video = document.createElement('video') + +const canvas = document.createElement('canvas') +const canvasContext = canvas.getContext('2d') + +canvas.width = WIDTH +canvas.height = HEIGHT + +export function capture (commit, mediaStream, duration) { + return new Promise((resolve, reject) => { + const totalFrames = duration / 1000 * FRAMES_PER_SECOND + + if (totalFrames < 1) { + resolve([]) + } + + const delayTime = 1000 / FRAMES_PER_SECOND + + video.srcObject = mediaStream + + const soureRectangle = crop(makeRectangle(0, 0, video.videoWidth, video.videoHeight)) + const destinationRectangle = makeRectangle(0, 0, canvas.width, canvas.height) + + const imageDataList = [] + + const intervalId = setInterval(() => { + if (imageDataList.length < totalFrames) { + console.log(`Capturing frame ${imageDataList.length} / ${totalFrames}`) + + canvasContext.drawImage( + video, + soureRectangle.x, + soureRectangle.y, + soureRectangle.width, + soureRectangle.height, + destinationRectangle.x, + destinationRectangle.y, + destinationRectangle.width, + destinationRectangle.height + ) + + const imageData = canvasContext.getImageData( + destinationRectangle.x, + destinationRectangle.y, + destinationRectangle.width, + destinationRectangle.height + ) + + imageDataList.push(imageData) + + commit('updateCaptureState', imageDataList.length / totalFrames * 100) + } else { + clearInterval(intervalId) + + resolve({ + imageDataList, + imageWidth: WIDTH, + imageHeight: HEIGHT, + delayTime + }) + } + }, delayTime) + }) +} diff --git a/src/services/encode.js b/src/services/encode.js new file mode 100644 index 0000000..0939e47 --- /dev/null +++ b/src/services/encode.js @@ -0,0 +1,38 @@ +import EncodeWorker from './encode.worker.js' + +const PALETTE_SIZE = 255 + +export function encode (imageDataList, imageWidth, imageHeight, paletteSize, delayTime) { + return new Promise((resolve, reject) => { + const worker = new EncodeWorker() + + worker.onerror = error => reject(error) + + worker.onmessage = event => { + const { type, payload } = event.data + + switch (type) { + default: + reject(new Error(`Unexpected EncodeWorker message with type ${type}`)) + break + + case 'progress': + console.log(`Encoding progress : ${payload.value}`) + break + + case 'done': + const dataUrl = 'data:image/gif;base64,' + btoa(payload.buffer.map((b) => String.fromCharCode(b)).join('')) + resolve(dataUrl) + break + } + } + + worker.postMessage({ + imageDataList, + imageWidth, + imageHeight, + paletteSize: PALETTE_SIZE, + delayTime + }) + }) +} diff --git a/src/services/encode.worker.js b/src/services/encode.worker.js new file mode 100644 index 0000000..7ec2125 --- /dev/null +++ b/src/services/encode.worker.js @@ -0,0 +1,104 @@ +import { + GifWriter, + MedianCutColorReducer, + IndexedColorImage +} from 'gif-writer' + +onmessage = (event) => { + console.log(event.data) + const { imageDataList, imageWidth, imageHeight, paletteSize, delayTime } = event.data + + console.log('Write GIF') + + const outputStream = new OutputStream() + const writer = new GifWriter(outputStream) + + postProgressMessage(0) + + console.log(`Write header`) + writer.writeHeader() + + console.log(`Write logical screen informations`) + writer.writeLogicalScreenInfo({ + width: imageWidth, + height: imageHeight + }) + + writer.writeLoopControlInfo(0) + + const indexedColorImages = imageDataList.map((imageData, index, { length }) => { + console.log(`Convert frame ${index} ImageData to IndexedColorImage`) + const indexedColorImage = imageDataToIndexedColorImage(imageData, paletteSize) + postProgressMessage(calcProgress(0, 0.9, length, index + 1)) + return indexedColorImage + }) + + indexedColorImages.forEach((indexedColorImage, index, { length }) => { + console.log(`Write frame IndexedColorImage ${index}`) + writer.writeTableBasedImageWithGraphicControl(indexedColorImage, { delayTimeInMS: delayTime }) + postProgressMessage(calcProgress(0.9, 1, length, index + 1)) + }) + + console.log(`Write trailer`) + writer.writeTrailer() + + postDoneMessage(outputStream.buffer) +} + +class OutputStream { + constructor () { + this.buffer = [] + } + + writeByte (b) { + this.buffer.push(b) + } + + writeBytes (bb) { + Array.prototype.push.apply(this.buffer, bb) + } +} + +function imageDataToIndexedColorImage (imageData, paletteSize) { + var reducer = new MedianCutColorReducer(imageData, paletteSize) + var paletteData = reducer.process() + var dat = Array.prototype.slice.call(imageData.data) + + var indexedColorImageData = [] + + for (var idx = 0, len = dat.length; idx < len; idx += 4) { + var d = dat.slice(idx, idx + 4) // r,g,b,a + indexedColorImageData.push(reducer.map(d[0], d[1], d[2])) + } + + return new IndexedColorImage( + { + width: imageData.width, + height: imageData.height + }, + indexedColorImageData, + paletteData + ) +} + +function calcProgress (from, to, steps, current) { + return from + ((to - from) / steps * current) +} + +function postProgressMessage (value) { + postMessage({ + type: 'progress', + payload: { + value + } + }) +} + +function postDoneMessage (buffer) { + postMessage({ + type: 'done', + payload: { + buffer + } + }) +} diff --git a/src/services/rectangle.js b/src/services/rectangle.js new file mode 100644 index 0000000..a433046 --- /dev/null +++ b/src/services/rectangle.js @@ -0,0 +1,18 @@ +export function makeRectangle (x, y, width, height) { + return { + x, + y, + width, + height + } +} + +export function crop ({ x, y, width: w, height: h }) { + if (w < h) { + return makeRectangle((h - w) / 2, y, h, h) + } else if (w > h) { + return makeRectangle((w - h) / 2, y, h, h) + } else { + return makeRectangle(x, y, w, h) + } +} diff --git a/src/store.js b/src/store.js index 6a6dd71..9d4e56f 100644 --- a/src/store.js +++ b/src/store.js @@ -1,6 +1,9 @@ import Vue from 'vue' import Vuex from 'vuex' +import { capture } from './services/capture.js' +import { encode } from './services/encode.js' + Vue.use(Vuex) export default new Vuex.Store({ @@ -22,7 +25,7 @@ export default new Vuex.Store({ } }, mutations: { - updateMediaStream(store, mediaStream) { + updateMediaStream (store, mediaStream) { if (store.mediaStream) { store.mediaStream.getTracks().forEach(track => track.stop()) } @@ -61,6 +64,34 @@ export default new Vuex.Store({ commit('updateMediaStream', mediaStream) }) .catch(error => console.error(error)) + }, + capture ({ commit, dispatch, state }) { + commit('startCapture') + + capture(commit, state.mediaStream, state.timer.selected * 1000) + .then(captureData => { + commit('stopCapture') + commit('updateCaptureState', 0) + dispatch('encode', captureData) + }) + .catch(error => console.error(error)) + }, + encode ({ commit }, captureData) { + commit('startEncoding') + + console.log(captureData) + + encode(captureData) + .then(clipDataUrl => { + commit('stopEncoding') + commit('startDownloading') + console.log(clipDataUrl) + }) + .catch(error => { + console.error(error) + commit('stopEncoding') + commit('startDownloading') + }) } } }) diff --git a/src/views/capture.vue b/src/views/capture.vue index caeef18..764ebc5 100644 --- a/src/views/capture.vue +++ b/src/views/capture.vue @@ -39,25 +39,11 @@ export default { }, methods: { startCapture () { - this.$store.commit('startCapture') - this.fakeCapture() - }, - fakeCapture () { - const interval = (this.timer.selected * 1000) / 100 - const fakeProgress = window.setInterval(() => { - if (this.capturing.state < 100) { - this.$store.commit('updateCaptureState', this.capturing.state + 1) - } else { - window.clearInterval(fakeProgress) - this.$store.commit('stopCapture') - this.$store.commit('updateCaptureState', 0) - this.$store.commit('startEncoding') - } - }, interval) + this.$store.dispatch('capture') } }, mounted: function () { this.$refs.preview.srcObject = this.mediaStream - }, + } } diff --git a/vue.config.js b/vue.config.js index fc74b48..d426a01 100644 --- a/vue.config.js +++ b/vue.config.js @@ -8,5 +8,17 @@ module.exports = { workboxOptions: { importWorkboxFrom: 'local' } + }, + configureWebpack: { + module: { + rules: [ + { + test: /\.worker\.js$/, + use: { + loader: 'worker-loader' + } + } + ] + } } }