app.js - webgbcam - [fork] gameboy webcam
HTML git clone git://src.adamsgaard.dk/webgbcam
DIR Log
DIR Files
DIR Refs
DIR README
DIR LICENSE
---
app.js (29102B)
---
1 /*
2 * Copyright (c) 2021-2022 maple@maple.pet
3 *
4 * This software is licensed through the Anti-Fascist and the Anti-Capitalist
5 * licenses. See LICENSE for more details.
6 *
7 * The above copyright notice, this permission notice and any supplementary
8 * license documents shall be included in all copies or substantial portions
9 * of the Software.
10 */
11
12 // 4-color GB palette must be dark to light
13 const palettes = [
14 // AYY4 - https://lospec.com/palette-list/ayy4
15 [
16 [0, 48, 59],
17 [255, 119, 119],
18 [255, 206, 150],
19 [241, 242, 218]
20 ],
21 /* this one was removed in favour of spacehaze which is very similar
22 // Barbie: The Slasher Movie - https://lospec.com/palette-list/barbie-the-slasher-movie
23 [
24 [0, 0, 0],
25 [110, 31, 177],
26 [204, 51, 133],
27 [248, 251, 243]
28 ],*/
29 // SpaceHaze - https://lospec.com/palette-list/spacehaze
30 [
31 [11, 6, 48],
32 [107, 31, 177],
33 [204, 52, 149],
34 [248, 227, 196]
35 ],
36 // CRTGB - https://lospec.com/palette-list/crtgb
37 [
38 [6, 6, 1],
39 [11, 62, 8],
40 [72, 154, 13],
41 [218, 242, 34]
42 ],
43 // Amber CRTGB - https://lospec.com/palette-list/amber-crtgb
44 [
45 [13, 4, 5],
46 [94, 18, 16],
47 [211, 86, 0],
48 [254, 208, 24]
49 ],
50 // Kirby (SGB) - https://lospec.com/palette-list/kirby-sgb
51 [
52 [44, 44, 150],
53 [119, 51, 231],
54 [231, 134, 134],
55 [247, 190, 247]
56 ],
57 // CherryMelon - https://lospec.com/palette-list/cherrymelon
58 [
59 [1, 40, 36],
60 [38, 89, 53],
61 [255, 77, 109],
62 [252, 222, 234]
63 ],
64 // Pumpkin GB - https://lospec.com/palette-list/pumpkin-gb
65 [
66 [20, 43, 35],
67 [25, 105, 44],
68 [244, 110, 22],
69 [247, 219, 126]
70 ],
71 // Purpledawn - https://lospec.com/palette-list/purpledawn
72 [
73 [0, 27, 46],
74 [45, 117, 126],
75 [154, 123, 188],
76 [238, 253, 237]
77 ],
78 // Royal4 - https://lospec.com/palette-list/royal4
79 [
80 [82, 18, 150],
81 [138, 31, 172],
82 [212, 134, 74],
83 [235, 219, 94]
84 ],
85 // Grand Dad 4 - https://lospec.com/palette-list/grand-dad-4
86 [
87 [76, 28, 45],
88 [210, 60, 78],
89 [95, 177, 245],
90 [234, 245, 250]
91 ],
92 // Mural GB - https://lospec.com/palette-list/mural-gb
93 [
94 [10, 22, 78],
95 [162, 81, 48],
96 [206, 173, 107],
97 [250, 253, 255]
98 ],
99 // Ocean GB - https://lospec.com/palette-list/ocean-gb
100 [
101 [28, 21, 48],
102 [42, 48, 139],
103 [54, 125, 1216],
104 [141, 226, 246]
105 ],
106 // Alleyway - ISS
107 [
108 [66, 66, 66],
109 [123, 123, 206],
110 [255, 107, 255],
111 [255, 214, 0]
112 ],
113 // Pocket - ISS
114 [
115 [108, 108, 78],
116 [142, 139, 97],
117 [195, 196, 165],
118 [227, 230, 201]
119 ],
120 // Kadabura4 - https://lospec.com/palette-list/kadabura4
121 [
122 [0, 0, 0],
123 [87, 87, 87],
124 [219, 0, 12],
125 [255, 255, 255]
126 ],
127 // Virtual - ISS
128 [
129 [2, 0, 0],
130 [65, 0, 0],
131 [127, 0, 0],
132 [255, 0, 0]
133 ],
134 // Love! Love! - ISS
135 [
136 [176, 16, 48],
137 [255, 96, 176],
138 [255, 184, 232],
139 [255, 255, 255]
140 ],
141 // Metroid II (SGB) - https://lospec.com/palette-list/metroid-ii-sgb
142 [
143 [44, 23, 0],
144 [4, 126, 96],
145 [182, 37, 88],
146 [174, 223, 30]
147 ],
148 // Micro 86 - https://lospec.com/palette-list/micro-86
149 [
150 [38, 0, 14],
151 [255, 0, 0],
152 [255, 123, 48],
153 [255, 217, 178]
154 ],
155 // Vivid 2Bit Scream - https://lospec.com/palette-list/vivid-2bit-scream
156 [
157 [86, 29, 23],
158 [92, 79, 163],
159 [116, 175, 52],
160 [202, 245, 50]
161 ],
162 // Pastel GBC/SGB - submitted by synth___ruiner
163 [
164 [4,2,4],
165 [156,146,244],
166 [236,138,140],
167 [252,250,172]
168 ],
169 // trans flag - by mavica
170 [
171 [32, 32, 32],
172 [91, 207, 250],
173 [245, 171, 185],
174 [255, 255, 255]
175 ],
176 // grayscale - by mavica
177 [
178 [40, 40, 40],
179 [104, 104, 104],
180 [168, 168, 168],
181 [252, 252, 252]
182 ],
183 // Scold 2 bit - https://lospec.com/palette-list/scold-2-bit
184 [
185 [16, 28, 86],
186 [206, 0, 148],
187 [15, 183, 0],
188 [211,211,211]
189 ],
190 // strawberry parfait - by mavica
191 [
192 [31, 19, 0],
193 [216, 32, 46],
194 [247, 80, 215],
195 [255, 231, 204]
196 ],
197 // bric-a-brac - by mavica
198 [
199 [12, 39, 56],
200 [237, 79, 54],
201 [248, 150, 23],
202 [184, 211, 218]
203 ],
204 // poolors 1 - mavica
205 [
206 [35, 37, 7],
207 [50, 8, 105],
208 [144, 88, 97],
209 [233, 137, 120]
210 ],
211 // poolors 2 - mavica
212 [
213 [64, 150, 138],
214 [169, 179, 121],
215 [206, 185, 181],
216 [183, 250, 241]
217 ],
218 // poolors 3 - mavica
219 [
220 [81, 17, 107],
221 [130, 87, 147],
222 [152, 243, 175],
223 [254, 218, 57]
224 ],
225 // poolors 4 - mavica
226 [
227 [19, 6, 18],
228 [72, 71, 173],
229 [8, 152, 149],
230 [152, 202, 145]
231 ],
232 ];
233
234 const cameraStream = document.querySelector("#camera-stream"),
235 cameraView = document.querySelector("#camera-view"),
236 cameraOutput = document.querySelector("#camera-output"),
237 cameraDiv = document.querySelector("#camera"),
238 appView = document.querySelector("#app-view"),
239 gifPreview = document.querySelector("#gif-preview"),
240 gifImg = document.querySelector("#gif-img"),
241 gifButtons = document.querySelector("#gif-buttons");
242 var amountOfCameras = 0;
243 var currentFacingMode = 'user';
244 var appScale;
245 var frameDrawing;
246 var resizeTimeout;
247 const gifLength = 50;
248 var outputScale = 6;
249 const minCameraZoom = 1;
250 const maxCameraZoom = 4;
251 var pinchStartDistance = null;
252 var pinchStartZoom = minCameraZoom;
253 var gifRecording,
254 gifEncoder,
255 gifFrames,
256 gifBlob;
257
258 const sliderGamma = [
259 2.5,
260 2,
261 1.5,
262 1,
263 0.8,
264 0.6,
265 0.4
266 ];
267
268 const sliderContrast = [
269 0.6,
270 0.9,
271 1.2,
272 1.5,
273 1.8,
274 2.1,
275 2.4
276 ];
277
278 const sliderSharpness = [
279 0,
280 0.25,
281 0.50,
282 0.75,
283 1.00,
284 1.25,
285 2.00
286 ];
287
288 // 8 x 8 Bayer Matrix
289 const bayer8 = [
290 [0,48,12,60,3,51,15,63],
291 [32,16,44,28,35,19,47,31],
292 [8,56,4,52,11,59,7,55],
293 [40,24,36,20,43,27,39,23],
294 [2,50,14,62,1,49,13,61],
295 [34,18,46,30,33,17,45,29],
296 [10,58,6,54,9,57,5,53],
297 [42,26,38,22,41,25,37,21]
298 ];
299
300 const clampNumber = (num, a, b) => Math.min(Math.max(num, a), b);
301
302 // bounding boxes for each button in the app
303 var buttons = {
304 bottomLeft: {
305 x:1,
306 y:113,
307 width:30,
308 height:30
309 },
310 bottomRight: {
311 x:129,
312 y:113,
313 width:30,
314 height:30
315 },
316 topLeft: {
317 x:1,
318 y:1,
319 width:30,
320 height:30
321 },
322 contrastLeft: {
323 x:10,
324 y:13,
325 width:15,
326 height:13
327 },
328 contrastRight: {
329 x:25,
330 y:13,
331 width:15,
332 height:13
333 },
334 brightnessLeft: {
335 x:65,
336 y:13,
337 width:15,
338 height:13
339 },
340 brightnessRight: {
341 x:80,
342 y:13,
343 width:15,
344 height:13
345 },
346 paletteLeft: {
347 x:120,
348 y:13,
349 width:15,
350 height:13
351 },
352 paletteRight: {
353 x:135,
354 y:13,
355 width:15,
356 height:13
357 },
358 sharpnessLeft: {
359 x:120,
360 y:126,
361 width:15,
362 height:13
363 },
364 sharpnessRight: {
365 x:135,
366 y:126,
367 width:15,
368 height:13
369 },
370 screenHotspot: {
371 x:31,
372 y:31,
373 width:98,
374 height:82
375 },
376 hideUI: {
377 x:145,
378 y:0,
379 width:15,
380 height:15
381 },
382 timer: {
383 x:52,
384 y:131,
385 width:13,
386 height:13
387 },
388 record: {
389 x:95,
390 y:131,
391 width:16,
392 height:13
393 },
394 bppSwitch: {
395 x:45,
396 y:133,
397 width:50,
398 height:9
399 }
400 };
401
402 var screens = {
403 uiMain: {
404 elem: document.querySelector("#ui-main"),
405 buttons: [
406 {
407 bounding: buttons.bottomLeft,
408 action: captureImage
409 },
410 {
411 bounding: buttons.screenHotspot,
412 action: captureImage
413 },
414 {
415 bounding: buttons.bottomRight,
416 action: switchCameras
417 },
418 {
419 bounding: buttons.topLeft,
420 action: ()=> {
421 currentUI = screens.uiSettings;
422 }
423 },
424 {
425 bounding: buttons.hideUI,
426 action: ()=> {
427 currentUI = screens.uiHidden;
428 }
429 },
430 {
431 bounding: buttons.timer,
432 action: ()=> {
433 // change UI to timer and trigger 3s delay to capture
434 currentUI = screens.uiTimer;
435 setTimeout(captureImage, 3000);
436 }
437 },
438 {
439 bounding: buttons.record,
440 action: gifStart
441 }
442 ]
443 },
444 uiCapture: {
445 elem: document.querySelector("#ui-capture"),
446 buttons: [
447 {
448 bounding: buttons.bottomLeft,
449 action: ()=> {
450 // return
451 cameraStream.play();
452 currentUI = screens.uiMain;
453 }
454 },
455 {
456 bounding: buttons.bottomRight,
457 action: savePicture
458 },
459 {
460 bounding: buttons.topLeft,
461 action: ()=> {
462 // go to settings
463 currentUI = screens.uiSettings;
464 }
465 }
466 ]
467 },
468 uiSettings: {
469 elem: document.querySelector("#ui-settings"),
470 buttons: [
471 {
472 bounding: buttons.bottomLeft,
473 action: ()=> {
474 // return
475 if(cameraStream.paused == true) {
476 // we're in capture
477 currentUI = screens.uiCapture;
478 } else {
479 currentUI = screens.uiMain;
480 }
481 }
482 },
483 {
484 bounding: buttons.contrastLeft,
485 action: ()=> {
486 if(cameraVars.contrast > 0) cameraVars.contrast--;
487 savePrefs();
488 }
489 },
490 {
491 bounding: buttons.contrastRight,
492 action: ()=> {
493 if(cameraVars.contrast < 6) cameraVars.contrast++;
494 savePrefs();
495 }
496 },
497 {
498 bounding: buttons.brightnessLeft,
499 action: ()=> {
500 if(cameraVars.gamma > 0) cameraVars.gamma--;
501 savePrefs();
502 }
503 },
504 {
505 bounding: buttons.brightnessRight,
506 action: ()=> {
507 if(cameraVars.gamma < 6) cameraVars.gamma++;
508 savePrefs();
509 }
510 },
511 {
512 bounding: buttons.sharpnessLeft,
513 action: ()=> {
514 if(cameraVars.sharpness > 0) cameraVars.sharpness--;
515 savePrefs();
516 }
517 },
518 {
519 bounding: buttons.sharpnessRight,
520 action: ()=> {
521 if(cameraVars.sharpness < 6) cameraVars.sharpness++;
522 savePrefs();
523 }
524 },
525 {
526 bounding: buttons.paletteLeft,
527 action: ()=> {
528 currentPalette--;
529 if(currentPalette < 0) currentPalette = palettes.length-1;
530 savePrefs();
531 }
532 },
533 {
534 bounding: buttons.paletteRight,
535 action: ()=> {
536 currentPalette++;
537 if(currentPalette >= palettes.length) currentPalette = 0;
538 savePrefs();
539 }
540 },
541 {
542 bounding: buttons.bppSwitch,
543 action: ()=> {
544 updateBpp(cameraVars.bppSwitch == 2 ? 1 : 2);
545 }
546 }
547 ]
548 },
549 uiHidden: {
550 elem: document.querySelector("#ui-hidden"),
551 buttons: [
552 {
553 bounding: buttons.hideUI,
554 action: ()=> {
555 // go back to main
556 currentUI = screens.uiMain;
557 }
558 }
559 ]
560 },
561 uiTimer: {
562 elem: document.querySelector("#ui-timer"),
563 buttons: []
564 },
565 uiRecord: {
566 elem: document.querySelector("#ui-record"),
567 buttons: []
568 }
569 };
570
571 // global settings for gbcamera
572 var renderWidth = 320,
573 renderHeight = 288,
574 currentPalette = 0,
575 currentUI = screens.uiMain;
576
577 var cameraVars = {
578 width: 128,
579 height: 112,
580 dither: 0.6,
581 contrast: 3,
582 gamma: 3,
583 sharpness: 3,
584 zoom: minCameraZoom,
585 trackZoomSupported: false,
586 xOffset: 0,
587 yOffset: 0,
588 xScale: 1,
589 yScale: 1,
590 bppSwitch: 2 // 2bpp = gameboy, 1bpp = atkinson, doubleres
591 };
592
593 // function to check if phone is portrait oriented
594 function screenIsPortrait() {
595 try {
596 let orientation = (screen.orientation || {}).type || screen.mozOrientation || screen.msOrientation;
597 if(orientation != undefined) {
598 if(orientation.includes('portrait')) return true;
599 } else if(window.orientation != undefined) {
600 if(window.orientation == 0) return true;
601 }
602 return false;
603 } catch(e) {
604 return false;
605 }
606 }
607
608 //Function to get the mouse position
609 function getMousePos(canvas, event) {
610 let rect = canvas.getBoundingClientRect();
611 let mousePos = {
612 x: (event.clientX - rect.left) / (rect.right - rect.left) * renderWidth / 2,
613 y: (event.clientY - rect.top) / (rect.bottom - rect.top) * renderHeight / 2
614 };
615 return mousePos;
616 }
617
618 function getTouchDistance(touchA, touchB) {
619 let deltaX = touchA.clientX - touchB.clientX;
620 let deltaY = touchA.clientY - touchB.clientY;
621 return Math.sqrt((deltaX * deltaX) + (deltaY * deltaY));
622 }
623 //Function to check whether a point is inside a rectangle
624 function isInside(pos, rect){
625 return pos.x > rect.x && pos.x < (rect.x+rect.width) && pos.y < (rect.y+rect.height) && pos.y > rect.y;
626 }
627
628 function applyTrackZoom() {
629 if (!window.stream || !window.stream.getVideoTracks) return;
630 let track = window.stream.getVideoTracks()[0];
631 if (!track || !track.getCapabilities || !track.applyConstraints) return;
632 let capabilities = track.getCapabilities();
633 if (!capabilities || !capabilities.zoom) {
634 cameraVars.trackZoomSupported = false;
635 return;
636 }
637 cameraVars.trackZoomSupported = true;
638 let zoom = clampNumber(cameraVars.zoom, capabilities.zoom.min, capabilities.zoom.max);
639 if (capabilities.zoom.step && capabilities.zoom.step > 0) {
640 let steps = Math.round((zoom - capabilities.zoom.min) / capabilities.zoom.step);
641 zoom = capabilities.zoom.min + (steps * capabilities.zoom.step);
642 }
643 cameraVars.zoom = zoom;
644 track.applyConstraints({
645 advanced: [{ zoom: zoom }]
646 }).catch((error) => {
647 console.warn('applyConstraints() zoom failed: ', error);
648 });
649 }
650
651 function setCameraZoom(zoom, save = true) {
652 let parsedZoom = Number(zoom);
653 if (Number.isNaN(parsedZoom)) return;
654 cameraVars.zoom = clampNumber(parsedZoom, minCameraZoom, maxCameraZoom);
655 if (save) {
656 localStorage.setItem("cameraZoom", cameraVars.zoom);
657 }
658 if (window.stream) {
659 applyTrackZoom();
660 initCameraDrawing(false);
661 }
662 }
663
664 function switchCameras() {
665 if(amountOfCameras > 1) {
666 if (currentFacingMode === 'environment') currentFacingMode = 'user';
667 else currentFacingMode = 'environment';
668 initCameraStream();
669 }
670 }
671
672 function download(filename, blob) {
673 var link = document.createElement('a');
674 link.href = URL.createObjectURL(blob);
675 link.download = filename;
676 link.style.display = 'none';
677 document.body.appendChild(link);
678 link.click();
679 document.body.removeChild(link);
680 }
681
682 function getFileDate() {
683 let now = new Date();
684 // i love javascript
685 let dateString = now.getDate() + "-" + (now.getMonth()+1) + "-"+ now.getFullYear() + " " + now.getHours() + " " + now.getMinutes() + " " + now.getSeconds();
686 return dateString;
687 }
688
689 function savePicture() {
690 let ctx = cameraOutput.getContext("2d");
691 ctx.drawImage(cameraView, 0,0, cameraOutput.width, cameraOutput.height);
692 Filters.filterImage(Filters.paletteSwap, cameraOutput, [palettes[currentPalette]])
693 cameraOutput.toBlob((blob) => {
694 download("webgbcam " + getFileDate() + ".png", blob);
695 }, 'image/png');
696 }
697
698 function loadPrefs() {
699 let localContrast = parseInt(localStorage.getItem("cameraContrast"));
700 let localGamma = parseInt(localStorage.getItem("cameraGamma"));
701 let localPalette = parseInt(localStorage.getItem("cameraPalette"));
702 let localSharpness = parseInt(localStorage.getItem("cameraSharpness"));
703 let localBpp = parseInt(localStorage.getItem("cameraBpp"));
704 let localZoom = parseFloat(localStorage.getItem("cameraZoom"));
705 cameraVars.contrast = (localContrast ? localContrast : 3);
706 cameraVars.gamma = (localGamma ? localGamma : 3);
707 cameraVars.sharpness = (localSharpness ? localSharpness : 3);
708 cameraVars.zoom = (Number.isNaN(localZoom) ? minCameraZoom : clampNumber(localZoom, minCameraZoom, maxCameraZoom));
709 updateBpp(localBpp ? localBpp : 2);
710 outputScale = (cameraVars.bppSwitch == 2 ? 6 : 3);
711 currentPalette = (localPalette ? localPalette : 0);
712 }
713
714 function savePrefs() {
715 localStorage.setItem("cameraContrast", cameraVars.contrast);
716 localStorage.setItem("cameraGamma", cameraVars.gamma);
717 localStorage.setItem("cameraSharpness", cameraVars.sharpness);
718 localStorage.setItem("cameraBpp", cameraVars.bppSwitch);
719 localStorage.setItem("cameraPalette", currentPalette);
720 localStorage.setItem("cameraZoom", cameraVars.zoom);
721 }
722
723 function updateBpp(bpp = 2) {
724 if (bpp == 1) {
725 // to 1bpp
726 cameraVars.bppSwitch = 1;
727 cameraVars.width = 256;
728 cameraVars.height = 224;
729 outputScale = 3;
730 } else {
731 // to 2bpp
732 cameraVars.bppSwitch = 2;
733 cameraVars.width = 128;
734 cameraVars.height = 112;
735 outputScale = 6;
736 }
737 cameraView.width = cameraVars.width;
738 cameraView.height = cameraVars.height;
739 initCameraDrawing(false);
740 }
741
742 function applyLevels(value, brightness, contrast, gamma) {
743 let newValue = value / 255.0;
744 newValue = (newValue - 0.5) * contrast + 0.5;
745 //newValue = newValue + brightness;
746 return Math.pow(clampNumber(newValue, 0, 1), gamma) * 255;
747 }
748
749 var Filters = {};
750 Filters.getPixels = function(c) {
751 return c.getContext('2d').getImageData(0,0,c.width,c.height);
752 };
753
754 Filters.filterImage = function(filter, canvas, var_args) {
755 let args = [this.getPixels(canvas)];
756 for (let i=0; i<var_args.length; i++) {
757 args.push(var_args[i]);
758 }
759 let idata = filter.apply(null, args);
760 canvas.getContext("2d").putImageData(idata, 0, 0);
761 };
762
763 function xyToIndex(x, y, width) {
764 return (x + y*width)*4;
765 }
766
767 Filters.grayscale = function(pixels) {
768 let d = pixels.data;
769
770 for (let i = 0; i < d.length; i += 4) {
771 let r = d[i];
772 let g = d[i+1];
773 let b = d[i+2];
774
775 // grayscale
776 let c = r*0.3 + g*0.59 + b*0.11;
777
778 // apply levels
779 c = clampNumber(applyLevels(c, 0, sliderContrast[cameraVars.contrast], sliderGamma[cameraVars.gamma]), 0, 255);
780
781 d[i] = c;
782 d[i+1] = c;
783 d[i+2] = c;
784 }
785
786 return pixels;
787 }
788
789 Filters.sharpen = function(pixels, sharpness) {
790 if (sharpness == 0) return pixels;
791 let d = pixels.data;
792 let temp_buf = [];
793
794 for(i = 0; i < pixels.width; i++) for(j = 0; j < pixels.height; j++)
795 {
796 let ms = d[xyToIndex(i, Math.min(j+1,pixels.height-1), pixels.width)]-128;
797 let mn = d[xyToIndex(i, Math.max(0,j-1), pixels.width)]-128;
798 let mw = d[xyToIndex(Math.max(0,i-1), j, pixels.width)]-128;
799 let me = d[xyToIndex(Math.min(i+1,pixels.width-1), j, pixels.width)]-128;
800 let px = d[xyToIndex(i, j, pixels.width)]-128;
801
802 temp_buf[xyToIndex(i, j, pixels.width)] = clampNumber(px+((4*px-mw-me-mn-ms)*sharpness), -128, 127)+128;
803 }
804 for (let i = 0; i < d.length; i += 4) {
805 d[i] = temp_buf[i];
806 d[i+1] = temp_buf[i];
807 d[i+2] = temp_buf[i];
808 }
809
810 return pixels;
811 }
812
813 Filters.gbcamera = function(pixels, ditherFactor) {
814 let d = pixels.data;
815
816 for(let y = 0; y < pixels.height; y++) {
817 for(let x = 0; x < pixels.width; x++) {
818 let i = xyToIndex(x, y, pixels.width);
819
820 let bayer = bayer8[(y)%8][(x)%8];
821 let c = d[i];
822
823 // apply bayer
824 c = clampNumber(c + ((bayer - 32) * ditherFactor), 0, 255);
825
826 // quantize to four places which will determine palette color
827 c = clampNumber(Math.round(c / 64), 0, 3) * 64;
828
829 d[i] = c;
830 d[i+1] = c;
831 d[i+2] = c;
832 }
833 }
834
835 return pixels;
836 }
837
838 // thanks to https://beyondloom.com/blog/dither.html
839 Filters.atkinson = function(pixels) {
840 let w = pixels.width;
841 let d = pixels.data;
842 const e=Array(2*w).fill(0), m=[0,1,w-2,w-1,w,2*w-1];
843 for (i = 0; i < d.length; i=i+4) {
844 let x = d[i]/256.0;
845 const pix=x+(e.push(0),e.shift()), col=pix>.5, err=(pix-col)/8;
846 m.forEach(x => e[x]+=err);
847 let c = col ? 255 : 0;
848 d[i] = c;
849 d[i+1] = c;
850 d[i+2] = c;
851 }
852 return pixels;
853 }
854
855 // takes grayscale and paints it with palette
856 Filters.paletteSwap = function(pixels, palette) {
857 let d = pixels.data;
858
859 for (let i = 0; i < d.length; i += 4) {
860 let c = clampNumber(Math.floor(d[i] / 64), 0, 3);
861
862 let r,g,b;
863 [r, g, b] = palette[c];
864
865 d[i] = r;
866 d[i+1] = g;
867 d[i+2] = b;
868 }
869
870 return pixels;
871 }
872
873 // this function counts the amount of video inputs
874 // it replaces DetectRTC that was previously implemented.
875 function deviceCount() {
876 return new Promise(function (resolve) {
877 var videoInCount = 0;
878
879 navigator.mediaDevices
880 .enumerateDevices()
881 .then(function (devices) {
882 devices.forEach(function (device) {
883 if (device.kind === 'video') {
884 device.kind = 'videoinput';
885 }
886
887 if (device.kind === 'videoinput') {
888 videoInCount++;
889 //console.log('videocam: ' + device.label);
890 }
891 });
892
893 resolve(videoInCount);
894 })
895 .catch(function (err) {
896 console.log(err.name + ': ' + err.message);
897 resolve(0);
898 });
899 });
900 }
901
902 document.addEventListener('DOMContentLoaded', function (event) {
903 // check if mediaDevices is supported
904 if (
905 navigator.mediaDevices &&
906 navigator.mediaDevices.getUserMedia &&
907 navigator.mediaDevices.enumerateDevices
908 ) {
909 // first we call getUserMedia to trigger permissions
910 // we need this before deviceCount, otherwise Safari doesn't return all the cameras
911 // we need to have the number in order to display the switch front/back button
912 navigator.mediaDevices
913 .getUserMedia({
914 audio: false,
915 video: true,
916 })
917 .then(function (stream) {
918 stream.getTracks().forEach(function (track) {
919 track.stop();
920 });
921
922 deviceCount().then(function (deviceCount) {
923 amountOfCameras = deviceCount;
924
925 // init the UI and the camera stream
926 initCameraUI();
927 initCameraStream();
928 });
929 })
930 .catch(function (error) {
931 //https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
932 if (error.name === 'NotAllowedError') {
933 alert('camera permission denied, please refresh and allow camera capture!');
934 } else if (error.name === 'NotFoundError') {
935 alert('no cameras found! make sure your webcam is plugged in and enabled!');
936 } else {
937 alert('unspecified camera error! make sure camera permissions are enabled!');
938 }
939
940 console.error('getUserMedia() error: ', error);
941 });
942 } else {
943 alert(
944 'it seems your browser does not support camera capture! :(',
945 );
946 }
947 loadPrefs();
948 });
949
950 function restartCamera() {
951 clearTimeout(resizeTimeout);
952 resizeTimeout = setTimeout(function() {
953 initAppScaling();
954 initCameraDrawing();
955 }, 300);
956 }
957
958 //window.onorientationchange = restartCamera;
959 //window.onresize = restartCamera;
960
961 function captureImage() {
962 cameraStream.pause();
963 currentUI = screens.uiCapture;
964 }
965
966 function initCameraUI() {
967 initAppScaling();
968
969 const queryString = window.location.search;
970 const urlParams = new URLSearchParams(queryString);
971 if (urlParams.has('hideui')) {
972 currentUI = screens.uiHidden;
973 }
974 if (urlParams.has('zoom')) {
975 setCameraZoom(parseFloat(urlParams.get('zoom')), false);
976 }
977
978 // handle canvas app clicks
979 appView.addEventListener('click', function(e) {
980 var mousePos = getMousePos(appView, e);
981
982 currentUI.buttons.forEach((button) => {
983 if (isInside(mousePos, button.bounding)) {
984 button.action();
985 }
986 });
987
988 }, false);
989
990 appView.addEventListener('touchstart', function(e) {
991 if (e.touches.length === 2) {
992 pinchStartDistance = getTouchDistance(e.touches[0], e.touches[1]);
993 pinchStartZoom = cameraVars.zoom;
994 }
995 }, { passive: false });
996
997 appView.addEventListener('touchmove', function(e) {
998 if (e.touches.length !== 2 || pinchStartDistance === null) return;
999 e.preventDefault();
1000 let currentDistance = getTouchDistance(e.touches[0], e.touches[1]);
1001 let zoomFromPinch = pinchStartZoom * (currentDistance / pinchStartDistance);
1002 if (Math.abs(zoomFromPinch - cameraVars.zoom) >= 0.03) {
1003 setCameraZoom(zoomFromPinch);
1004 }
1005 }, { passive: false });
1006
1007 appView.addEventListener('touchend', function(e) {
1008 if (e.touches.length < 2) {
1009 pinchStartDistance = null;
1010 }
1011 }, false);
1012 }
1013
1014 function initAppScaling(scale = 2) {
1015 appScale = scale;
1016
1017 // canvas sizes
1018 cameraView.width = cameraVars.width;
1019 cameraView.height = cameraVars.height;
1020 appView.width = renderWidth * appScale;
1021 appView.height = renderHeight * appScale;
1022
1023 let ctx = appView.getContext("2d");
1024 ctx.imageSmoothingEnabled = false;
1025 ctx.scale(appScale,appScale);
1026 ctx = cameraView.getContext("2d");
1027 ctx.imageSmoothingEnabled = false;
1028 }
1029
1030 // https://github.com/webrtc/samples/blob/gh-pages/src/content/devices/input-output/js/main.js
1031 function initCameraStream() {
1032 // stop any active streams in the window
1033 if (window.stream) {
1034 window.stream.getTracks().forEach(function (track) {
1035 //console.log(track);
1036 track.stop();
1037 });
1038 }
1039
1040 var constraints = {
1041 audio: false,
1042 video: {
1043 width: { ideal: 640 },
1044 height: { ideal: 480 },
1045 facingMode: currentFacingMode,
1046 },
1047 };
1048
1049 function handleSuccess(stream) {
1050 if (stream) {
1051 window.stream = stream; // make stream available to browser console
1052 cameraStream.srcObject = stream;
1053
1054 let track = window.stream.getVideoTracks()[0];
1055 cameraStream.width = track.getSettings().width;
1056 cameraStream.height = track.getSettings().height;
1057 applyTrackZoom();
1058
1059 setTimeout(initCameraDrawing, 500);
1060 }
1061 }
1062
1063 function handleError(error) {
1064 console.error('getUserMedia() error: ', error);
1065 }
1066
1067 navigator.mediaDevices
1068 .getUserMedia(constraints)
1069 .then(handleSuccess)
1070 .catch(handleError);
1071 }
1072
1073 function initCameraDrawing(start = true) {
1074 // if cameraStream has vertical or horizontal resolution of 0 then it's not initialized, we retry until the browser decides to properly work
1075 if (cameraStream.videoHeight == 0) setTimeout(restartCamera, 500);
1076
1077 const track = window.stream.getVideoTracks()[0];
1078 let settings = track.getSettings();
1079 //let str = JSON.stringify(settings, null, 4);
1080 //console.log('settings ' + str);
1081
1082 // calculate scale and offset to render camera stream to camera view canvas
1083 if(cameraStream.videoWidth >= cameraStream.videoHeight) {
1084 // horizontal
1085 cameraVars.yScale = cameraStream.videoHeight;
1086 cameraVars.xScale = Math.floor((cameraStream.videoHeight / cameraVars.height) * cameraVars.width);
1087 cameraVars.yOffset = 0;
1088 cameraVars.xOffset = Math.floor((cameraStream.videoWidth - cameraVars.xScale) / 2);
1089 } else {
1090 //vertical
1091 cameraVars.xScale = cameraStream.videoWidth;
1092 cameraVars.yScale = Math.floor((cameraStream.videoWidth / cameraVars.width) * cameraVars.height);
1093 cameraVars.xOffset = 0;
1094 cameraVars.yOffset = Math.floor((cameraStream.videoHeight - cameraVars.yScale) / 2);
1095 }
1096
1097 // If track zoom is unavailable, apply digital center-crop zoom.
1098 if (!cameraVars.trackZoomSupported && cameraVars.zoom > minCameraZoom) {
1099 let zoomedXScale = Math.max(1, Math.floor(cameraVars.xScale / cameraVars.zoom));
1100 let zoomedYScale = Math.max(1, Math.floor(cameraVars.yScale / cameraVars.zoom));
1101 cameraVars.xOffset += Math.floor((cameraVars.xScale - zoomedXScale) / 2);
1102 cameraVars.yOffset += Math.floor((cameraVars.yScale - zoomedYScale) / 2);
1103 cameraVars.xScale = zoomedXScale;
1104 cameraVars.yScale = zoomedYScale;
1105 }
1106
1107 // canvas starts flipped for user facing camera
1108 if(settings.facingMode != "environment") { // not environment = front-facing phone cam or pc webcam, flip
1109 cameraView.getContext('2d').setTransform(-1, 0, 0, 1, cameraVars.width, 0);
1110 } else {
1111 cameraView.getContext('2d').setTransform(1, 0, 0, 1, 0, 0);
1112 }
1113 //console.log(cameraVars);
1114
1115 cameraOutput.width = cameraVars.width * outputScale;
1116 cameraOutput.height = cameraVars.height * outputScale;
1117 let ctx = cameraOutput.getContext("2d");
1118 ctx.imageSmoothingEnabled = false;
1119
1120 if (start) {
1121 cameraStream.play();
1122 clearInterval(frameDrawing)
1123 frameDrawing = setInterval(drawFrame, 100);
1124 }
1125 }
1126
1127 function showGifModal() {
1128 gifPreview.classList.remove("hidden");
1129 }
1130
1131 function loadGifModal(blob) {
1132 gifImg.src = URL.createObjectURL(blob);
1133 gifBlob = blob;
1134 gifButtons.classList.remove("hidden");
1135 }
1136
1137 function downloadGif() {
1138 download("webgbcam " + getFileDate() + ".gif", gifBlob);
1139 resetGifModal();
1140 }
1141
1142 function resetGifModal() {
1143 gifBlob = null;
1144 gifImg.src = "loading.gif";
1145 gifPreview.classList.add("hidden");
1146 gifButtons.classList.add("hidden");
1147 }
1148
1149 function gifStart() {
1150 gifEncoder = new GIF({
1151 workers: 2,
1152 workerScript: 'gifjs/gif.worker.js',
1153 quality: 10,
1154 repeat: 0,
1155 width: cameraOutput.width,
1156 height: cameraOutput.height
1157 });
1158 gifEncoder.on('finished', function(blob) {
1159 loadGifModal(blob);
1160 //download("webgbcam " + getFileDate() + ".gif", URL.createObjectURL(blob));
1161 });
1162 gifFrames = gifLength;
1163 currentUI = screens.uiRecord;
1164 gifRecording = true;
1165 }
1166
1167 function gifEnd() {
1168 gifRecording = false;
1169 currentUI = screens.uiMain;
1170 gifEncoder.render();
1171 showGifModal();
1172 }
1173
1174 function gifFrame() {
1175 let ctx = cameraOutput.getContext("2d");
1176 Filters.filterImage(Filters.paletteSwap, cameraView, [palettes[currentPalette]])
1177 ctx.drawImage(cameraView, 0,0, cameraOutput.width, cameraOutput.height);
1178 gifEncoder.addFrame(ctx, {delay: 100, copy: true});
1179 if(--gifFrames == 0) gifEnd();
1180 }
1181
1182 function scaledFillRect(ctx, x, y, width, height) {
1183 const scale = 2;
1184 ctx.fillRect(x * scale, y * scale, width * scale, height * scale);
1185 }
1186
1187 function drawFrame() {
1188 let camctx = cameraView.getContext('2d');
1189 camctx.drawImage(cameraStream, cameraVars.xOffset, cameraVars.yOffset, cameraVars.xScale, cameraVars.yScale, 0, 0, cameraVars.width, cameraVars.height);
1190
1191 Filters.filterImage(Filters.grayscale, cameraView, []);
1192 Filters.filterImage(Filters.sharpen, cameraView, [sliderSharpness[cameraVars.sharpness]]);
1193
1194 if (cameraVars.bppSwitch == 2) {
1195 Filters.filterImage(Filters.gbcamera, cameraView, [cameraVars.dither]);
1196 } else {
1197 Filters.filterImage(Filters.atkinson, cameraView, []);
1198 }
1199
1200 let ctx = appView.getContext("2d");
1201 ctx.drawImage(cameraView, 32, 32, 256, 224);
1202 ctx.drawImage(currentUI.elem, 0, 0, 160, 144, 0, 0, 320, 288);
1203
1204 if (currentUI === screens.uiSettings) {
1205 // update settings values
1206 ctx.fillStyle = "rgb(192,192,192)";
1207 for(let i = 1; i <= cameraVars.contrast; i++) {
1208 scaledFillRect(ctx, 42, 22 - (i*3), 4, 2);
1209 }
1210 for(let i = 1; i <= cameraVars.gamma; i++) {
1211 scaledFillRect(ctx, 97, 22 - (i*3), 4, 2);
1212 }
1213 for(let i = 1; i <= cameraVars.sharpness; i++) {
1214 scaledFillRect(ctx, 152, 135 - (i*3), 4, 2);
1215 }
1216
1217 ctx.fillStyle = "rgb(130,130,130)";
1218 if (cameraVars.bppSwitch == 2) {
1219 scaledFillRect(ctx, 70, 135, 4, 4);
1220 } else {
1221 scaledFillRect(ctx, 65, 135, 4, 4);
1222 }
1223 } else if (currentUI === screens.uiRecord) {
1224 // update record length
1225 ctx.fillStyle = "rgb(64,64,64)";
1226 scaledFillRect(ctx, 25, 134, 110 - (gifFrames / gifLength * 110), 6);
1227 }
1228
1229 try {
1230 Filters.filterImage(Filters.paletteSwap, appView, [palettes[currentPalette]])
1231 } catch(e) {
1232
1233 }
1234 if (gifRecording) gifFrame();
1235 }
1236
1237 function toggleAbout() {
1238 const elemAbout = document.getElementById("about");
1239
1240 if (elemAbout.classList.contains("hidden")) {
1241 elemAbout.classList.remove("hidden");
1242 } else {
1243 elemAbout.classList.add("hidden");
1244 }
1245 }