├── .dockerignore ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── docs ├── beep.mp3 ├── bundle.js ├── index.html ├── logo.png ├── shutter.mp3 ├── style.css ├── switches.css ├── template.png ├── wasm_video_filters.wasm └── wasmbooth.wasm ├── lib ├── fps.js ├── main.js └── utils.js ├── package.json ├── public ├── bundle.js ├── index.html ├── logo.png ├── shutter.mp3 ├── style.css ├── switches.css ├── template.png └── wasmbooth.wasm ├── server.js ├── src ├── bitflags.rs ├── convolution.rs ├── filter.rs ├── image.rs ├── lib.rs └── pixel.rs └── webpack.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | /node_modules 4 | package-lock.json -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "wasmbooth" 3 | version = "0.1.0" 4 | 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wasmbooth" 3 | version = "0.1.0" 4 | authors = ["Matt Harrison "] 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | 9 | [dependencies] 10 | 11 | [profile.release] 12 | codegen-units = 1 13 | incremental = false 14 | lto = true 15 | opt-level = "z" 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:slim 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install 8 | RUN npm ci --only=production 9 | 10 | COPY . . 11 | 12 | EXPOSE 4000 13 | CMD [ "node", "server.js" ] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Matt Harrison 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![WASMBOOTH](https://raw.githubusercontent.com/mtharrison/wasmbooth/master/public/logo.png) 2 | 3 | ## Video effect booth written in Rust and WebAssembly 4 | 5 | Play with it here: https://mtharrison.github.io/wasmbooth/ 6 | 7 | ### Aim 8 | 9 | I wrote this purely to teach myself more about both Rust and WebAssembly and how to use the two together. The aim of this is definitely _not_ to show off the performance of wasm. I haven't benchmarked or compared this to a pure JS implementation but I wouldn't be surprised if it were slower because it copies all the ImageData from canvas into the wasm linear memory on every frame. Additionally it uses convolutional image processing for a few of the effects, which aren't the most efficient algorithms but are elegant and easy to write/understand. 10 | 11 | ### How it works 12 | 13 | The front end is usual HTML, CSS, JS. It streams your webcam into an offscreen video element, which is then written to a hidden canvas. On each frame we grab the image data from the canvas and write it into WebAssembly's linear memory at a pre-determined offset. We then call a WebAssembly function that will process those pixels with our chosen filters. Finally, we construct a new ImageData object and put it on a visible canvas. 14 | 15 | To capture a still, we write the visible canvas data into a premade template. 16 | 17 | The wasm module exposes 2 functions to JavaScript. One tells the module to allocate enough space to hold all our pixel data and returns a pointer, which is a simple integer offset in the wasm linear memory. The other function takes that pointer and the dimensions of the image, along with our chosen filters. 18 | 19 | - `lib` - Contains the frontend JS which will be bundled into public/bundle.js by webpack 20 | - `public` - Everything that will be served up to the browser including compiled wasm module 21 | - `src` - The Rust source code which will be compiled to wasm 22 | 23 | ### Usage 24 | 25 | To simply use the app, run the following: 26 | 27 | - `npm install --production` to install hapi (to serve the site) 28 | - `npm start` to start a server 29 | 30 | Then browse to `http://localhost:4000` 31 | 32 | If you want to change JS inside lib, you should run: 33 | 34 | - `npm install` to webpack 35 | - `npm run build-js` after to bundle the JS again 36 | 37 | If you want to change Rust, you should run: 38 | 39 | - `npm run build-wasm` to recompile the .wasm module. You will need nighty Rust and the wasm target installed for this. There's a [good explanation here](https://rust-lang-nursery.github.io/rust-wasm/setup.html) 40 | 41 | There are some Rust tests, to run them run: 42 | 43 | - `npm test` or `cargo test` 44 | 45 | #### Using Docker 46 | 47 | Build the image: 48 | 49 | - `docker build -t mtharrison/wasmbooth .` 50 | 51 | Run the image (on port 4000): 52 | 53 | - `docker run -p 4000:4000 mtharrison/wasmbooth` 54 | 55 | #### Using Docker with docker-compose 56 | 57 | ``` 58 | docker-compose up --build 59 | ``` 60 | 61 | ### Contributing 62 | 63 | PRs welcome to improve the code or approach or to add more effects, this is all about learning! I'm a newbie to both Rust and wasm so please open an issue if you think there's something I missed or could have done better. 64 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | wasmbooth: 4 | build: . 5 | restart: always 6 | ports: 7 | - 4000:4000 8 | -------------------------------------------------------------------------------- /docs/beep.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtharrison/wasmbooth/e88bc036d1bd416b53186ce6907296cae51596ec/docs/beep.mp3 -------------------------------------------------------------------------------- /docs/bundle.js: -------------------------------------------------------------------------------- 1 | !function(e){var t={};function n(i){if(t[i])return t[i].exports;var o=t[i]={i:i,l:!1,exports:{}};return e[i].call(o.exports,o,o.exports,n),o.l=!0,o.exports}n.m=e,n.c=t,n.d=function(e,t,i){n.o(e,t)||Object.defineProperty(e,t,{configurable:!1,enumerable:!0,get:i})},n.r=function(e){Object.defineProperty(e,"__esModule",{value:!0})},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=6)}([function(e,t){var n;n=function(){return this}();try{n=n||Function("return this")()||(0,eval)("this")}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){"use strict";function i(e,t,n){new Uint8Array(t,n,e.byteLength).set(new Uint8Array(e))}n.d(t,"a",function(){return i})},function(e,t,n){"use strict";n.d(t,"a",function(){return i});class i{constructor(e,t){this.lastTick=performance.now(),this.lastNotify=this.lastTick,this.interval=e,this.element=t,this.runningSum=0,this.runningSamples=0}tick(){const e=performance.now();this.runningSum+=e-this.lastTick,this.runningSamples++,this.lastTick=e,e-this.lastNotify>this.interval&&this.notify(e)}notify(e){const t=1e3/(this.runningSum/this.runningSamples);this.element.innerText=`${t.toFixed(2)}fps`,this.lastNotify=e,this.runningSamples=0,this.runningSum=0}}},function(e,t){var n,i,o=e.exports={};function a(){throw new Error("setTimeout has not been defined")}function r(){throw new Error("clearTimeout has not been defined")}function s(e){if(n===setTimeout)return setTimeout(e,0);if((n===a||!n)&&setTimeout)return n=setTimeout,setTimeout(e,0);try{return n(e,0)}catch(t){try{return n.call(null,e,0)}catch(t){return n.call(this,e,0)}}}!function(){try{n="function"==typeof setTimeout?setTimeout:a}catch(e){n=a}try{i="function"==typeof clearTimeout?clearTimeout:r}catch(e){i=r}}();var c,u=[],l=!1,d=-1;function m(){l&&c&&(l=!1,c.length?u=c.concat(u):d=-1,u.length&&f())}function f(){if(!l){var e=s(m);l=!0;for(var t=u.length;t;){for(c=u,u=[];++d1)for(var n=1;n=0&&(e._idleTimeoutId=setTimeout(function(){e._onTimeout&&e._onTimeout()},t))},n(4),t.setImmediate="undefined"!=typeof self&&self.setImmediate||void 0!==e&&e.setImmediate||this&&this.setImmediate,t.clearImmediate="undefined"!=typeof self&&self.clearImmediate||void 0!==e&&e.clearImmediate||this&&this.clearImmediate}).call(this,n(0))},function(e,t,n){"use strict";n.r(t),function(e){var t=n(2),i=n(1);const o={canvas:{video:document.getElementById("video"),hidden:document.createElement("canvas"),visible:document.getElementById("canvas"),capture:document.getElementById("capture"),template:document.getElementById("template"),ctx:{}},audio:{shutter:document.getElementById("shutter")},downloadBtn:document.getElementById("btn-download"),loading:document.getElementById("loading"),fps:document.getElementById("fps"),options:[document.getElementById("mirror_x_on"),document.getElementById("mirror_y_on"),document.getElementById("grayscale_on"),document.getElementById("outline_on"),document.getElementById("sharpen_on"),document.getElementById("invert_on"),document.getElementById("blur_on"),document.getElementById("emboss_on")]};o.canvas.hidden.width=400,o.canvas.hidden.height=400,o.canvas.ctx.hidden=o.canvas.hidden.getContext("2d"),o.canvas.ctx.visible=o.canvas.visible.getContext("2d"),o.canvas.ctx.capture=o.canvas.capture.getContext("2d"),o.canvas.ctx.capture.drawImage(o.canvas.template,0,0),o.downloadBtn.addEventListener("click",function(t){let n=3;o.loading.innerText="";const i=()=>{if(0===n)return sounds.shutter(),void e(()=>{o.downloadBtn.innerText="CAPTURE",o.canvas.visible.classList.add("flash"),o.canvas.ctx.capture.drawImage(o.canvas.visible,25,25);var e=o.canvas.capture.toDataURL("image/png");const t=document.createElement("a");t.href=e,t.download="wasmbooth.png",setTimeout(()=>o.canvas.visible.classList.remove("flash"),1e3);var n=new MouseEvent("click");t.dispatchEvent(n)});sounds.beep(),o.downloadBtn.innerText="... "+n.toString()+" ...",setTimeout(()=>{n--,i()},1e3)};i(),t.preventDefault()});navigator.mediaDevices.getUserMedia({video:!0,audio:!1}).then(function(e){o.canvas.video.srcObject=e}).catch(function(e){throw e}),window.sounds=(()=>{var e=window.AudioContext||window.webkitAudioContext||!1;if(e){var t=new e;return{beep:(()=>{const e=t.createOscillator(),n=t.createGain();return n.gain.value=0,e.type="sine",e.connect(n),e.frequency.value=830.6,n.connect(t.destination),e.start(0),()=>{n.gain.value=.1,setTimeout(()=>n.gain.value=0,150)}})(),shutter:()=>{o.audio.shutter.play()}}}return{beep:()=>{},shutter:()=>{}}})(),fetch("wasmbooth.wasm").then(e=>e.arrayBuffer()).then(e=>WebAssembly.instantiate(e)).then(({instance:e})=>{const n=e.exports.alloc_pixels(16e4),a=new t.a(250,o.fps),r=()=>{a.tick(),o.canvas.ctx.hidden.drawImage(o.canvas.video,(o.canvas.video.videoWidth-400)/2,(o.canvas.video.videoHeight-400)/2,400,400,0,0,400,400);const t=o.canvas.ctx.hidden.getImageData(0,0,400,400);i.a(t.data.buffer,e.exports.memory.buffer,n),e.exports.apply_filters(n,(()=>{let e=0;for(let[t,n]of o.options.entries())e|=n.checked?1< 2 | 3 | 4 | 5 | 6 | 7 | 8 | WASMBOOTH 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 |
18 | 19 |

WASMBOOTH

20 | 21 |
22 | 23 |
24 |
25 | 35 |
36 | 37 |
38 | 48 |
49 | 50 |
51 | 61 |
62 | 63 |
64 | 74 |
75 |
76 | 77 |
78 |

HAZ CAM?

79 | 80 | 81 | 82 | 84 | 85 |

86 |
87 | 88 |
89 |
90 | 100 |
101 | 102 |
103 | 113 |
114 | 115 |
116 | 126 |
127 | 128 |
129 | 139 |
140 |
141 | 142 |
143 | 144 |
145 | CAPTURE 146 |
147 |
148 |
149 | 150 | 151 | 158 | 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtharrison/wasmbooth/e88bc036d1bd416b53186ce6907296cae51596ec/docs/logo.png -------------------------------------------------------------------------------- /docs/shutter.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtharrison/wasmbooth/e88bc036d1bd416b53186ce6907296cae51596ec/docs/shutter.mp3 -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | /* My CSS is awful and I don't care */ 2 | 3 | html, body { 4 | height: 100%; 5 | } 6 | 7 | body { 8 | background: radial-gradient(ellipse at center, #5a46ee 0%,#4435b2 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */ 9 | padding-top: 0px; 10 | font-family: 'Open Sans', sans-serif; 11 | color: #FFFFFF; 12 | display: flex; 13 | flex-direction: column; 14 | margin: 0; 15 | } 16 | 17 | h1 { 18 | font-family: 'Bungee Shade', cursive; 19 | font-size: 46px; 20 | color: #FFFFFF; 21 | text-align: center; 22 | font-weight: normal; 23 | } 24 | 25 | #video { 26 | width: 1px; 27 | height: 1px; 28 | } 29 | 30 | .video h2 { 31 | font-family: 'Bungee Shade', cursive; 32 | font-size: 34px; 33 | color: #4435b2; 34 | text-align: center; 35 | font-weight: normal; 36 | position: absolute; 37 | top: 140px; 38 | left: 106px; 39 | z-index: 0; 40 | } 41 | 42 | .container { 43 | width: 620px; 44 | margin: auto; 45 | flex: 1 0 auto; 46 | } 47 | 48 | .content > div.top { 49 | padding-bottom: 10px; 50 | } 51 | 52 | div.top:after { 53 | content: ""; 54 | display: table; 55 | clear: both; 56 | } 57 | 58 | .container .video { 59 | position: relative; 60 | box-shadow: 6px 6px 0px 0px rgba(0,0,0, 0.5); 61 | width: 400px; 62 | height: 400px; 63 | background: #FFFFFF; 64 | float: left; 65 | padding: 10px 10px 50px; 66 | position: relative; 67 | } 68 | 69 | .video #canvas { 70 | z-index: 5; 71 | position: absolute; 72 | box-shadow: 0px 0px 2px 0px rgba(0,0,0, 0.5); 73 | } 74 | 75 | .container .video #fps { 76 | position: absolute; 77 | top: 369px; 78 | right: 10px; 79 | font-size: 13px; 80 | background: rgba(0,0,0,0.5); 81 | padding: 5px; 82 | z-index: 10; 83 | } 84 | 85 | .container .controls { 86 | float: left; 87 | width: 70px; 88 | padding: 15px; 89 | } 90 | 91 | .controls.left { 92 | text-align: right; 93 | } 94 | 95 | .controls.left .switch-material.switch-light > span { 96 | left: 20px; 97 | } 98 | 99 | .control-set-left { 100 | float: left; 101 | margin-right: 30px; 102 | } 103 | 104 | .control-set-right { 105 | float: left; 106 | } 107 | 108 | .content > div.bottom { 109 | margin-top: 15px; 110 | } 111 | 112 | .switch-option { 113 | margin: 0px 0px 15px; 114 | } 115 | 116 | .switch-option span { 117 | margin-top: 5px; 118 | outline: none; 119 | box-shadow: 1px 1px 0px 0px rgba(0,0,0,0.5) inset; 120 | } 121 | 122 | .switch-option span a { 123 | outline: none!important; 124 | box-shadow: 1px 1px 0px 0px rgba(0,0,0,0.5); 125 | } 126 | 127 | #btn-download { 128 | font-family: 'Bungee Shade', cursive; 129 | border-radius: 28px; 130 | color: #4435b2; 131 | font-size: 24px; 132 | background: #FFFFFF; 133 | padding: 10px 20px 10px 20px; 134 | text-decoration: none; 135 | box-shadow: 0px 6px 1px 0px rgba(0,0,0, 0.3); 136 | width: 140px; 137 | display:block; 138 | margin: auto; 139 | text-align: center; 140 | } 141 | 142 | #btn-download:hover { 143 | text-decoration: none; 144 | position:relative; 145 | top: 1px; 146 | } 147 | 148 | #btn-download:active { 149 | text-decoration: none; 150 | position:relative; 151 | top: 3px; 152 | } 153 | 154 | footer { 155 | left: 5px; 156 | padding-bottom: 10px; 157 | color:#FFFFFF; 158 | text-align: center; 159 | font-size: 16px; 160 | flex-shrink: 0; 161 | } 162 | 163 | footer a { 164 | color:#FFFFFF; 165 | } 166 | 167 | #template, #capture { 168 | display: none; 169 | } 170 | 171 | .flash { 172 | opacity: 1; 173 | -webkit-animation: flash 1s; 174 | animation: flash 1s; 175 | } 176 | 177 | @-webkit-keyframes flash { 178 | 0% { opacity: .3; } 179 | 100% { opacity: 1; } 180 | } 181 | @keyframes flash { 182 | 0% { opacity: .3; } 183 | 100% { opacity: 1; } 184 | } -------------------------------------------------------------------------------- /docs/switches.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | /* 3 | * CSS TOGGLE SWITCH 4 | * 5 | * Ionuț Colceriu - ghinda.net 6 | * https://github.com/ghinda/css-toggle-switch 7 | * 8 | */ 9 | /* supported values are px, rem-calc, em-calc 10 | */ 11 | /* imports 12 | */ 13 | /* Functions 14 | */ 15 | /* Shared 16 | */ 17 | /* Hide by default 18 | */ 19 | .switch-toggle a, 20 | .switch-light span span { 21 | display: none; } 22 | 23 | /* We can't test for a specific feature, 24 | * so we only target browsers with support for media queries. 25 | */ 26 | @media only screen { 27 | /* Checkbox 28 | */ 29 | .switch-light { 30 | position: relative; 31 | display: block; 32 | /* simulate default browser focus outlines on the switch, 33 | * when the inputs are focused. 34 | */ } 35 | .switch-light::after { 36 | clear: both; 37 | content: ''; 38 | display: table; } 39 | .switch-light *, 40 | .switch-light *:before, 41 | .switch-light *:after { 42 | box-sizing: border-box; } 43 | .switch-light a { 44 | display: block; 45 | transition: all 0.2s ease-out; } 46 | .switch-light label, 47 | .switch-light > span { 48 | /* breathing room for bootstrap/foundation classes. 49 | */ 50 | line-height: 2em; } 51 | .switch-light input:focus ~ span a, 52 | .switch-light input:focus + label { 53 | outline-width: 2px; 54 | outline-style: solid; 55 | outline-color: Highlight; 56 | /* Chrome/Opera gets its native focus styles. 57 | */ } } 58 | @media only screen and (-webkit-min-device-pixel-ratio: 0) { 59 | .switch-light input:focus ~ span a, 60 | .switch-light input:focus + label { 61 | outline-color: -webkit-focus-ring-color; 62 | outline-style: auto; } } 63 | 64 | @media only screen { 65 | /* don't hide the input from screen-readers and keyboard access 66 | */ 67 | .switch-light input { 68 | position: absolute; 69 | opacity: 0; 70 | z-index: 3; } 71 | .switch-light input:checked ~ span a { 72 | right: 0%; } 73 | /* inherit from label 74 | */ 75 | .switch-light strong { 76 | font-weight: inherit; } 77 | .switch-light > span { 78 | position: relative; 79 | overflow: hidden; 80 | display: block; 81 | min-height: 2em; 82 | /* overwrite 3rd party classes padding 83 | * eg. bootstrap .alert 84 | */ 85 | padding: 0; 86 | text-align: left; } 87 | .switch-light span span { 88 | position: relative; 89 | z-index: 2; 90 | display: block; 91 | float: left; 92 | width: 50%; 93 | text-align: center; 94 | user-select: none; } 95 | .switch-light a { 96 | position: absolute; 97 | right: 50%; 98 | top: 0; 99 | z-index: 1; 100 | display: block; 101 | width: 50%; 102 | height: 100%; 103 | padding: 0; } 104 | /* bootstrap 4 tweaks 105 | */ 106 | .switch-light.row { 107 | display: flex; } 108 | .switch-light .alert-light { 109 | color: #333; } 110 | /* Radio Switch 111 | */ 112 | .switch-toggle { 113 | position: relative; 114 | display: block; 115 | /* simulate default browser focus outlines on the switch, 116 | * when the inputs are focused. 117 | */ 118 | /* For callout panels in foundation 119 | */ 120 | padding: 0 !important; 121 | /* 2 items 122 | */ 123 | /* 3 items 124 | */ 125 | /* 4 items 126 | */ 127 | /* 5 items 128 | */ 129 | /* 6 items 130 | */ } 131 | .switch-toggle::after { 132 | clear: both; 133 | content: ''; 134 | display: table; } 135 | .switch-toggle *, 136 | .switch-toggle *:before, 137 | .switch-toggle *:after { 138 | box-sizing: border-box; } 139 | .switch-toggle a { 140 | display: block; 141 | transition: all 0.2s ease-out; } 142 | .switch-toggle label, 143 | .switch-toggle > span { 144 | /* breathing room for bootstrap/foundation classes. 145 | */ 146 | line-height: 2em; } 147 | .switch-toggle input:focus ~ span a, 148 | .switch-toggle input:focus + label { 149 | outline-width: 2px; 150 | outline-style: solid; 151 | outline-color: Highlight; 152 | /* Chrome/Opera gets its native focus styles. 153 | */ } } 154 | @media only screen and (-webkit-min-device-pixel-ratio: 0) { 155 | .switch-toggle input:focus ~ span a, 156 | .switch-toggle input:focus + label { 157 | outline-color: -webkit-focus-ring-color; 158 | outline-style: auto; } } 159 | 160 | @media only screen { 161 | .switch-toggle input { 162 | position: absolute; 163 | left: 0; 164 | opacity: 0; } 165 | .switch-toggle input + label { 166 | position: relative; 167 | z-index: 2; 168 | display: block; 169 | float: left; 170 | padding: 0 0.5em; 171 | margin: 0; 172 | text-align: center; } 173 | .switch-toggle a { 174 | position: absolute; 175 | top: 0; 176 | left: 0; 177 | padding: 0; 178 | z-index: 1; 179 | width: 10px; 180 | height: 100%; } 181 | .switch-toggle label:nth-child(2):nth-last-child(4), 182 | .switch-toggle label:nth-child(2):nth-last-child(4) ~ label, 183 | .switch-toggle label:nth-child(2):nth-last-child(4) ~ a { 184 | width: 50%; } 185 | .switch-toggle label:nth-child(2):nth-last-child(4) ~ input:checked:nth-child(3) + label ~ a { 186 | left: 50%; } 187 | .switch-toggle label:nth-child(2):nth-last-child(6), 188 | .switch-toggle label:nth-child(2):nth-last-child(6) ~ label, 189 | .switch-toggle label:nth-child(2):nth-last-child(6) ~ a { 190 | width: 33.33%; } 191 | .switch-toggle label:nth-child(2):nth-last-child(6) ~ input:checked:nth-child(3) + label ~ a { 192 | left: 33.33%; } 193 | .switch-toggle label:nth-child(2):nth-last-child(6) ~ input:checked:nth-child(5) + label ~ a { 194 | left: 66.66%; } 195 | .switch-toggle label:nth-child(2):nth-last-child(8), 196 | .switch-toggle label:nth-child(2):nth-last-child(8) ~ label, 197 | .switch-toggle label:nth-child(2):nth-last-child(8) ~ a { 198 | width: 25%; } 199 | .switch-toggle label:nth-child(2):nth-last-child(8) ~ input:checked:nth-child(3) + label ~ a { 200 | left: 25%; } 201 | .switch-toggle label:nth-child(2):nth-last-child(8) ~ input:checked:nth-child(5) + label ~ a { 202 | left: 50%; } 203 | .switch-toggle label:nth-child(2):nth-last-child(8) ~ input:checked:nth-child(7) + label ~ a { 204 | left: 75%; } 205 | .switch-toggle label:nth-child(2):nth-last-child(10), 206 | .switch-toggle label:nth-child(2):nth-last-child(10) ~ label, 207 | .switch-toggle label:nth-child(2):nth-last-child(10) ~ a { 208 | width: 20%; } 209 | .switch-toggle label:nth-child(2):nth-last-child(10) ~ input:checked:nth-child(3) + label ~ a { 210 | left: 20%; } 211 | .switch-toggle label:nth-child(2):nth-last-child(10) ~ input:checked:nth-child(5) + label ~ a { 212 | left: 40%; } 213 | .switch-toggle label:nth-child(2):nth-last-child(10) ~ input:checked:nth-child(7) + label ~ a { 214 | left: 60%; } 215 | .switch-toggle label:nth-child(2):nth-last-child(10) ~ input:checked:nth-child(9) + label ~ a { 216 | left: 80%; } 217 | .switch-toggle label:nth-child(2):nth-last-child(12), 218 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ label, 219 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ a { 220 | width: 16.6%; } 221 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ input:checked:nth-child(3) + label ~ a { 222 | left: 16.6%; } 223 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ input:checked:nth-child(5) + label ~ a { 224 | left: 33.2%; } 225 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ input:checked:nth-child(7) + label ~ a { 226 | left: 49.8%; } 227 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ input:checked:nth-child(9) + label ~ a { 228 | left: 66.4%; } 229 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ input:checked:nth-child(11) + label ~ a { 230 | left: 83%; } 231 | 232 | /* Material Theme 233 | */ 234 | /* switch-light 235 | */ 236 | .switch-light.switch-material a { 237 | top: -0.1875em; 238 | width: 1.75em; 239 | height: 1.75em; 240 | border-radius: 50%; 241 | background: #FFFFFF; 242 | box-shadow: 0 0.125em 0.125em 0 rgba(0, 0, 0, 0.14), 0 0.1875em 0.125em -0.125em rgba(0, 0, 0, 0.2), 0 0.125em 0.25em 0 rgba(0, 0, 0, 0.12); 243 | transition: right 0.28s cubic-bezier(0.4, 0, 0.2, 1); } 244 | .switch-material.switch-light { 245 | overflow: visible; } 246 | .switch-material.switch-light::after { 247 | clear: both; 248 | content: ''; 249 | display: table; } 250 | .switch-material.switch-light > span { 251 | overflow: visible; 252 | position: relative; 253 | top: 0.1875em; 254 | width: 3.25em; 255 | height: 1.5em; 256 | min-height: auto; 257 | border-radius: 1em; 258 | background: rgba(0, 0, 0, 0.26); } 259 | .switch-material.switch-light span span { 260 | position: absolute; 261 | clip: rect(0 0 0 0); } 262 | .switch-material.switch-light input:checked ~ span a { 263 | right: 0; 264 | /* background: #3f51b5; */ 265 | } 266 | .switch-material.switch-light input:checked ~ span { 267 | } 268 | /* switch-toggle 269 | */ 270 | .switch-toggle.switch-material { 271 | overflow: visible; } 272 | .switch-toggle.switch-material::after { 273 | clear: both; 274 | content: ''; 275 | display: table; } 276 | .switch-toggle.switch-material a { 277 | top: 48%; 278 | width: 0.375em !important; 279 | height: 0.375em; 280 | margin-left: 0.25em; 281 | background: #3f51b5; 282 | border-radius: 100%; 283 | transform: translateY(-50%); 284 | transition: transform .4s ease-in; } 285 | .switch-toggle.switch-material label { 286 | color: rgba(0, 0, 0, 0.54); 287 | font-size: 1em; } 288 | .switch-toggle.switch-material label:before { 289 | content: ''; 290 | position: absolute; 291 | top: 48%; 292 | left: 0; 293 | display: block; 294 | width: 0.875em; 295 | height: 0.875em; 296 | border-radius: 100%; 297 | border: 0.125em solid rgba(0, 0, 0, 0.54); 298 | transform: translateY(-50%); } 299 | .switch-toggle.switch-material input:checked + label:before { 300 | border-color: #3f51b5; } 301 | /* ripple 302 | */ 303 | .switch-light.switch-material > span:before, 304 | .switch-light.switch-material > span:after, 305 | .switch-toggle.switch-material label:after { 306 | content: ''; 307 | position: absolute; 308 | top: 0; 309 | left: 0; 310 | z-index: 3; 311 | display: block; 312 | width: 4em; 313 | height: 4em; 314 | border-radius: 100%; 315 | background: #3f51b5; 316 | opacity: .4; 317 | margin-left: -1.25em; 318 | margin-top: -1.25em; 319 | transform: scale(0); 320 | transition: opacity .4s ease-in; } 321 | .switch-light.switch-material > span:after { 322 | left: auto; 323 | right: 0; 324 | margin-left: 0; 325 | margin-right: -1.25em; } 326 | .switch-toggle.switch-material label:after { 327 | width: 3.25em; 328 | height: 3.25em; 329 | margin-top: -0.75em; } 330 | 331 | .switch-material.switch-light input:not(:checked) ~ span:after, 332 | .switch-material.switch-light input:checked ~ span:before, 333 | .switch-toggle.switch-material input:checked + label:after { 334 | animation: materialRipple .4s ease-in; } 335 | /* trick to prevent the default checked ripple animation from showing 336 | * when the page loads. 337 | * the ripples are hidden by default, and shown only when the input is focused. 338 | */ 339 | .switch-light.switch-material.switch-light input ~ span:before, 340 | .switch-light.switch-material.switch-light input ~ span:after, 341 | .switch-material.switch-toggle input + label:after { 342 | visibility: hidden; } 343 | .switch-light.switch-material.switch-light input:focus:checked ~ span:before, 344 | .switch-light.switch-material.switch-light input:focus:not(:checked) ~ span:after, 345 | .switch-material.switch-toggle input:focus:checked + label:after { 346 | visibility: visible; } } 347 | 348 | /* Bugfix for older Webkit, including mobile Webkit. Adapted from 349 | * http://css-tricks.com/webkit-sibling-bug/ 350 | */ 351 | @media only screen and (-webkit-max-device-pixel-ratio: 2) and (max-device-width: 80em) { 352 | .switch-light, 353 | .switch-toggle { 354 | -webkit-animation: webkitSiblingBugfix infinite 1s; } } 355 | 356 | @-webkit-keyframes webkitSiblingBugfix { 357 | from { 358 | -webkit-transform: translate3d(0, 0, 0); } 359 | to { 360 | -webkit-transform: translate3d(0, 0, 0); } } 361 | 362 | /*# sourceMappingURL=toggle-switch.css.map */ -------------------------------------------------------------------------------- /docs/template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtharrison/wasmbooth/e88bc036d1bd416b53186ce6907296cae51596ec/docs/template.png -------------------------------------------------------------------------------- /docs/wasm_video_filters.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtharrison/wasmbooth/e88bc036d1bd416b53186ce6907296cae51596ec/docs/wasm_video_filters.wasm -------------------------------------------------------------------------------- /docs/wasmbooth.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtharrison/wasmbooth/e88bc036d1bd416b53186ce6907296cae51596ec/docs/wasmbooth.wasm -------------------------------------------------------------------------------- /lib/fps.js: -------------------------------------------------------------------------------- 1 | export default class Fps { 2 | constructor(interval, element) { 3 | this.lastTick = performance.now(); 4 | this.lastNotify = this.lastTick; 5 | this.interval = interval; 6 | this.element = element; 7 | this.runningSum = 0; 8 | this.runningSamples = 0; 9 | } 10 | 11 | tick() { 12 | const now = performance.now(); 13 | this.runningSum += (now - this.lastTick); 14 | this.runningSamples++; 15 | this.lastTick = now; 16 | 17 | if ((now - this.lastNotify) > this.interval) { 18 | this.notify(now); 19 | } 20 | } 21 | 22 | notify(now) { 23 | const avgFrame = this.runningSum / this.runningSamples; 24 | const fps = 1000 / avgFrame; 25 | this.element.innerText = `${fps.toFixed(2)}fps`; 26 | this.lastNotify = now; 27 | this.runningSamples = 0; 28 | this.runningSum = 0; 29 | } 30 | } -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | import FpsIndicator from './fps'; 2 | import * as Utils from './utils'; 3 | 4 | const WIDTH = 400; 5 | const HEIGHT = 400; 6 | 7 | const elements = { 8 | canvas: { 9 | video: document.getElementById('video'), 10 | hidden: document.createElement('canvas'), 11 | visible: document.getElementById('canvas'), 12 | capture: document.getElementById('capture'), 13 | template: document.getElementById('template'), 14 | ctx: {} 15 | }, 16 | audio: { 17 | shutter: document.getElementById('shutter') 18 | }, 19 | downloadBtn: document.getElementById('btn-download'), 20 | loading: document.getElementById('loading'), 21 | fps: document.getElementById('fps'), 22 | options: [ 23 | document.getElementById('mirror_x_on'), 24 | document.getElementById('mirror_y_on'), 25 | document.getElementById('grayscale_on'), 26 | document.getElementById('outline_on'), 27 | document.getElementById('sharpen_on'), 28 | document.getElementById('invert_on'), 29 | document.getElementById('blur_on'), 30 | document.getElementById('emboss_on'), 31 | ] 32 | }; 33 | 34 | // Hidden canvas for rendering video directly onto 35 | 36 | elements.canvas.hidden.width = WIDTH; 37 | elements.canvas.hidden.height = HEIGHT; 38 | elements.canvas.ctx.hidden = elements.canvas.hidden.getContext('2d'); 39 | 40 | // Target canvas for rendering effects to 41 | 42 | elements.canvas.ctx.visible = elements.canvas.visible.getContext('2d'); 43 | 44 | // Setup target canvas for capture 45 | 46 | elements.canvas.ctx.capture = elements.canvas.capture.getContext('2d'); 47 | elements.canvas.ctx.capture.drawImage(elements.canvas.template, 0, 0); 48 | 49 | // Hook up download button 50 | 51 | elements.downloadBtn.addEventListener('click', function (e) { 52 | 53 | let countdown = 3; 54 | elements.loading.innerText = ''; 55 | 56 | const tick = () => { 57 | 58 | if (countdown === 0) { 59 | sounds.shutter(); 60 | setImmediate(() => { 61 | 62 | elements.downloadBtn.innerText = 'CAPTURE'; 63 | elements.canvas.visible.classList.add('flash'); 64 | elements.canvas.ctx.capture.drawImage(elements.canvas.visible, 25, 25); 65 | var dataURL = elements.canvas.capture.toDataURL('image/png'); 66 | const a = document.createElement('a'); 67 | a.href = dataURL; 68 | a.download = "wasmbooth.png"; 69 | setTimeout(() => elements.canvas.visible.classList.remove('flash'), 1000); 70 | 71 | var event = new MouseEvent('click'); 72 | a.dispatchEvent(event); 73 | }); 74 | 75 | return; 76 | } 77 | 78 | sounds.beep(); 79 | elements.downloadBtn.innerText = '... ' + countdown.toString() + ' ...'; 80 | 81 | setTimeout(() => { 82 | 83 | countdown--; 84 | tick(); 85 | 86 | }, 1000); 87 | }; 88 | 89 | tick(); 90 | 91 | e.preventDefault(); 92 | }); 93 | 94 | const getOptions = () => { 95 | 96 | // Create a bitset of options, an efficient way to pass options to wasm 97 | // invert: 0b00000001 98 | // mirror_y: 0b00000100 99 | // 100 | // options: 0b00000101 === 5 101 | 102 | let options = 0; 103 | 104 | for (let [i, el] of elements.options.entries()) { 105 | options |= el.checked ? (1 << i) : 0; 106 | } 107 | 108 | return options; 109 | }; 110 | 111 | // Start to capture webcam 112 | 113 | navigator.mediaDevices.getUserMedia({ video: true, audio: false }) 114 | .then(function (stream) { 115 | 116 | elements.canvas.video.srcObject = stream; 117 | }) 118 | .catch(function (err) { 119 | 120 | throw err; 121 | }); 122 | 123 | // Create a beep sound for countdown 124 | 125 | window.sounds = (() => { 126 | var AudioContext = window.AudioContext // Default 127 | || window.webkitAudioContext // Safari and old versions of Chrome 128 | || false; 129 | 130 | if (AudioContext) { 131 | var context = new AudioContext(); 132 | 133 | // Beep sound 134 | 135 | const beep = (() => { 136 | 137 | const freq = 830.6; 138 | const type = 'sine'; 139 | const osc = context.createOscillator(); 140 | const gain = context.createGain(); 141 | gain.gain.value = 0; 142 | osc.type = type; 143 | osc.connect(gain); 144 | osc.frequency.value = freq; 145 | gain.connect(context.destination); 146 | osc.start(0); 147 | 148 | return () => { 149 | gain.gain.value = 0.1; 150 | setTimeout(() => gain.gain.value = 0, 150); 151 | } 152 | })(); 153 | 154 | const shutter = () => { 155 | 156 | elements.audio.shutter.play(); 157 | }; 158 | 159 | return { beep, shutter }; 160 | } 161 | 162 | return { beep: () => { }, shutter: () => { } }; 163 | })(); 164 | 165 | 166 | fetch('wasmbooth.wasm').then((response) => 167 | response.arrayBuffer() 168 | ).then((bytes) => 169 | WebAssembly.instantiate(bytes) // support safari 170 | ).then(({ instance }) => { 171 | 172 | // Allocate enough space for pixel data in the wasm linear memory 173 | // and get a pointer to the start of the data 174 | 175 | const offset = instance.exports.alloc_pixels(WIDTH * HEIGHT); 176 | 177 | // Setup FPS indicator 178 | 179 | const fps = new FpsIndicator(250, elements.fps); 180 | 181 | const render = () => { 182 | 183 | fps.tick(); 184 | 185 | // Draw video frame to hidden canvas 186 | 187 | elements.canvas.ctx.hidden.drawImage(elements.canvas.video, (elements.canvas.video.videoWidth - WIDTH) / 2, (elements.canvas.video.videoHeight - HEIGHT) / 2, WIDTH, HEIGHT, 0, 0, WIDTH, HEIGHT); 188 | 189 | // Get the image data from the hidden canvas 190 | 191 | const imageData = elements.canvas.ctx.hidden.getImageData(0, 0, WIDTH, HEIGHT); 192 | 193 | // Copy image data into wasm linear memory 194 | 195 | Utils.memcopy(imageData.data.buffer, instance.exports.memory.buffer, offset); 196 | 197 | // Call into wasm to apply filters to image data 198 | 199 | instance.exports.apply_filters(offset, getOptions(), WIDTH, HEIGHT); 200 | 201 | // Get view onto wasm memory 202 | 203 | const data = new Uint8ClampedArray(instance.exports.memory.buffer, offset, WIDTH * HEIGHT * 4); 204 | 205 | // Create new image data object 206 | 207 | const imageDataUpdated = new ImageData(data, WIDTH, HEIGHT); 208 | 209 | // Write image data back to canvas 210 | 211 | elements.canvas.ctx.visible.putImageData(imageDataUpdated, 0, 0); 212 | 213 | // Queue another frame 214 | 215 | requestAnimationFrame(render); 216 | } 217 | 218 | render(); 219 | }); -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | export function memcopy(b1, b2, offset) { 2 | new Uint8Array(b2, offset, b1.byteLength).set(new Uint8Array(b1)); 3 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wasmbooth", 3 | "version": "1.0.0", 4 | "description": "Wasm video filter booth app written in Rust", 5 | "main": "lib/main.js", 6 | "scripts": { 7 | "test": "cargo test", 8 | "build-wasm": "cargo +nightly build --release --target wasm32-unknown-unknown && mv target/wasm32-unknown-unknown/release/wasmbooth.wasm public", 9 | "build-js": "webpack .", 10 | "build": "npm run build-wasm && npm run build-js", 11 | "build-web": "npm run build && cp -a public/. docs/", 12 | "start": "node server.js" 13 | }, 14 | "keywords": [ 15 | "wasm", 16 | "webassembly", 17 | "rust", 18 | "video" 19 | ], 20 | "author": "Matt Harrison ", 21 | "license": "MIT", 22 | "dependencies": { 23 | "hapi": "^17.3.1", 24 | "inert": "^5.1.0" 25 | }, 26 | "devDependencies": { 27 | "webpack": "^4.6.0", 28 | "webpack-cli": "^2.0.15" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /public/bundle.js: -------------------------------------------------------------------------------- 1 | !function(e){var t={};function n(i){if(t[i])return t[i].exports;var o=t[i]={i:i,l:!1,exports:{}};return e[i].call(o.exports,o,o.exports,n),o.l=!0,o.exports}n.m=e,n.c=t,n.d=function(e,t,i){n.o(e,t)||Object.defineProperty(e,t,{configurable:!1,enumerable:!0,get:i})},n.r=function(e){Object.defineProperty(e,"__esModule",{value:!0})},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=6)}([function(e,t){var n;n=function(){return this}();try{n=n||Function("return this")()||(0,eval)("this")}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){"use strict";function i(e,t,n){new Uint8Array(t,n,e.byteLength).set(new Uint8Array(e))}n.d(t,"a",function(){return i})},function(e,t,n){"use strict";n.d(t,"a",function(){return i});class i{constructor(e,t){this.lastTick=performance.now(),this.lastNotify=this.lastTick,this.interval=e,this.element=t,this.runningSum=0,this.runningSamples=0}tick(){const e=performance.now();this.runningSum+=e-this.lastTick,this.runningSamples++,this.lastTick=e,e-this.lastNotify>this.interval&&this.notify(e)}notify(e){const t=1e3/(this.runningSum/this.runningSamples);this.element.innerText=`${t.toFixed(2)}fps`,this.lastNotify=e,this.runningSamples=0,this.runningSum=0}}},function(e,t){var n,i,o=e.exports={};function a(){throw new Error("setTimeout has not been defined")}function r(){throw new Error("clearTimeout has not been defined")}function s(e){if(n===setTimeout)return setTimeout(e,0);if((n===a||!n)&&setTimeout)return n=setTimeout,setTimeout(e,0);try{return n(e,0)}catch(t){try{return n.call(null,e,0)}catch(t){return n.call(this,e,0)}}}!function(){try{n="function"==typeof setTimeout?setTimeout:a}catch(e){n=a}try{i="function"==typeof clearTimeout?clearTimeout:r}catch(e){i=r}}();var c,u=[],l=!1,d=-1;function m(){l&&c&&(l=!1,c.length?u=c.concat(u):d=-1,u.length&&f())}function f(){if(!l){var e=s(m);l=!0;for(var t=u.length;t;){for(c=u,u=[];++d1)for(var n=1;n=0&&(e._idleTimeoutId=setTimeout(function(){e._onTimeout&&e._onTimeout()},t))},n(4),t.setImmediate="undefined"!=typeof self&&self.setImmediate||void 0!==e&&e.setImmediate||this&&this.setImmediate,t.clearImmediate="undefined"!=typeof self&&self.clearImmediate||void 0!==e&&e.clearImmediate||this&&this.clearImmediate}).call(this,n(0))},function(e,t,n){"use strict";n.r(t),function(e){var t=n(2),i=n(1);const o={canvas:{video:document.getElementById("video"),hidden:document.createElement("canvas"),visible:document.getElementById("canvas"),capture:document.getElementById("capture"),template:document.getElementById("template"),ctx:{}},audio:{shutter:document.getElementById("shutter")},downloadBtn:document.getElementById("btn-download"),loading:document.getElementById("loading"),fps:document.getElementById("fps"),options:[document.getElementById("mirror_x_on"),document.getElementById("mirror_y_on"),document.getElementById("grayscale_on"),document.getElementById("outline_on"),document.getElementById("sharpen_on"),document.getElementById("invert_on"),document.getElementById("blur_on"),document.getElementById("emboss_on")]};o.canvas.hidden.width=400,o.canvas.hidden.height=400,o.canvas.ctx.hidden=o.canvas.hidden.getContext("2d"),o.canvas.ctx.visible=o.canvas.visible.getContext("2d"),o.canvas.ctx.capture=o.canvas.capture.getContext("2d"),o.canvas.ctx.capture.drawImage(o.canvas.template,0,0),o.downloadBtn.addEventListener("click",function(t){let n=3;o.loading.innerText="";const i=()=>{if(0===n)return sounds.shutter(),void e(()=>{o.downloadBtn.innerText="CAPTURE",o.canvas.visible.classList.add("flash"),o.canvas.ctx.capture.drawImage(o.canvas.visible,25,25);var e=o.canvas.capture.toDataURL("image/png");const t=document.createElement("a");t.href=e,t.download="wasmbooth.png",setTimeout(()=>o.canvas.visible.classList.remove("flash"),1e3);var n=new MouseEvent("click");t.dispatchEvent(n)});sounds.beep(),o.downloadBtn.innerText="... "+n.toString()+" ...",setTimeout(()=>{n--,i()},1e3)};i(),t.preventDefault()});navigator.mediaDevices.getUserMedia({video:!0,audio:!1}).then(function(e){o.canvas.video.srcObject=e}).catch(function(e){throw e}),window.sounds=(()=>{var e=window.AudioContext||window.webkitAudioContext||!1;if(e){var t=new e;return{beep:(()=>{const e=t.createOscillator(),n=t.createGain();return n.gain.value=0,e.type="sine",e.connect(n),e.frequency.value=830.6,n.connect(t.destination),e.start(0),()=>{n.gain.value=.1,setTimeout(()=>n.gain.value=0,150)}})(),shutter:()=>{o.audio.shutter.play()}}}return{beep:()=>{},shutter:()=>{}}})(),fetch("wasmbooth.wasm").then(e=>e.arrayBuffer()).then(e=>WebAssembly.instantiate(e)).then(({instance:e})=>{const n=e.exports.alloc_pixels(16e4),a=new t.a(250,o.fps),r=()=>{a.tick(),o.canvas.ctx.hidden.drawImage(o.canvas.video,(o.canvas.video.videoWidth-400)/2,(o.canvas.video.videoHeight-400)/2,400,400,0,0,400,400);const t=o.canvas.ctx.hidden.getImageData(0,0,400,400);i.a(t.data.buffer,e.exports.memory.buffer,n),e.exports.apply_filters(n,(()=>{let e=0;for(let[t,n]of o.options.entries())e|=n.checked?1< 2 | 3 | 4 | 5 | 6 | 7 | 8 | WASMBOOTH 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 |
18 | 19 |

WASMBOOTH

20 | 21 |
22 | 23 |
24 |
25 | 35 |
36 | 37 |
38 | 48 |
49 | 50 |
51 | 61 |
62 | 63 |
64 | 74 |
75 |
76 | 77 |
78 |

HAZ CAM?

79 | 80 | 81 | 82 | 84 | 85 |

86 |
87 | 88 |
89 |
90 | 100 |
101 | 102 |
103 | 113 |
114 | 115 |
116 | 126 |
127 | 128 |
129 | 139 |
140 |
141 | 142 |
143 | 144 |
145 | CAPTURE 146 |
147 |
148 |
149 | 150 | 151 | 158 | 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtharrison/wasmbooth/e88bc036d1bd416b53186ce6907296cae51596ec/public/logo.png -------------------------------------------------------------------------------- /public/shutter.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtharrison/wasmbooth/e88bc036d1bd416b53186ce6907296cae51596ec/public/shutter.mp3 -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | /* My CSS is awful and I don't care */ 2 | 3 | html, body { 4 | height: 100%; 5 | } 6 | 7 | body { 8 | background: radial-gradient(ellipse at center, #5a46ee 0%,#4435b2 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */ 9 | padding-top: 0px; 10 | font-family: 'Open Sans', sans-serif; 11 | color: #FFFFFF; 12 | display: flex; 13 | flex-direction: column; 14 | margin: 0; 15 | } 16 | 17 | h1 { 18 | font-family: 'Bungee Shade', cursive; 19 | font-size: 46px; 20 | color: #FFFFFF; 21 | text-align: center; 22 | font-weight: normal; 23 | } 24 | 25 | #video { 26 | width: 1px; 27 | height: 1px; 28 | } 29 | 30 | .video h2 { 31 | font-family: 'Bungee Shade', cursive; 32 | font-size: 34px; 33 | color: #4435b2; 34 | text-align: center; 35 | font-weight: normal; 36 | position: absolute; 37 | top: 140px; 38 | left: 106px; 39 | z-index: 0; 40 | } 41 | 42 | .container { 43 | width: 620px; 44 | margin: auto; 45 | flex: 1 0 auto; 46 | } 47 | 48 | .content > div.top { 49 | padding-bottom: 10px; 50 | } 51 | 52 | div.top:after { 53 | content: ""; 54 | display: table; 55 | clear: both; 56 | } 57 | 58 | .container .video { 59 | position: relative; 60 | box-shadow: 6px 6px 0px 0px rgba(0,0,0, 0.5); 61 | width: 400px; 62 | height: 400px; 63 | background: #FFFFFF; 64 | float: left; 65 | padding: 10px 10px 50px; 66 | position: relative; 67 | } 68 | 69 | .video #canvas { 70 | z-index: 5; 71 | position: absolute; 72 | box-shadow: 0px 0px 2px 0px rgba(0,0,0, 0.5); 73 | } 74 | 75 | .container .video #fps { 76 | position: absolute; 77 | top: 369px; 78 | right: 10px; 79 | font-size: 13px; 80 | background: rgba(0,0,0,0.5); 81 | padding: 5px; 82 | z-index: 10; 83 | } 84 | 85 | .container .controls { 86 | float: left; 87 | width: 70px; 88 | padding: 15px; 89 | } 90 | 91 | .controls.left { 92 | text-align: right; 93 | } 94 | 95 | .controls.left .switch-material.switch-light > span { 96 | left: 20px; 97 | } 98 | 99 | .control-set-left { 100 | float: left; 101 | margin-right: 30px; 102 | } 103 | 104 | .control-set-right { 105 | float: left; 106 | } 107 | 108 | .content > div.bottom { 109 | margin-top: 15px; 110 | } 111 | 112 | .switch-option { 113 | margin: 0px 0px 15px; 114 | } 115 | 116 | .switch-option span { 117 | margin-top: 5px; 118 | outline: none; 119 | box-shadow: 1px 1px 0px 0px rgba(0,0,0,0.5) inset; 120 | } 121 | 122 | .switch-option span a { 123 | outline: none!important; 124 | box-shadow: 1px 1px 0px 0px rgba(0,0,0,0.5); 125 | } 126 | 127 | #btn-download { 128 | font-family: 'Bungee Shade', cursive; 129 | border-radius: 28px; 130 | color: #4435b2; 131 | font-size: 24px; 132 | background: #FFFFFF; 133 | padding: 10px 20px 10px 20px; 134 | text-decoration: none; 135 | box-shadow: 0px 6px 1px 0px rgba(0,0,0, 0.3); 136 | width: 140px; 137 | display:block; 138 | margin: auto; 139 | text-align: center; 140 | } 141 | 142 | #btn-download:hover { 143 | text-decoration: none; 144 | position:relative; 145 | top: 1px; 146 | } 147 | 148 | #btn-download:active { 149 | text-decoration: none; 150 | position:relative; 151 | top: 3px; 152 | } 153 | 154 | footer { 155 | left: 5px; 156 | padding-bottom: 10px; 157 | color:#FFFFFF; 158 | text-align: center; 159 | font-size: 16px; 160 | flex-shrink: 0; 161 | } 162 | 163 | footer a { 164 | color:#FFFFFF; 165 | } 166 | 167 | #template, #capture { 168 | display: none; 169 | } 170 | 171 | .flash { 172 | opacity: 1; 173 | -webkit-animation: flash 1s; 174 | animation: flash 1s; 175 | } 176 | 177 | @-webkit-keyframes flash { 178 | 0% { opacity: .3; } 179 | 100% { opacity: 1; } 180 | } 181 | @keyframes flash { 182 | 0% { opacity: .3; } 183 | 100% { opacity: 1; } 184 | } -------------------------------------------------------------------------------- /public/switches.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | /* 3 | * CSS TOGGLE SWITCH 4 | * 5 | * Ionuț Colceriu - ghinda.net 6 | * https://github.com/ghinda/css-toggle-switch 7 | * 8 | */ 9 | /* supported values are px, rem-calc, em-calc 10 | */ 11 | /* imports 12 | */ 13 | /* Functions 14 | */ 15 | /* Shared 16 | */ 17 | /* Hide by default 18 | */ 19 | .switch-toggle a, 20 | .switch-light span span { 21 | display: none; } 22 | 23 | /* We can't test for a specific feature, 24 | * so we only target browsers with support for media queries. 25 | */ 26 | @media only screen { 27 | /* Checkbox 28 | */ 29 | .switch-light { 30 | position: relative; 31 | display: block; 32 | /* simulate default browser focus outlines on the switch, 33 | * when the inputs are focused. 34 | */ } 35 | .switch-light::after { 36 | clear: both; 37 | content: ''; 38 | display: table; } 39 | .switch-light *, 40 | .switch-light *:before, 41 | .switch-light *:after { 42 | box-sizing: border-box; } 43 | .switch-light a { 44 | display: block; 45 | transition: all 0.2s ease-out; } 46 | .switch-light label, 47 | .switch-light > span { 48 | /* breathing room for bootstrap/foundation classes. 49 | */ 50 | line-height: 2em; } 51 | .switch-light input:focus ~ span a, 52 | .switch-light input:focus + label { 53 | outline-width: 2px; 54 | outline-style: solid; 55 | outline-color: Highlight; 56 | /* Chrome/Opera gets its native focus styles. 57 | */ } } 58 | @media only screen and (-webkit-min-device-pixel-ratio: 0) { 59 | .switch-light input:focus ~ span a, 60 | .switch-light input:focus + label { 61 | outline-color: -webkit-focus-ring-color; 62 | outline-style: auto; } } 63 | 64 | @media only screen { 65 | /* don't hide the input from screen-readers and keyboard access 66 | */ 67 | .switch-light input { 68 | position: absolute; 69 | opacity: 0; 70 | z-index: 3; } 71 | .switch-light input:checked ~ span a { 72 | right: 0%; } 73 | /* inherit from label 74 | */ 75 | .switch-light strong { 76 | font-weight: inherit; } 77 | .switch-light > span { 78 | position: relative; 79 | overflow: hidden; 80 | display: block; 81 | min-height: 2em; 82 | /* overwrite 3rd party classes padding 83 | * eg. bootstrap .alert 84 | */ 85 | padding: 0; 86 | text-align: left; } 87 | .switch-light span span { 88 | position: relative; 89 | z-index: 2; 90 | display: block; 91 | float: left; 92 | width: 50%; 93 | text-align: center; 94 | user-select: none; } 95 | .switch-light a { 96 | position: absolute; 97 | right: 50%; 98 | top: 0; 99 | z-index: 1; 100 | display: block; 101 | width: 50%; 102 | height: 100%; 103 | padding: 0; } 104 | /* bootstrap 4 tweaks 105 | */ 106 | .switch-light.row { 107 | display: flex; } 108 | .switch-light .alert-light { 109 | color: #333; } 110 | /* Radio Switch 111 | */ 112 | .switch-toggle { 113 | position: relative; 114 | display: block; 115 | /* simulate default browser focus outlines on the switch, 116 | * when the inputs are focused. 117 | */ 118 | /* For callout panels in foundation 119 | */ 120 | padding: 0 !important; 121 | /* 2 items 122 | */ 123 | /* 3 items 124 | */ 125 | /* 4 items 126 | */ 127 | /* 5 items 128 | */ 129 | /* 6 items 130 | */ } 131 | .switch-toggle::after { 132 | clear: both; 133 | content: ''; 134 | display: table; } 135 | .switch-toggle *, 136 | .switch-toggle *:before, 137 | .switch-toggle *:after { 138 | box-sizing: border-box; } 139 | .switch-toggle a { 140 | display: block; 141 | transition: all 0.2s ease-out; } 142 | .switch-toggle label, 143 | .switch-toggle > span { 144 | /* breathing room for bootstrap/foundation classes. 145 | */ 146 | line-height: 2em; } 147 | .switch-toggle input:focus ~ span a, 148 | .switch-toggle input:focus + label { 149 | outline-width: 2px; 150 | outline-style: solid; 151 | outline-color: Highlight; 152 | /* Chrome/Opera gets its native focus styles. 153 | */ } } 154 | @media only screen and (-webkit-min-device-pixel-ratio: 0) { 155 | .switch-toggle input:focus ~ span a, 156 | .switch-toggle input:focus + label { 157 | outline-color: -webkit-focus-ring-color; 158 | outline-style: auto; } } 159 | 160 | @media only screen { 161 | .switch-toggle input { 162 | position: absolute; 163 | left: 0; 164 | opacity: 0; } 165 | .switch-toggle input + label { 166 | position: relative; 167 | z-index: 2; 168 | display: block; 169 | float: left; 170 | padding: 0 0.5em; 171 | margin: 0; 172 | text-align: center; } 173 | .switch-toggle a { 174 | position: absolute; 175 | top: 0; 176 | left: 0; 177 | padding: 0; 178 | z-index: 1; 179 | width: 10px; 180 | height: 100%; } 181 | .switch-toggle label:nth-child(2):nth-last-child(4), 182 | .switch-toggle label:nth-child(2):nth-last-child(4) ~ label, 183 | .switch-toggle label:nth-child(2):nth-last-child(4) ~ a { 184 | width: 50%; } 185 | .switch-toggle label:nth-child(2):nth-last-child(4) ~ input:checked:nth-child(3) + label ~ a { 186 | left: 50%; } 187 | .switch-toggle label:nth-child(2):nth-last-child(6), 188 | .switch-toggle label:nth-child(2):nth-last-child(6) ~ label, 189 | .switch-toggle label:nth-child(2):nth-last-child(6) ~ a { 190 | width: 33.33%; } 191 | .switch-toggle label:nth-child(2):nth-last-child(6) ~ input:checked:nth-child(3) + label ~ a { 192 | left: 33.33%; } 193 | .switch-toggle label:nth-child(2):nth-last-child(6) ~ input:checked:nth-child(5) + label ~ a { 194 | left: 66.66%; } 195 | .switch-toggle label:nth-child(2):nth-last-child(8), 196 | .switch-toggle label:nth-child(2):nth-last-child(8) ~ label, 197 | .switch-toggle label:nth-child(2):nth-last-child(8) ~ a { 198 | width: 25%; } 199 | .switch-toggle label:nth-child(2):nth-last-child(8) ~ input:checked:nth-child(3) + label ~ a { 200 | left: 25%; } 201 | .switch-toggle label:nth-child(2):nth-last-child(8) ~ input:checked:nth-child(5) + label ~ a { 202 | left: 50%; } 203 | .switch-toggle label:nth-child(2):nth-last-child(8) ~ input:checked:nth-child(7) + label ~ a { 204 | left: 75%; } 205 | .switch-toggle label:nth-child(2):nth-last-child(10), 206 | .switch-toggle label:nth-child(2):nth-last-child(10) ~ label, 207 | .switch-toggle label:nth-child(2):nth-last-child(10) ~ a { 208 | width: 20%; } 209 | .switch-toggle label:nth-child(2):nth-last-child(10) ~ input:checked:nth-child(3) + label ~ a { 210 | left: 20%; } 211 | .switch-toggle label:nth-child(2):nth-last-child(10) ~ input:checked:nth-child(5) + label ~ a { 212 | left: 40%; } 213 | .switch-toggle label:nth-child(2):nth-last-child(10) ~ input:checked:nth-child(7) + label ~ a { 214 | left: 60%; } 215 | .switch-toggle label:nth-child(2):nth-last-child(10) ~ input:checked:nth-child(9) + label ~ a { 216 | left: 80%; } 217 | .switch-toggle label:nth-child(2):nth-last-child(12), 218 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ label, 219 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ a { 220 | width: 16.6%; } 221 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ input:checked:nth-child(3) + label ~ a { 222 | left: 16.6%; } 223 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ input:checked:nth-child(5) + label ~ a { 224 | left: 33.2%; } 225 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ input:checked:nth-child(7) + label ~ a { 226 | left: 49.8%; } 227 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ input:checked:nth-child(9) + label ~ a { 228 | left: 66.4%; } 229 | .switch-toggle label:nth-child(2):nth-last-child(12) ~ input:checked:nth-child(11) + label ~ a { 230 | left: 83%; } 231 | 232 | /* Material Theme 233 | */ 234 | /* switch-light 235 | */ 236 | .switch-light.switch-material a { 237 | top: -0.1875em; 238 | width: 1.75em; 239 | height: 1.75em; 240 | border-radius: 50%; 241 | background: #FFFFFF; 242 | box-shadow: 0 0.125em 0.125em 0 rgba(0, 0, 0, 0.14), 0 0.1875em 0.125em -0.125em rgba(0, 0, 0, 0.2), 0 0.125em 0.25em 0 rgba(0, 0, 0, 0.12); 243 | transition: right 0.28s cubic-bezier(0.4, 0, 0.2, 1); } 244 | .switch-material.switch-light { 245 | overflow: visible; } 246 | .switch-material.switch-light::after { 247 | clear: both; 248 | content: ''; 249 | display: table; } 250 | .switch-material.switch-light > span { 251 | overflow: visible; 252 | position: relative; 253 | top: 0.1875em; 254 | width: 3.25em; 255 | height: 1.5em; 256 | min-height: auto; 257 | border-radius: 1em; 258 | background: rgba(0, 0, 0, 0.26); } 259 | .switch-material.switch-light span span { 260 | position: absolute; 261 | clip: rect(0 0 0 0); } 262 | .switch-material.switch-light input:checked ~ span a { 263 | right: 0; 264 | /* background: #3f51b5; */ 265 | } 266 | .switch-material.switch-light input:checked ~ span { 267 | } 268 | /* switch-toggle 269 | */ 270 | .switch-toggle.switch-material { 271 | overflow: visible; } 272 | .switch-toggle.switch-material::after { 273 | clear: both; 274 | content: ''; 275 | display: table; } 276 | .switch-toggle.switch-material a { 277 | top: 48%; 278 | width: 0.375em !important; 279 | height: 0.375em; 280 | margin-left: 0.25em; 281 | background: #3f51b5; 282 | border-radius: 100%; 283 | transform: translateY(-50%); 284 | transition: transform .4s ease-in; } 285 | .switch-toggle.switch-material label { 286 | color: rgba(0, 0, 0, 0.54); 287 | font-size: 1em; } 288 | .switch-toggle.switch-material label:before { 289 | content: ''; 290 | position: absolute; 291 | top: 48%; 292 | left: 0; 293 | display: block; 294 | width: 0.875em; 295 | height: 0.875em; 296 | border-radius: 100%; 297 | border: 0.125em solid rgba(0, 0, 0, 0.54); 298 | transform: translateY(-50%); } 299 | .switch-toggle.switch-material input:checked + label:before { 300 | border-color: #3f51b5; } 301 | /* ripple 302 | */ 303 | .switch-light.switch-material > span:before, 304 | .switch-light.switch-material > span:after, 305 | .switch-toggle.switch-material label:after { 306 | content: ''; 307 | position: absolute; 308 | top: 0; 309 | left: 0; 310 | z-index: 3; 311 | display: block; 312 | width: 4em; 313 | height: 4em; 314 | border-radius: 100%; 315 | background: #3f51b5; 316 | opacity: .4; 317 | margin-left: -1.25em; 318 | margin-top: -1.25em; 319 | transform: scale(0); 320 | transition: opacity .4s ease-in; } 321 | .switch-light.switch-material > span:after { 322 | left: auto; 323 | right: 0; 324 | margin-left: 0; 325 | margin-right: -1.25em; } 326 | .switch-toggle.switch-material label:after { 327 | width: 3.25em; 328 | height: 3.25em; 329 | margin-top: -0.75em; } 330 | 331 | .switch-material.switch-light input:not(:checked) ~ span:after, 332 | .switch-material.switch-light input:checked ~ span:before, 333 | .switch-toggle.switch-material input:checked + label:after { 334 | animation: materialRipple .4s ease-in; } 335 | /* trick to prevent the default checked ripple animation from showing 336 | * when the page loads. 337 | * the ripples are hidden by default, and shown only when the input is focused. 338 | */ 339 | .switch-light.switch-material.switch-light input ~ span:before, 340 | .switch-light.switch-material.switch-light input ~ span:after, 341 | .switch-material.switch-toggle input + label:after { 342 | visibility: hidden; } 343 | .switch-light.switch-material.switch-light input:focus:checked ~ span:before, 344 | .switch-light.switch-material.switch-light input:focus:not(:checked) ~ span:after, 345 | .switch-material.switch-toggle input:focus:checked + label:after { 346 | visibility: visible; } } 347 | 348 | /* Bugfix for older Webkit, including mobile Webkit. Adapted from 349 | * http://css-tricks.com/webkit-sibling-bug/ 350 | */ 351 | @media only screen and (-webkit-max-device-pixel-ratio: 2) and (max-device-width: 80em) { 352 | .switch-light, 353 | .switch-toggle { 354 | -webkit-animation: webkitSiblingBugfix infinite 1s; } } 355 | 356 | @-webkit-keyframes webkitSiblingBugfix { 357 | from { 358 | -webkit-transform: translate3d(0, 0, 0); } 359 | to { 360 | -webkit-transform: translate3d(0, 0, 0); } } 361 | 362 | /*# sourceMappingURL=toggle-switch.css.map */ -------------------------------------------------------------------------------- /public/template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtharrison/wasmbooth/e88bc036d1bd416b53186ce6907296cae51596ec/public/template.png -------------------------------------------------------------------------------- /public/wasmbooth.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtharrison/wasmbooth/e88bc036d1bd416b53186ce6907296cae51596ec/public/wasmbooth.wasm -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const Hapi = require('hapi'); 2 | const Inert = require('inert'); 3 | 4 | const server = new Hapi.Server({ port: 4000 }); 5 | 6 | const start = async () => { 7 | 8 | await server.register(Inert); 9 | 10 | server.route({ 11 | method: 'GET', 12 | path: '/{param*}', 13 | config: { 14 | handler: { 15 | directory: { 16 | path: 'public' 17 | } 18 | } 19 | } 20 | }); 21 | 22 | await server.start(); 23 | 24 | console.log('Listening on %s', server.info.uri); 25 | }; 26 | 27 | start(); -------------------------------------------------------------------------------- /src/bitflags.rs: -------------------------------------------------------------------------------- 1 | pub struct BitFlags { 2 | num: u8, 3 | } 4 | 5 | impl BitFlags { 6 | pub fn new(num: u8) -> BitFlags { 7 | BitFlags { num } 8 | } 9 | 10 | pub fn get(&self, i: usize) -> bool { 11 | if i > 7 { 12 | return false; 13 | } 14 | 15 | return (self.num >> i & 1) == 1; 16 | } 17 | } 18 | 19 | #[cfg(test)] 20 | mod tests { 21 | 22 | use super::*; 23 | 24 | #[test] 25 | fn test_get() { 26 | let flags = BitFlags::new(0b00000000); 27 | 28 | assert_eq!(flags.get(0), false); 29 | assert_eq!(flags.get(1), false); 30 | assert_eq!(flags.get(2), false); 31 | assert_eq!(flags.get(3), false); 32 | assert_eq!(flags.get(4), false); 33 | assert_eq!(flags.get(5), false); 34 | assert_eq!(flags.get(6), false); 35 | assert_eq!(flags.get(7), false); 36 | 37 | let flags = BitFlags::new(0b00001011); 38 | 39 | assert_eq!(flags.get(0), true); 40 | assert_eq!(flags.get(1), true); 41 | assert_eq!(flags.get(2), false); 42 | assert_eq!(flags.get(3), true); 43 | assert_eq!(flags.get(4), false); 44 | assert_eq!(flags.get(5), false); 45 | assert_eq!(flags.get(6), false); 46 | assert_eq!(flags.get(7), false); 47 | 48 | let flags = BitFlags::new(0b11111111); 49 | 50 | assert_eq!(flags.get(0), true); 51 | assert_eq!(flags.get(1), true); 52 | assert_eq!(flags.get(2), true); 53 | assert_eq!(flags.get(3), true); 54 | assert_eq!(flags.get(4), true); 55 | assert_eq!(flags.get(5), true); 56 | assert_eq!(flags.get(6), true); 57 | assert_eq!(flags.get(7), true); 58 | 59 | assert_eq!(flags.get(8), false); 60 | assert_eq!(flags.get(9), false); 61 | assert_eq!(flags.get(10), false); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/convolution.rs: -------------------------------------------------------------------------------- 1 | pub type ConvolutionMatrix = [[f32; 3]; 3]; 2 | 3 | pub static EDGE_DETECT: ConvolutionMatrix = [ 4 | [-1.0, -1.0, -1.0], // 5 | [-1.0, 8.0, -1.0], // 6 | [-1.0, -1.0, -1.0], // 7 | ]; 8 | 9 | pub static SHARPEN: ConvolutionMatrix = [ 10 | [0.0, -1.0, 0.0], // 11 | [-1.0, 5.0, -1.0], // 12 | [0.0, -1.0, 0.0], // 13 | ]; 14 | 15 | pub static BLUR: ConvolutionMatrix = [ 16 | [0.0625, 0.125, 0.0625], 17 | [0.125, 0.25, 0.125], 18 | [0.0625, 0.125, 0.0625], 19 | ]; 20 | 21 | pub static EMBOSS: ConvolutionMatrix = [ 22 | [-2.0, -1.0, 0.0], // 23 | [-1.0, 1.0, 1.0], // 24 | [0.0, 1.0, 2.0], // 25 | ]; 26 | 27 | pub fn apply_convolution(m1: [u8; 9], m2: ConvolutionMatrix) -> u8 { 28 | let mut accum: f32 = 0.0; 29 | 30 | for i in 0..3 { 31 | for j in 0..3 { 32 | accum += (m1[(i * 3) + j] as f32) * (m2[i][j]); 33 | } 34 | } 35 | 36 | if accum < 0.0 { 37 | return 0; 38 | } 39 | 40 | if accum > 255.0 { 41 | return 255; 42 | } 43 | 44 | return accum as u8; 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | 50 | use super::*; 51 | 52 | #[test] 53 | fn test_apply_convolution() { 54 | assert_eq!( 55 | apply_convolution( 56 | [ 57 | 1, 2, 3, // 58 | 4, 5, 6, // 59 | 7, 8, 9 // 60 | ], 61 | EDGE_DETECT 62 | ), 63 | 0 64 | ); 65 | 66 | assert_eq!( 67 | apply_convolution( 68 | [ 69 | 5, 5, 5, // 70 | 5, 6, 5, // 71 | 5, 5, 5 // 72 | ], 73 | EDGE_DETECT 74 | ), 75 | 8 76 | ); 77 | 78 | assert_eq!( 79 | apply_convolution( 80 | [ 81 | 0, 0, 0, // 82 | 0, 6, 0, // 83 | 0, 0, 0 // 84 | ], 85 | EDGE_DETECT 86 | ), 87 | 48 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/filter.rs: -------------------------------------------------------------------------------- 1 | use image::Image; 2 | use pixel::Pixel; 3 | use convolution::{apply_convolution, ConvolutionMatrix}; 4 | 5 | pub enum FilterType { 6 | MirrorX, 7 | MirrorY, 8 | Grayscale, 9 | Invert, 10 | Convolution(ConvolutionMatrix), 11 | } 12 | 13 | pub trait ImageFilterExt { 14 | fn filter(&mut self, FilterType); 15 | } 16 | 17 | impl<'a> ImageFilterExt for Image<'a> { 18 | fn filter(&mut self, filter: FilterType) { 19 | match filter { 20 | FilterType::MirrorX => mirror_x(self), 21 | FilterType::MirrorY => mirror_y(self), 22 | FilterType::Grayscale => grayscale(self), 23 | FilterType::Invert => invert(self), 24 | FilterType::Convolution(matrix) => convolution(self, matrix), 25 | } 26 | } 27 | } 28 | 29 | fn mirror_x(image: &mut Image) { 30 | for i in 0..image.pixels.len() { 31 | let mid = image.width / 2; 32 | let (row, col) = image.index_to_row_col(i); 33 | 34 | if col < mid { 35 | let j = image.row_col_to_index(row, image.width - 1 - col); 36 | image.pixels[j] = image.pixels[i].clone(); 37 | } 38 | } 39 | } 40 | 41 | fn mirror_y(image: &mut Image) { 42 | for i in 0..image.pixels.len() { 43 | let mid = image.height / 2; 44 | let (row, col) = image.index_to_row_col(i); 45 | 46 | if row < mid { 47 | let j = image.row_col_to_index(image.height - 1 - row, col); 48 | image.pixels[j] = image.pixels[i].clone(); 49 | } 50 | } 51 | } 52 | 53 | fn grayscale(image: &mut Image) { 54 | for i in 0..image.pixels.len() { 55 | image.pixels[i].grayscale(); 56 | } 57 | } 58 | 59 | fn convolution(image: &mut Image, matrix: ConvolutionMatrix) { 60 | let mut pixels_copy: Vec = image.pixels.iter().cloned().collect(); 61 | let original = Image { 62 | width: image.width, 63 | height: image.height, 64 | pixels: &mut pixels_copy[..], 65 | }; 66 | 67 | for i in 0..image.pixels.len() { 68 | let (row, col) = image.index_to_row_col(i); 69 | if row > 0 && row < (image.height - 1) && col > 0 && col < (image.width - 1) { // ignore outer border 70 | let (red_n, green_n, blue_n) = original.get_neighbour_colours(i); 71 | let red = apply_convolution(red_n, matrix); 72 | let green = apply_convolution(green_n, matrix); 73 | let blue = apply_convolution(blue_n, matrix); 74 | image.pixels[i] = Pixel::rgb(red, green, blue); 75 | } 76 | } 77 | } 78 | 79 | fn invert(image: &mut Image) { 80 | for i in 0..image.pixels.len() { 81 | image.pixels[i].invert(); 82 | } 83 | } 84 | 85 | #[cfg(test)] 86 | mod tests { 87 | 88 | use super::*; 89 | 90 | #[test] 91 | fn test_filter_mirror_x() { 92 | let mut pixels = [ 93 | Pixel::rgb(100, 100, 100), 94 | Pixel::rgb(0, 0, 0), 95 | Pixel::rgb(100, 100, 100), 96 | Pixel::rgb(0, 0, 0), 97 | ]; 98 | 99 | let mut image = Image::from_raw(&mut pixels[0], 2, 2); 100 | image.filter(FilterType::MirrorX); 101 | 102 | assert_eq!(image.pixels, [ 103 | Pixel::rgb(100, 100, 100), 104 | Pixel::rgb(100, 100, 100), 105 | Pixel::rgb(100, 100, 100), 106 | Pixel::rgb(100, 100, 100), 107 | ]); 108 | 109 | let mut pixels = [ 110 | Pixel::rgb(100, 100, 100), 111 | Pixel::rgb(0, 0, 0), 112 | Pixel::rgb(0, 0, 0), 113 | Pixel::rgb(100, 100, 100), 114 | Pixel::rgb(0, 0, 0), 115 | Pixel::rgb(0, 0, 0), 116 | Pixel::rgb(100, 100, 100), 117 | Pixel::rgb(0, 0, 0), 118 | Pixel::rgb(0, 0, 0), 119 | ]; 120 | 121 | let mut image = Image::from_raw(&mut pixels[0], 3, 3); 122 | image.filter(FilterType::MirrorX); 123 | 124 | assert_eq!(image.pixels, [ 125 | Pixel::rgb(100, 100, 100), 126 | Pixel::rgb(0, 0, 0), 127 | Pixel::rgb(100, 100, 100), 128 | Pixel::rgb(100, 100, 100), 129 | Pixel::rgb(0, 0, 0), 130 | Pixel::rgb(100, 100, 100), 131 | Pixel::rgb(100, 100, 100), 132 | Pixel::rgb(0, 0, 0), 133 | Pixel::rgb(100, 100, 100), 134 | ]); 135 | } 136 | 137 | #[test] 138 | fn test_filter_mirror_y() { 139 | let mut pixels = [ 140 | Pixel::rgb(100, 100, 100), 141 | Pixel::rgb(100, 100, 100), 142 | Pixel::rgb(0, 0, 0), 143 | Pixel::rgb(0, 0, 0), 144 | ]; 145 | 146 | let mut image = Image::from_raw(&mut pixels[0], 2, 2); 147 | image.filter(FilterType::MirrorY); 148 | 149 | assert_eq!(image.pixels, [ 150 | Pixel::rgb(100, 100, 100), 151 | Pixel::rgb(100, 100, 100), 152 | Pixel::rgb(100, 100, 100), 153 | Pixel::rgb(100, 100, 100), 154 | ]); 155 | 156 | let mut pixels = [ 157 | Pixel::rgb(100, 100, 100), 158 | Pixel::rgb(100, 100, 100), 159 | Pixel::rgb(100, 100, 100), 160 | Pixel::rgb(0, 0, 0), 161 | Pixel::rgb(0, 0, 0), 162 | Pixel::rgb(0, 0, 0), 163 | Pixel::rgb(0, 0, 0), 164 | Pixel::rgb(0, 0, 0), 165 | Pixel::rgb(0, 0, 0), 166 | ]; 167 | 168 | let mut image = Image::from_raw(&mut pixels[0], 3, 3); 169 | image.filter(FilterType::MirrorY); 170 | 171 | assert_eq!(image.pixels, [ 172 | Pixel::rgb(100, 100, 100), 173 | Pixel::rgb(100, 100, 100), 174 | Pixel::rgb(100, 100, 100), 175 | Pixel::rgb(0, 0, 0), 176 | Pixel::rgb(0, 0, 0), 177 | Pixel::rgb(0, 0, 0), 178 | Pixel::rgb(100, 100, 100), 179 | Pixel::rgb(100, 100, 100), 180 | Pixel::rgb(100, 100, 100), 181 | ]); 182 | } 183 | 184 | #[test] 185 | fn test_filter_grayscale() { 186 | let mut pixels = [ 187 | Pixel::rgb(100, 150, 200), 188 | Pixel::rgb(100, 150, 200), 189 | Pixel::rgb(100, 150, 200), 190 | Pixel::rgb(100, 150, 200), 191 | ]; 192 | 193 | let mut image = Image::from_raw(&mut pixels[0], 2, 2); 194 | image.filter(FilterType::Grayscale); 195 | 196 | assert_eq!(image.pixels, [ 197 | Pixel::rgb(150, 150, 150), 198 | Pixel::rgb(150, 150, 150), 199 | Pixel::rgb(150, 150, 150), 200 | Pixel::rgb(150, 150, 150), 201 | ]); 202 | } 203 | 204 | #[test] 205 | fn test_convolution() { 206 | let mut pixels = [ 207 | Pixel::rgb(100, 150, 200), 208 | Pixel::rgb(100, 150, 200), 209 | Pixel::rgb(100, 150, 200), 210 | Pixel::rgb(100, 150, 200), 211 | Pixel::rgb(100, 150, 200), 212 | Pixel::rgb(100, 150, 200), 213 | Pixel::rgb(100, 150, 200), 214 | Pixel::rgb(100, 150, 200), 215 | Pixel::rgb(100, 150, 200), 216 | ]; 217 | 218 | let mut image = Image::from_raw(&mut pixels[0], 3, 3); 219 | image.filter(FilterType::Convolution([ 220 | [0.,0.,0.], 221 | [0.,1.,0.], // identity matrix 222 | [0.,0.,0.], 223 | ])); 224 | 225 | assert_eq!(image.pixels, [ 226 | Pixel::rgb(100, 150, 200), 227 | Pixel::rgb(100, 150, 200), 228 | Pixel::rgb(100, 150, 200), 229 | Pixel::rgb(100, 150, 200), 230 | Pixel::rgb(100, 150, 200), 231 | Pixel::rgb(100, 150, 200), 232 | Pixel::rgb(100, 150, 200), 233 | Pixel::rgb(100, 150, 200), 234 | Pixel::rgb(100, 150, 200), 235 | ]); 236 | 237 | image.filter(FilterType::Convolution([ 238 | [0.,0.,0.], 239 | [0.,0.,0.], // zero out matrix 240 | [0.,0.,0.], 241 | ])); 242 | 243 | assert_eq!(image.pixels, [ 244 | Pixel::rgb(100, 150, 200), 245 | Pixel::rgb(100, 150, 200), 246 | Pixel::rgb(100, 150, 200), 247 | Pixel::rgb(100, 150, 200), 248 | Pixel::rgb(0, 0, 0), 249 | Pixel::rgb(100, 150, 200), 250 | Pixel::rgb(100, 150, 200), 251 | Pixel::rgb(100, 150, 200), 252 | Pixel::rgb(100, 150, 200), 253 | ]); 254 | } 255 | 256 | #[test] 257 | fn test_invert() { 258 | let mut pixels = [ 259 | Pixel::rgb(100, 150, 200), 260 | Pixel::rgb(100, 150, 200), 261 | Pixel::rgb(100, 150, 200), 262 | Pixel::rgb(100, 150, 200), 263 | ]; 264 | 265 | let mut image = Image::from_raw(&mut pixels[0], 2, 2); 266 | image.filter(FilterType::Invert); 267 | 268 | assert_eq!(image.pixels, [ 269 | Pixel::rgb(155, 105, 55), 270 | Pixel::rgb(155, 105, 55), 271 | Pixel::rgb(155, 105, 55), 272 | Pixel::rgb(155, 105, 55), 273 | ]); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/image.rs: -------------------------------------------------------------------------------- 1 | use std::slice; 2 | use pixel::Pixel; 3 | 4 | pub struct Image<'a> { 5 | pub width: usize, 6 | pub height: usize, 7 | pub pixels: &'a mut [Pixel], 8 | } 9 | 10 | impl<'a> Image<'a> { 11 | pub fn from_raw(ptr: *mut Pixel, width: usize, height: usize) -> Image<'a> { 12 | let num_pixels = width * height; 13 | let pixels = unsafe { slice::from_raw_parts_mut(ptr, num_pixels) }; 14 | 15 | Image { 16 | width, 17 | height, 18 | pixels, 19 | } 20 | } 21 | 22 | pub fn flip_x(&mut self) { 23 | for i in 0..self.pixels.len() { 24 | let (row, col) = self.index_to_row_col(i); 25 | if col >= self.width / 2 { 26 | let target_idx = self.row_col_to_index(row, self.width - 1 - col); 27 | let temp = self.pixels[i].clone(); 28 | self.pixels[i] = self.pixels[target_idx]; 29 | self.pixels[target_idx] = temp; 30 | } 31 | } 32 | } 33 | 34 | pub fn row_col_to_index(&self, row: usize, col: usize) -> usize { 35 | (self.width * row) + col 36 | } 37 | 38 | pub fn index_to_row_col(&self, i: usize) -> (usize, usize) { 39 | (i / self.width, i % self.width) 40 | } 41 | 42 | pub fn get_neighbour_colours(&self, i: usize) -> ([u8; 9], [u8; 9], [u8; 9]) { 43 | let mut red = [0; 9]; 44 | let mut green = [0; 9]; 45 | let mut blue = [0; 9]; 46 | 47 | let (row, col) = self.index_to_row_col(i); 48 | 49 | let mut idx = 0; 50 | 51 | for i in row as isize - 1..row as isize + 2 { 52 | for j in col as isize - 1..col as isize + 2 { 53 | let pix = self.pixels[(self.width * (i as usize)) + (j as usize)]; 54 | red[idx] = pix.red; 55 | green[idx] = pix.green; 56 | blue[idx] = pix.blue; 57 | idx += 1; 58 | } 59 | } 60 | 61 | (red, green, blue) 62 | } 63 | } 64 | 65 | #[cfg(test)] 66 | mod tests { 67 | 68 | use super::*; 69 | 70 | #[test] 71 | fn test_from_raw() { 72 | let mut pixels = [ 73 | Pixel::rgb(0, 0, 0), 74 | Pixel::rgb(0, 0, 0), 75 | Pixel::rgb(0, 0, 0), 76 | Pixel::rgb(0, 0, 0), 77 | ]; 78 | 79 | let image = Image::from_raw(&mut pixels[0], 2, 2); 80 | 81 | assert_eq!(image.width, 2); 82 | assert_eq!(image.height, 2); 83 | assert_eq!(image.pixels, pixels); 84 | } 85 | 86 | #[test] 87 | fn test_flip_x() { 88 | let mut pixels = [ 89 | Pixel::rgb(100, 0, 0), 90 | Pixel::rgb(0, 0, 0), 91 | Pixel::rgb(100, 0, 0), 92 | Pixel::rgb(0, 0, 0), 93 | ]; 94 | 95 | let mut image = Image::from_raw(&mut pixels[0], 2, 2); 96 | image.flip_x(); 97 | 98 | assert_eq!(image.pixels, [ 99 | Pixel::rgb(0, 0, 0), 100 | Pixel::rgb(100, 0, 0), 101 | Pixel::rgb(0, 0, 0), 102 | Pixel::rgb(100,0, 0), 103 | ]); 104 | } 105 | 106 | #[test] 107 | fn test_row_col_to_index() { 108 | let mut pixels = [ 109 | Pixel::rgb(100, 0, 0), 110 | Pixel::rgb(0, 0, 0), 111 | Pixel::rgb(100, 0, 0), 112 | Pixel::rgb(0, 0, 0), 113 | ]; 114 | 115 | let image = Image::from_raw(&mut pixels[0], 2, 2); 116 | 117 | assert_eq!(image.row_col_to_index(1, 0), 2); 118 | assert_eq!(image.row_col_to_index(1, 1), 3); 119 | } 120 | 121 | #[test] 122 | fn test_index_to_row_col() { 123 | let mut pixels = [ 124 | Pixel::rgb(100, 0, 0), 125 | Pixel::rgb(0, 0, 0), 126 | Pixel::rgb(100, 0, 0), 127 | Pixel::rgb(0, 0, 0), 128 | ]; 129 | 130 | let image = Image::from_raw(&mut pixels[0], 2, 2); 131 | 132 | assert_eq!(image.index_to_row_col(2), (1, 0)); 133 | assert_eq!(image.index_to_row_col(3), (1, 1)); 134 | } 135 | 136 | #[test] 137 | fn test_get_neighbour_colours() { 138 | let mut pixels = [ 139 | Pixel::rgb(100, 0, 0), 140 | Pixel::rgb(0, 0, 0), 141 | Pixel::rgb(100, 0, 0), 142 | Pixel::rgb(0, 0, 0), 143 | Pixel::rgb(100, 0, 0), 144 | Pixel::rgb(0, 0, 0), 145 | Pixel::rgb(100, 0, 0), 146 | Pixel::rgb(0, 0, 0), 147 | Pixel::rgb(100, 0, 0), 148 | ]; 149 | 150 | let image = Image::from_raw(&mut pixels[0], 3, 3); 151 | 152 | assert_eq!(image.get_neighbour_colours(4), ( 153 | [100, 0, 100, 0, 100, 0, 100, 0, 100], 154 | [0, 0, 0, 0, 0, 0, 0, 0, 0], 155 | [0, 0, 0, 0, 0, 0, 0, 0, 0], 156 | )); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod bitflags; 2 | mod filter; 3 | mod image; 4 | mod pixel; 5 | mod convolution; 6 | 7 | use bitflags::BitFlags; 8 | use filter::FilterType; 9 | use filter::ImageFilterExt; 10 | use image::Image; 11 | use pixel::Pixel; 12 | 13 | // Allocate enough space in linear memory to hold n pixels 14 | // returns a pointer to the start of the buffer and forgets 15 | // the vector so Rust doesn't attempt to free the memory 16 | // this is essentially 'leaked' memory 17 | 18 | #[no_mangle] 19 | pub fn alloc_pixels(n: usize) -> i32 { 20 | let mut vec = Vec::::with_capacity(n); 21 | let ptr = vec.as_mut_ptr(); 22 | std::mem::forget(vec); 23 | ptr as i32 24 | } 25 | 26 | // Apply each of the filters to the image 27 | 28 | #[no_mangle] 29 | pub fn apply_filters(ptr: i32, options: u8, width: usize, height: usize) { 30 | let mut image = Image::from_raw(ptr as *mut Pixel, width, height); 31 | 32 | image.flip_x(); // feels more natural 33 | 34 | let flags = BitFlags::new(options); 35 | 36 | if flags.get(0) { 37 | image.filter(FilterType::MirrorX); 38 | } 39 | 40 | if flags.get(1) { 41 | image.filter(FilterType::MirrorY); 42 | } 43 | 44 | if flags.get(2) { 45 | image.filter(FilterType::Grayscale); 46 | } 47 | 48 | if flags.get(3) { 49 | image.filter(FilterType::Convolution(convolution::EDGE_DETECT)); 50 | } 51 | 52 | if flags.get(4) { 53 | image.filter(FilterType::Convolution(convolution::SHARPEN)); 54 | } 55 | 56 | if flags.get(5) { 57 | image.filter(FilterType::Invert); 58 | } 59 | 60 | if flags.get(6) { 61 | image.filter(FilterType::Convolution(convolution::BLUR)); 62 | } 63 | 64 | if flags.get(7) { 65 | image.filter(FilterType::Convolution(convolution::EMBOSS)); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/pixel.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Copy, Debug, PartialEq)] 2 | pub struct Pixel { 3 | pub red: u8, 4 | pub green: u8, 5 | pub blue: u8, 6 | _alpha: u8, 7 | } 8 | 9 | impl Pixel { 10 | pub fn rgb(red: u8, green: u8, blue: u8) -> Pixel { 11 | Pixel { 12 | red, 13 | green, 14 | blue, 15 | _alpha: 255, 16 | } 17 | } 18 | 19 | pub fn set_rgb(&mut self, r: u8, g: u8, b: u8) { 20 | self.red = r; 21 | self.green = g; 22 | self.blue = b; 23 | } 24 | 25 | pub fn set_gray(&mut self, g: u8) { 26 | self.red = g; 27 | self.green = g; 28 | self.blue = g; 29 | } 30 | 31 | pub fn invert(&mut self) { 32 | let (red, green, blue) = (255 - self.red, 255 - self.green, 255 - self.blue); 33 | self.set_rgb(red, green, blue); 34 | } 35 | 36 | pub fn grayscale(&mut self) { 37 | let avg = ((self.green as u32 + self.red as u32 + self.blue as u32) / 3) as u8; 38 | self.set_gray(avg); 39 | } 40 | } 41 | 42 | #[cfg(test)] 43 | mod tests { 44 | 45 | use super::*; 46 | 47 | #[test] 48 | fn test_rgb() { 49 | let pixel = Pixel::rgb(50, 100, 150); 50 | assert_eq!(pixel.red, 50); 51 | assert_eq!(pixel.green, 100); 52 | assert_eq!(pixel.blue, 150); 53 | } 54 | 55 | #[test] 56 | fn test_set_rgb() { 57 | let mut pixel = Pixel::rgb(50, 100, 150); 58 | assert_eq!(pixel.red, 50); 59 | assert_eq!(pixel.green, 100); 60 | assert_eq!(pixel.blue, 150); 61 | 62 | pixel.set_rgb(1, 2, 3); 63 | assert_eq!(pixel.red, 1); 64 | assert_eq!(pixel.green, 2); 65 | assert_eq!(pixel.blue, 3); 66 | } 67 | 68 | #[test] 69 | fn test_set_gray() { 70 | let mut pixel = Pixel::rgb(50, 100, 150); 71 | assert_eq!(pixel.red, 50); 72 | assert_eq!(pixel.green, 100); 73 | assert_eq!(pixel.blue, 150); 74 | 75 | pixel.set_gray(42); 76 | assert_eq!(pixel.red, 42); 77 | assert_eq!(pixel.green, 42); 78 | assert_eq!(pixel.blue, 42); 79 | } 80 | 81 | #[test] 82 | fn test_invert() { 83 | let mut pixel = Pixel::rgb(50, 100, 150); 84 | assert_eq!(pixel.red, 50); 85 | assert_eq!(pixel.green, 100); 86 | assert_eq!(pixel.blue, 150); 87 | 88 | pixel.invert(); 89 | assert_eq!(pixel.red, 205); 90 | assert_eq!(pixel.green, 155); 91 | assert_eq!(pixel.blue, 105); 92 | } 93 | 94 | #[test] 95 | fn test_grayscale() { 96 | let mut pixel = Pixel::rgb(50, 100, 150); 97 | assert_eq!(pixel.red, 50); 98 | assert_eq!(pixel.green, 100); 99 | assert_eq!(pixel.blue, 150); 100 | 101 | pixel.grayscale(); 102 | assert_eq!(pixel.red, 100); 103 | assert_eq!(pixel.green, 100); 104 | assert_eq!(pixel.blue, 100); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: "./lib/main.js", 5 | output: { 6 | path: path.resolve(__dirname, "public"), 7 | filename: "bundle.js", 8 | }, 9 | mode: "production" 10 | }; --------------------------------------------------------------------------------