├── .gitignore ├── README.md ├── app ├── ImageOpener.js ├── capture-image.html ├── capture-image.js ├── cssgram.min.css ├── index.html ├── index.js ├── lightbroom.css ├── lightbroom.js ├── package.json ├── photo.js ├── svg-image.js ├── transfer-image.js └── vendor │ └── exif.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Lightbroom 2 | -- 3 | 4 | Back in 2003, I created [this repository](https://github.com/zz85/lightbroom/commit/124270abe79588366e7bd041f32851aa548ddbc9) because I had some [wild ideas](https://plus.google.com/117614030945250277079/posts/LcApD7CFF86) of building a better replacement to my lightroom workflow... (yeah it is never that easy but one can always dream...) 5 | 6 | ## Current Features 7 | 8 | - Drag drop photos 9 | - Mass preview and apply filters 10 | - Mass export 11 | 12 | ## Running the app 13 | 14 | `electron app.js` 15 | 16 | ### Prerequisite 17 | 18 | Electron 19 | 20 | `npm install -g electron-prebuilt` 21 | 22 | Node modules 23 | 24 | `npm install --production` 25 | 26 | ## Current State 27 | 28 | An Instagram-like desktop app developed with Electron and Web technologies for a simple photo retouching workflow. 29 | This iteration uses [CSSgram](https://github.com/una/CSSgram/) (that uses GPU accelerated CSS filters and blend modes) for Instagram like effects. 30 | 31 | ![screenshot 2015-12-30 04 36 58](https://cloud.githubusercontent.com/assets/314997/12041835/1473add4-aeb0-11e5-8a52-85bd959d062c.png) 32 | 33 | ![lightbroom1](https://cloud.githubusercontent.com/assets/314997/12041841/282d7f62-aeb0-11e5-841b-726cc98bae1c.gif) 34 | 35 | ## Contact 36 | [@blurspline](http://twitter.com/blurspline) 37 | -------------------------------------------------------------------------------- /app/ImageOpener.js: -------------------------------------------------------------------------------- 1 | // 2 | // this is a simple image opener util file that supports 3 | // image loading via 4 | // 1. drag and drop 5 | // 2. browsing 6 | // 3. TODO clipboard paste 7 | // Load 3. Integration as JS file for demo 8 | 9 | function ImageOpener( processImage, target ) { 10 | 11 | var debug = true; 12 | // Check for the various File API support. 13 | if (window.File && window.FileReader && window.FileList && window.Blob) { 14 | // Great success! All the File APIs are supported. 15 | window.URL = window.URL || window.webkitURL; 16 | } else { 17 | return console.error('The File APIs are not fully supported in this browser.'); 18 | } 19 | 20 | target = target || document.body; 21 | 22 | // Input Field 23 | var input = document.createElement('input'); 24 | input.type = 'file'; 25 | input.addEventListener('change', handleFileSelect, false); 26 | this.input = input; 27 | 28 | // target.appendChild(input); 29 | 30 | target.onpaste = handlepaste; 31 | 32 | /*************************************** 33 | Event handlers for file load 34 | ****************************************/ 35 | function handleFileSelect(evt) { 36 | var files = evt.target.files; // FileList object 37 | 38 | // files is a FileList of File objects. List some properties. 39 | for (var i = 0, f; f = files[i]; i++) { 40 | if (debug) { 41 | console.log( 'detected', f.name, f.type, 42 | f.size, ' bytes, last modified: ', 43 | f.lastModifiedDate ? f.lastModifiedDate.toLocaleDateString() : 'n/a'); 44 | } 45 | 46 | openFile(files[i], i); 47 | } 48 | } 49 | 50 | function handlepaste (e) { 51 | if (e && e.clipboardData && e.clipboardData.getData) { 52 | // Webkit - get data from clipboard, put into editdiv, cleanup, then cancel event 53 | // ii = e.clipboardData.getData('jpg/image'); 54 | var items = e.clipboardData.items; 55 | 56 | var i = 0; 57 | if (items[i].kind == 'file' && 58 | items[i].type.indexOf('image/') !== -1) { 59 | var blob = items[i].getAsFile(); 60 | var blobUrl = URL.createObjectURL(blob); 61 | 62 | var img = document.createElement('img'); 63 | img.src = blobUrl; 64 | img.onload = function(e) { 65 | processImage(img); 66 | }; 67 | } 68 | 69 | // console.log(e, e.clipboardData.getData('text/plain')); 70 | if (e.preventDefault) { 71 | e.stopPropagation(); 72 | e.preventDefault(); 73 | } 74 | return false; 75 | } 76 | } 77 | /*************************************** 78 | Event handlers for drag and drop 79 | ****************************************/ 80 | target.addEventListener('dragover', handleDragOver, false); 81 | target.addEventListener('dragenter', stopDefault, false); 82 | target.addEventListener('dragexit', stopDefault, false); 83 | 84 | target.addEventListener('drop', dropBehavior, false); 85 | 86 | /*************************************** 87 | Event Callbacks for drag and drop 88 | ****************************************/ 89 | 90 | 91 | function handleDragOver(e) { 92 | if (e.dataTransfer.files) { 93 | e.stopPropagation(); 94 | e.preventDefault(); 95 | e.dataTransfer.dropEffect = 'copy'; // Explicitly show this is a copy. 96 | } 97 | } 98 | 99 | 100 | function stopDefault(e) { 101 | e.preventDefault(); 102 | return false; 103 | } 104 | 105 | function dropBehavior(e) { 106 | var files = e.dataTransfer.files; 107 | 108 | if (files) { 109 | e.stopPropagation(); 110 | e.preventDefault(); 111 | } else { 112 | console.log('Files are dropped', e); 113 | return; 114 | } 115 | 116 | if (files.length) { 117 | for (var i = 0; i < files.length; i++) { 118 | openFile(files[i], i); 119 | } 120 | } else { 121 | // TODO support copypaste/clipboard drag in 122 | } 123 | } 124 | 125 | function openFile(file, i) { 126 | if (!file.type.match('image.*')) { 127 | if (debug) console.log('image fail'); 128 | return; 129 | } 130 | 131 | var reader = new FileReader(); 132 | reader.onloadend = function(e) { 133 | loadImage(e, file.path, i); 134 | }; 135 | 136 | reader.onerror = errorHandler; 137 | 138 | reader.onprogress = updateProgress; 139 | reader.onabort = function(e) { 140 | console.log('File read cancelled'); 141 | }; 142 | 143 | reader.onloadstart = function(e) { 144 | console.log('onloadstart'); 145 | }; 146 | 147 | reader.onload = function(e) { 148 | console.log('onload'); 149 | }; 150 | 151 | reader.readAsArrayBuffer(file); 152 | // reader.readAsBinaryString(file); 153 | // reader.readAsDataURL(file); 154 | } 155 | 156 | function loadImage(e, path, i) { 157 | var arraybuffer = e.target.result; 158 | // console.log('loadImage', e); // e, target, reader, e.target.result 159 | 160 | var blob = new Blob([arraybuffer]); 161 | var objectURL = URL.createObjectURL(blob); 162 | 163 | var img = document.createElement("img"); 164 | img.src = objectURL; 165 | 166 | img.onload = function(e) { 167 | processImage(img, path, i); 168 | }; 169 | } 170 | 171 | 172 | function updateProgress(e) { 173 | // evt is an ProgressEvent. 174 | if (e.lengthComputable) { 175 | var percentLoaded = Math.round((e.loaded / e.total) * 100); 176 | 177 | if (percentLoaded < 100) { 178 | console.log(percentLoaded + '%'); 179 | } 180 | } 181 | } 182 | 183 | 184 | function errorHandler(e) { 185 | switch (e.target.error.code) { 186 | case e.target.error.NOT_FOUND_ERR: 187 | alert('File Not Found!'); 188 | break; 189 | case e.target.error.NOT_READABLE_ERR: 190 | alert('File is not readable'); 191 | break; 192 | case e.target.error.ABORT_ERR: 193 | break; // noop 194 | default: 195 | alert('An error occurred reading this file.' + e); 196 | } 197 | } 198 | 199 | } -------------------------------------------------------------------------------- /app/capture-image.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /app/capture-image.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const electron = require('electron'); 4 | const remote = electron.remote; 5 | const nativeImage = electron.nativeImage 6 | const fs = require('fs'); 7 | 8 | let win = remote.getCurrentWindow(); 9 | 10 | var figure = document.getElementById('figure'); 11 | function load (photo, style, filename, longest, orientation) { 12 | while (figure.firstChild) { 13 | figure.removeChild(figure.firstChild); 14 | } 15 | 16 | console.time('load'); 17 | 18 | if (longest) figure.style.cssText = `max-width: ${longest}px; max-height: ${longest}px;`; 19 | 20 | var img = new Image(); 21 | img.src = photo; 22 | 23 | figure.appendChild(img); 24 | figure.className = style; 25 | 26 | console.log('orientation', arguments); 27 | 28 | if (orientation) figure.classList.add(`orientation${orientation}`); 29 | 30 | 31 | img.onload = () => { 32 | console.timeEnd('load'); 33 | console.time('raf'); 34 | 35 | // if (!longest) { 36 | // longest = 'auto'; 37 | // figure.style.cssText = `max-width: ${longest}px; max-height: ${longest}px;`; 38 | // } 39 | 40 | if (orientation > 1) { 41 | let b = img.getBoundingClientRect(); 42 | console.log(b); 43 | 44 | figure.style.cssText += ` 45 | transform: 46 | translateX(${-b.left}px) 47 | translateY(${-b.top}px) 48 | rotate(-90deg) 49 | 50 | ; 51 | `; 52 | // if (b.width > b.height) { 53 | // var diff = b.height - b.width / 2; 54 | // figure.style.cssText += ` 55 | // transform: 56 | // translateY(${diff}px) 57 | // translateX(${-diff}px) 58 | // rotate(-90deg); 59 | // `; 60 | // } 61 | } 62 | 63 | var count = 0; 64 | var wait = function() { 65 | count++; 66 | if (count < 3 || win.isLoading()) { 67 | // not sure if this actually is useful. 68 | // 'did-stop-loading' perhaps naturalWidth can give clues too? 69 | return requestAnimationFrame(wait); 70 | } 71 | console.timeEnd('raf') 72 | console.log(filename, img); 73 | save(filename, img); 74 | } 75 | requestAnimationFrame(wait); 76 | }; 77 | } 78 | 79 | function save(filename, img) { 80 | let b = img.getBoundingClientRect(); 81 | 82 | let o = { 83 | x: b.left, 84 | y: b.top, 85 | width: b.width, 86 | height: b.height 87 | }; 88 | 89 | console.log(o); 90 | 91 | // caveat - bounds must entirely be inside chrome's viewport!! 92 | win.capturePage(o, img => { 93 | // console.log(img.getSize()) 94 | // fs.writeFileSync('screenshot.png', img.toPng()); 95 | fs.writeFileSync(filename, img.toJpeg(98)); 96 | 97 | // turn this off if you need debugging 98 | win.close(); 99 | 100 | // // could make an ipc call here instead. 101 | // electron.ipcRenderer.send('signal', 'done'); 102 | }); 103 | } -------------------------------------------------------------------------------- /app/cssgram.min.css: -------------------------------------------------------------------------------- 1 | .aden{-webkit-filter:hue-rotate(-20deg) contrast(.9) saturate(.85) brightness(1.2);filter:hue-rotate(-20deg) contrast(.9) saturate(.85) brightness(1.2)}.aden::after{background:-webkit-linear-gradient(left,rgba(66,10,14,.2),transparent);background:linear-gradient(to right,rgba(66,10,14,.2),transparent);mix-blend-mode:darken}.inkwell{-webkit-filter:sepia(.3) contrast(1.1) brightness(1.1) grayscale(1);filter:sepia(.3) contrast(1.1) brightness(1.1) grayscale(1)}.perpetua::after{background:-webkit-linear-gradient(top,#005b9a,#e6c13d);background:linear-gradient(to bottom,#005b9a,#e6c13d);mix-blend-mode:soft-light;opacity:.5}.reyes{-webkit-filter:sepia(.22) brightness(1.1) contrast(.85) saturate(.75);filter:sepia(.22) brightness(1.1) contrast(.85) saturate(.75)}.reyes::after{background:#efcdad;mix-blend-mode:soft-light;opacity:.5}.gingham{-webkit-filter:brightness(1.05) hue-rotate(-10deg);filter:brightness(1.05) hue-rotate(-10deg)}.gingham::after{background:-webkit-linear-gradient(left,rgba(66,10,14,.2),transparent);background:linear-gradient(to right,rgba(66,10,14,.2),transparent);mix-blend-mode:darken}.toaster{-webkit-filter:contrast(1.5) brightness(.9);filter:contrast(1.5) brightness(.9)}.toaster::after{background:-webkit-radial-gradient(circle,#804e0f,#3b003b);background:radial-gradient(circle,#804e0f,#3b003b);mix-blend-mode:screen}.walden{-webkit-filter:brightness(1.1) hue-rotate(-10deg) sepia(.3) saturate(1.6);filter:brightness(1.1) hue-rotate(-10deg) sepia(.3) saturate(1.6)}.walden::after{background:#04c;mix-blend-mode:screen;opacity:.3}.hudson{-webkit-filter:brightness(1.2) contrast(.9) saturate(1.1);filter:brightness(1.2) contrast(.9) saturate(1.1)}.hudson::after{background:-webkit-radial-gradient(circle,#a6b1ff 50%,#342134);background:radial-gradient(circle,#a6b1ff 50%,#342134);mix-blend-mode:multiply;opacity:.5}.earlybird{-webkit-filter:contrast(.9) sepia(.2);filter:contrast(.9) sepia(.2)}.earlybird::after{background:-webkit-radial-gradient(circle,#d0ba8e 20%,#360309 85%,#1d0210 100%);background:radial-gradient(circle,#d0ba8e 20%,#360309 85%,#1d0210 100%);mix-blend-mode:overlay}.mayfair{-webkit-filter:contrast(1.1) saturate(1.1);filter:contrast(1.1) saturate(1.1)}.mayfair::after{background:-webkit-radial-gradient(40% 40%,circle,rgba(255,255,255,.8),rgba(255,200,200,.6),#111 60%);background:radial-gradient(circle at 40% 40%,rgba(255,255,255,.8),rgba(255,200,200,.6),#111 60%);mix-blend-mode:overlay;opacity:.4}.lofi{-webkit-filter:saturate(1.1) contrast(1.5);filter:saturate(1.1) contrast(1.5)}.lofi::after{background:-webkit-radial-gradient(circle,transparent 70%,#222 150%);background:radial-gradient(circle,transparent 70%,#222 150%);mix-blend-mode:multiply}._1977{-webkit-filter:contrast(1.1) brightness(1.1) saturate(1.3);filter:contrast(1.1) brightness(1.1) saturate(1.3)}._1977:after{background:rgba(243,106,188,.3);mix-blend-mode:screen}.brooklyn{-webkit-filter:contrast(.9) brightness(1.1);filter:contrast(.9) brightness(1.1)}.brooklyn::after{background:-webkit-radial-gradient(circle,rgba(168,223,193,.4) 70%,#c4b7c8);background:radial-gradient(circle,rgba(168,223,193,.4) 70%,#c4b7c8);mix-blend-mode:overlay}.xpro2{-webkit-filter:sepia(.3);filter:sepia(.3)}.xpro2::after{background:-webkit-radial-gradient(circle,#e6e7e0 40%,rgba(43,42,161,.6) 110%);background:radial-gradient(circle,#e6e7e0 40%,rgba(43,42,161,.6) 110%);mix-blend-mode:color-burn}.nashville{-webkit-filter:sepia(.2) contrast(1.2) brightness(1.05) saturate(1.2);filter:sepia(.2) contrast(1.2) brightness(1.05) saturate(1.2)}.nashville::after{background:rgba(0,70,150,.4);mix-blend-mode:lighten}.nashville::before{background:rgba(247,176,153,.56);mix-blend-mode:darken}.lark{-webkit-filter:contrast(.9);filter:contrast(.9)}.lark::after{background:rgba(242,242,242,.8);mix-blend-mode:darken}.lark::before{background:#22253f;mix-blend-mode:color-dodge}.moon{-webkit-filter:grayscale(1) contrast(1.1) brightness(1.1);filter:grayscale(1) contrast(1.1) brightness(1.1)}.moon::before{background:#a0a0a0;mix-blend-mode:soft-light}.moon::after{background:#383838;mix-blend-mode:lighten}.clarendon{-webkit-filter:contrast(1.2) saturate(1.35);filter:contrast(1.2) saturate(1.35)}.clarendon:before{background:rgba(127,187,227,.2);mix-blend-mode:overlay}._1977:after,._1977:before,.aden:after,.aden:before,.brooklyn:after,.brooklyn:before,.clarendon:after,.clarendon:before,.earlybird:after,.earlybird:before,.gingham:after,.gingham:before,.hudson:after,.hudson:before,.inkwell:after,.inkwell:before,.lark:after,.lark:before,.lofi:after,.lofi:before,.mayfair:after,.mayfair:before,.moon:after,.moon:before,.nashville:after,.nashville:before,.perpetua:after,.perpetua:before,.reyes:after,.reyes:before,.toaster:after,.toaster:before,.walden:after,.walden:before,.willow:after,.willow:before,.xpro2:after,.xpro2:before{content:'';display:block;height:100%;width:100%;top:0;left:0;position:absolute;pointer-events:none}._1977,.aden,.brooklyn,.clarendon,.earlybird,.gingham,.hudson,.inkwell,.lark,.lofi,.mayfair,.moon,.nashville,.perpetua,.reyes,.toaster,.walden,.willow,.xpro2{position:relative}._1977 img,.aden img,.brooklyn img,.clarendon img,.earlybird img,.gingham img,.hudson img,.inkwell img,.lark img,.lofi img,.mayfair img,.moon img,.nashville img,.perpetua img,.reyes img,.toaster img,.walden img,.willow img,.xpro2 img{width:100%;z-index:1}._1977:before,.aden:before,.brooklyn:before,.clarendon:before,.earlybird:before,.gingham:before,.hudson:before,.inkwell:before,.lark:before,.lofi:before,.mayfair:before,.moon:before,.nashville:before,.perpetua:before,.reyes:before,.toaster:before,.walden:before,.willow:before,.xpro2:before{z-index:2}._1977:after,.aden:after,.brooklyn:after,.clarendon:after,.earlybird:after,.gingham:after,.hudson:after,.inkwell:after,.lark:after,.lofi:after,.mayfair:after,.moon:after,.nashville:after,.perpetua:after,.reyes:after,.toaster:after,.walden:after,.willow:after,.xpro2:after{z-index:3}.willow{-webkit-filter:grayscale(.5) contrast(.95) brightness(.9);filter:grayscale(.5) contrast(.95) brightness(.9)}.willow::before{background-color:radial-gradient(40%,circle,#d4a9af 55%,#000 150%);mix-blend-mode:overlay}.willow::after{background-color:#d8cdcb;mix-blend-mode:color} -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | Lightbroom Preview 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | Long side: 40 | 41 | 42 | 43 | 44 | 45 |
46 | Filmstrip: 47 | 48 | 49 | 50 |
51 | 52 |
53 | Panels: 54 | 55 | 56 | 57 |
58 | 59 |
60 | 61 | Drag photos into the app to start. 62 | 63 | 64 | 65 | 66 | 67 |
68 | 69 |
70 | 71 | 72 | 73 |
74 |
75 |
76 |
77 | 78 |
79 | 80 |
81 | 82 | 83 |
84 |
85 | 86 |
87 |
88 |
89 | 90 |
91 | 92 |
93 |
94 |
95 |
96 | 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const electron = require('electron'); 3 | const app = electron.app; 4 | 5 | // prevent window being garbage collected 6 | let mainWindow; 7 | 8 | function onClosed() { 9 | mainWindow = null; 10 | } 11 | 12 | function createMainWindow() { 13 | const win = new electron.BrowserWindow({ 14 | width: 600, 15 | height: 400 16 | }); 17 | 18 | win.loadURL(`file://${__dirname}/index.html`); 19 | win.on('closed', onClosed); 20 | 21 | // win.webContents.openDevTools(); 22 | 23 | return win; 24 | } 25 | 26 | app.on('window-all-closed', () => { 27 | if (process.platform !== 'darwin') { 28 | app.quit(); 29 | } 30 | }); 31 | 32 | app.on('activate-with-no-open-windows', () => { 33 | if (!mainWindow) { 34 | mainWindow = createMainWindow(); 35 | } 36 | }); 37 | 38 | app.on('ready', () => { 39 | mainWindow = createMainWindow(); 40 | }); 41 | -------------------------------------------------------------------------------- /app/lightbroom.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Lato', sans-serif; 3 | background-color: #333740; 4 | color: #ddd; 5 | font-size: 13px; 6 | margin: 0; 7 | padding: 0; 8 | outline: none; 9 | 10 | display: flex; 11 | flex-direction: column; 12 | } 13 | 14 | html, body { 15 | height: 100%; 16 | } 17 | 18 | #topbar { 19 | background: #555862; 20 | box-shadow: 0px 1px 0px 0px #24272D; 21 | width: 100%; 22 | height: 50px; 23 | display: flex; 24 | } 25 | 26 | #save-options { 27 | transition: width 400ms ease-out; 28 | width: 90px; 29 | display: inline-block; 30 | overflow: hidden; 31 | } 32 | 33 | #save-options:hover { 34 | width: 670px; 35 | } 36 | 37 | .peek { 38 | max-height: 120px; 39 | max-width: 120px; 40 | float: left; 41 | margin: 2px; 42 | } 43 | 44 | .nofilter img { 45 | width: 100%; 46 | } 47 | 48 | .selected-effect { 49 | border: 2px solid #ccc; 50 | box-sizing: border-box; 51 | } 52 | 53 | .selected-effect:hover { 54 | border: 2px solid #fff; 55 | } 56 | 57 | #filter-wrapper { 58 | overflow: scroll; 59 | background-color: #23262C; 60 | box-shadow: 0px 1px 0px 0px rgba(255,255,255,0.30); 61 | font-size: 13px; 62 | transition: height 250ms; 63 | height: 128px; 64 | } 65 | 66 | #filters { 67 | padding: 14px; 68 | display: flex; 69 | } 70 | 71 | button { 72 | font-family: 'Lato', sans-serif; 73 | padding: 10px; 74 | margin: 4px 4px 14px 4px; 75 | background-image: linear-gradient(-180deg, #7B7E87 0%, #555862 100%); 76 | border: 1px solid #292B31; 77 | box-shadow: 0px 1px 5px 0px rgba(0, 0, 0, 0.30), inset 0px 1px 0px 0px rgba(255, 255, 255, 0.60); 78 | border-radius: 4px; 79 | color: #eaeaea; 80 | font-size: 13px; 81 | 82 | text-overflow: ellipsis; 83 | white-space: nowrap; 84 | outline: none; 85 | } 86 | 87 | .effect { 88 | width: 130px; 89 | display: block; 90 | text-align: center; 91 | font-weight: bold; 92 | display: block; 93 | } 94 | 95 | button:active, button.active { 96 | /*box-shadow: 0px 1px 1px 0px rgba(255, 255, 255, 0.30), inset 0px 0px 4px 0px rgba(0, 0, 0, 0.80);*/ 97 | background: #23262C; 98 | border: 1px solid #000000; 99 | box-shadow: 0px 1px 1px 0px rgba(255,255,255,0.50); 100 | } 101 | 102 | button:hover { 103 | background-image: -webkit-linear-gradient(#BDC2D0 0%, #656874 100%); 104 | background-image: -o-linear-gradient(#BDC2D0 0%, #656874 100%); 105 | background-image: linear-gradient(#BDC2D0 0%, #656874 100%); 106 | } 107 | 108 | img { 109 | border: 1px solid transparent; 110 | } 111 | 112 | img:hover { 113 | border: 1px solid #ddd; 114 | } 115 | 116 | #contents { 117 | flex: 1; 118 | display: flex; 119 | flex-direction: column; 120 | } 121 | 122 | #loupe { 123 | /*height: 46%;*/ 124 | flex: 1; 125 | overflow: auto; 126 | } 127 | 128 | #big { 129 | /*width: 590px; 130 | height: 390px; 131 | */ 132 | /*max-height: 590px; 133 | max-width: 590px;*/ 134 | } 135 | 136 | .dotted-border { 137 | width: 360px; 138 | height: 230px; 139 | border: 2px dotted #676B78; 140 | border-radius: 4px; 141 | } 142 | 143 | .txt-drag-your-photos-here { 144 | font-size: 15px; 145 | color: #898D9B; 146 | } 147 | 148 | #sliders { 149 | right: 0px; 150 | width: 220px; 151 | position: absolute; 152 | background:rgba(0, 0, 0, 0.30); 153 | z-index: 10; 154 | } 155 | 156 | #preview { 157 | height: 39%; 158 | width: 100%; 159 | 160 | /*position: absolute; 161 | overflow: auto;*/ 162 | bottom: 0; 163 | border-top: 1px solid #999; 164 | transition: 350ms ease-out height; 165 | /*background: #333;*/ 166 | z-index: 2; 167 | 168 | display: flex; 169 | flex-direction: column; 170 | } 171 | 172 | #filmstrip img { 173 | transition: height 350ms ease-out; 174 | } 175 | 176 | #filmstrip { 177 | /*width: 100%; 178 | height: 100%;*/ 179 | flex: 1; 180 | overflow: auto; 181 | 182 | display: flex; 183 | flex-direction: row; 184 | flex-wrap: wrap; 185 | 186 | align-content: flex-start; 187 | } 188 | 189 | #preview-slider { 190 | display: block; 191 | width: 100%; 192 | height: 8px; 193 | background: #000; 194 | cursor: ns-resize; 195 | } 196 | 197 | .no-transition { 198 | transition: none !important; 199 | } 200 | 201 | .checkbox { 202 | /*background: deeppink;*/ 203 | /*width: 40px; 204 | height: 40px;*/ 205 | position: absolute; 206 | right: 0; 207 | /*display: inline-block;*/ 208 | z-index: 10; 209 | /*opacity: 0.2;*/ 210 | 211 | } 212 | 213 | .checkbox label:hover { 214 | opacity: 1; 215 | background: rgba(0,0,0,0.50); 216 | } 217 | 218 | .checkbox label { 219 | display: inline-block; 220 | position: relative; 221 | font-size: 2em; 222 | vertical-align: center; 223 | text-align: center; 224 | color: rgba(255, 255, 255, 0.5); 225 | cursor: pointer; 226 | transition: color 0.3s; 227 | border: 2px solid rgba(255, 255, 255, 0.8); 228 | width: 1.3em; 229 | height: 1.3em; 230 | opacity: 0.2; 231 | border-radius: 0; 232 | transition: opacity 0.2s, border-radius 0.2s, border 0.25s; 233 | margin: 4px; 234 | } 235 | 236 | .checkbox input[type="checkbox"], 237 | .checkbox input[type="radio"] { 238 | display: none; 239 | } 240 | 241 | .checkbox .checked { 242 | opacity: 1; 243 | color: rgba(255, 255, 255, 1); 244 | border: 2px solid rgba(255, 255, 255, 0.8); 245 | border-radius: 50%; 246 | } 247 | 248 | #filmstrip > div { 249 | display: inline-block; 250 | margin: 5px; 251 | position: relative; 252 | } 253 | 254 | .pic { 255 | margin: 0; 256 | } 257 | 258 | .pic img, .effect img { 259 | border-radius: 4px; 260 | } -------------------------------------------------------------------------------- /app/lightbroom.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var MAX_EFFECT_PREVIEW_LENGTH = 240; 4 | var MAX_BIG_PREVIEW_LENGTH = 1600; // || 590; 5 | var FILMSTRIP_THUMBNAIL_LENGTH = 390;; 6 | 7 | var opener = new ImageOpener(processImage); 8 | 9 | var preview = document.getElementById('preview'); 10 | var filters = document.getElementById('filters'); 11 | var big = document.getElementById('big'), bigimg; 12 | var filmstrip = document.getElementById('filmstrip'); 13 | 14 | var selectionMode = false; 15 | 16 | function toggleSelectMode(b) { 17 | selectionMode = !selectionMode; 18 | b.className = selectionMode ? 'active' : ''; 19 | } 20 | 21 | var styles = [ 22 | "Nofilter", 23 | "Aden", 24 | "Inkwell", 25 | "Perpetua", 26 | "Reyes", 27 | "Gingham", 28 | "Toaster", 29 | "Walden", 30 | "Hudson", 31 | "Earlybird", 32 | "Mayfair", 33 | "Lofi", 34 | "_1977", 35 | "Brooklyn", 36 | "Xpro2", 37 | "Nashville", 38 | "Lark", 39 | "Moon", 40 | "Clarendon", 41 | "Willow" 42 | ]; 43 | 44 | var currentStyle = 'nofilter'; 45 | var saved; 46 | var filterItems = new Map(); 47 | 48 | var filenames; 49 | var selectedPhoto; 50 | 51 | var photos = new PhotoList(); 52 | 53 | setup(); 54 | 55 | function setup() { 56 | // add styled effect 57 | styles.forEach( function(s) { 58 | var div = document.createElement('div'); 59 | 60 | var b = document.createElement('b'); 61 | b.className = 'effect'; 62 | 63 | b.onclick = function() { 64 | var last = document.querySelector('.selected-effect'); 65 | if (last) last.classList.remove('selected-effect'); 66 | b.querySelector('img').classList.add('selected-effect'); 67 | 68 | saved = currentStyle; 69 | switchStyle(s, true); 70 | } 71 | 72 | b.onmouseover = function() { 73 | saved = currentStyle; 74 | switchStyle(s); 75 | } 76 | 77 | b.onmouseout = function() { 78 | switchStyle(saved); 79 | } 80 | 81 | div.appendChild(b); 82 | 83 | var figure = document.createElement('figure'); 84 | b.appendChild(figure); 85 | b.innerHTML += s; 86 | 87 | filterItems.set(s, div); 88 | filters.appendChild(div); 89 | } ); 90 | 91 | // setup big image preview 92 | 93 | // var x = 0; 94 | 95 | // var tmp; 96 | // var save; 97 | 98 | // var img = big; 99 | 100 | // img.onmouseover = function(e) { 101 | // save = currentStyle; 102 | // } 103 | 104 | // img.onmousemove = function(e) { 105 | // // code here to reimplement scrubbing behaviour 106 | // // (potatoe prototype6) 107 | // } 108 | 109 | // img.onmouseout = function(e) { 110 | // if (!bigimg) return; 111 | // tmp = bigimg.classList[1]; 112 | // bigimg.classList.remove(tmp); 113 | // bigimg.classList.add(currentStyle); 114 | // } 115 | 116 | // img.onmousedown = function(e) { 117 | // save = bigimg.classList[1]; 118 | // switchStyle(save); 119 | // } 120 | } 121 | 122 | 123 | function switchStyle(s, commit) { 124 | var old = currentStyle; 125 | 126 | // photos.selection.forEach( p => { 127 | // p.effect = s; 128 | // }); 129 | 130 | // photosDomSync(); 131 | 132 | var pics = document.querySelectorAll('.pic, .big') 133 | 134 | for (var i = 0; i < pics.length; i ++) { 135 | var p = pics[i]; 136 | p.classList.remove(old); 137 | p.classList.add(s); 138 | } 139 | 140 | currentStyle = s; 141 | } 142 | 143 | function previewBig(oimg, photo, orientation) { 144 | selectedPhoto = photo; 145 | 146 | var img = scaleImage(oimg, MAX_BIG_PREVIEW_LENGTH, orientation); 147 | 148 | var child; 149 | while (child = big.childNodes[0]) { 150 | child.remove(); 151 | } 152 | 153 | var n = document.createElement('figure'); 154 | n.appendChild(img.cloneNode()); 155 | n.classList.add('big'); 156 | n.classList.add(currentStyle); 157 | bigimg = n; 158 | 159 | big.appendChild(n); 160 | 161 | // img = scaleImage(oimg, maxLength, orientation); 162 | 163 | img = scaleImage(img, MAX_EFFECT_PREVIEW_LENGTH); 164 | 165 | img.onload = () => { 166 | 167 | styles.forEach(function(s) { 168 | var m = filterItems.get(s).querySelector('figure'); 169 | 170 | while (child = m.firstChild) { 171 | child.remove(); 172 | } 173 | 174 | m.appendChild(img.cloneNode()); 175 | 176 | while (m.classList.length) { 177 | m.classList.remove(m.classList[0]); 178 | } 179 | m.classList.add('peek'); 180 | m.classList.add(s); 181 | 182 | }); 183 | 184 | } 185 | 186 | } 187 | 188 | function scaleImage(img, maxLength, orientation) { 189 | var canvas = getCanvas(img, maxLength, orientation); 190 | 191 | var image = new Image(); 192 | image.src = canvas.toDataURL("image/png"); 193 | 194 | return image; 195 | } 196 | 197 | function getCanvas(img, maxLength, orientation) { 198 | var imgWidth = img.naturalWidth; 199 | var imgHeight = img.naturalHeight; 200 | 201 | // can rotate here.... 202 | 203 | var scale = imgWidth > imgHeight ? maxLength / imgWidth : maxLength / imgHeight; 204 | 205 | var canvas = document.createElement('canvas'); 206 | var ctx = canvas.getContext('2d'); 207 | 208 | var targetWidth = imgWidth * scale; 209 | var targetHeight = imgHeight * scale; 210 | 211 | canvas.width = targetWidth; 212 | canvas.height = targetHeight; 213 | if (orientation > 4) { 214 | canvas.width = targetHeight; 215 | canvas.height = targetWidth; 216 | } 217 | 218 | var width = targetWidth; 219 | var height = targetHeight; 220 | 221 | switch (orientation) { 222 | case 2: 223 | // horizontal flip 224 | ctx.translate(width, 0); 225 | ctx.scale(-1, 1); 226 | break; 227 | case 3: 228 | // 180° rotate left 229 | ctx.translate(width, height); 230 | ctx.rotate(Math.PI); 231 | break; 232 | case 4: 233 | // vertical flip 234 | ctx.translate(0, height); 235 | ctx.scale(1, -1); 236 | break; 237 | case 5: 238 | // vertical flip + 90 rotate right 239 | ctx.rotate(0.5 * Math.PI); 240 | ctx.scale(1, -1); 241 | break; 242 | case 6: 243 | // 90° rotate right 244 | ctx.rotate(0.5 * Math.PI); 245 | ctx.translate(0, -height); 246 | break; 247 | case 7: 248 | // horizontal flip + 90 rotate right 249 | ctx.rotate(0.5 * Math.PI); 250 | ctx.translate(width, -height); 251 | ctx.scale(-1, 1); 252 | break; 253 | case 8: 254 | // 90° rotate left 255 | ctx.rotate(-0.5 * Math.PI); 256 | ctx.translate(-width, 0); 257 | break; 258 | } 259 | 260 | ctx.drawImage(img, 0, 0, targetWidth, targetHeight); 261 | 262 | return canvas; 263 | } 264 | 265 | function processImage(oImg, path, i) { 266 | EXIF.getData(oImg, function() { 267 | var exif = EXIF.getAllTags(oImg); 268 | processImage2(oImg, path, i, exif); 269 | }); 270 | } 271 | 272 | function processImage2(oImg, path, i, exif) { 273 | var orientation = exif.Orientation; 274 | var photo = new Photo(path, oImg, orientation); 275 | photos.add( photo ); 276 | 277 | // console.log('process image'); 278 | 279 | // var canvas = getCanvas(img, 550); 280 | // inlineSVGImg(canvas.toDataURL(), canvas.width, canvas.height); 281 | 282 | if (photos.count() == 1) { 283 | previewBig(oImg, photo, orientation); 284 | } 285 | 286 | var img = scaleImage(oImg, FILMSTRIP_THUMBNAIL_LENGTH, orientation); 287 | 288 | var div = document.createElement('div'); 289 | div.dataset.path = path; 290 | 291 | var checkbox = document.createElement('div'); 292 | checkbox.className = 'checkbox'; 293 | 294 | var lb = document.createElement('label'); 295 | lb.innerHTML = '✔'; 296 | 297 | var cb = document.createElement('input'); 298 | cb.type = 'checkbox'; 299 | cb.onchange = function() { 300 | if (cb.checked) { 301 | lb.classList.add('checked'); 302 | photos.selection.add( photo ); 303 | } else { 304 | lb.classList.remove('checked'); 305 | photos.selection.delete( photo ); 306 | } 307 | } 308 | 309 | lb.appendChild(cb); 310 | 311 | // ✔☑✓✓ 312 | checkbox.appendChild(lb); 313 | 314 | var figure = document.createElement('figure'); 315 | figure.classList.add('pic'); 316 | figure.classList.add(currentStyle); 317 | figure.appendChild(img); 318 | 319 | div.appendChild(checkbox); 320 | div.appendChild(figure); 321 | 322 | // div.innerHTML += path; 323 | 324 | filmstrip.appendChild(div); 325 | figure.scrollIntoView(false, 200); 326 | 327 | img.onclick = function() { 328 | if (selectionMode) { 329 | cb.checked = !cb.checked; 330 | cb.onchange(); 331 | } else { 332 | previewBig(oImg, photo, orientation); 333 | } 334 | } 335 | 336 | /* 337 | photos.remove(photo); 338 | photosDomSync(); 339 | */ 340 | 341 | 342 | sizeThumbnails(lastThumbnailSize); 343 | } 344 | 345 | var toggled = 0; 346 | 347 | /* Actions */ 348 | function togglePreviewSlider(x) { 349 | toggled = x !== undefined ? x : toggled; 350 | switch (toggled) { 351 | case 0: 352 | preview.style.height = '100%'; 353 | break; 354 | case 1: 355 | preview.style.height = '300px'; 356 | break; 357 | case 2: 358 | preview.style.height = '0px'; 359 | break; 360 | } 361 | 362 | toggled = (toggled + 1) % 3; 363 | } 364 | 365 | var lastThumbnailSize = 200; 366 | function sizeThumbnails(size) { 367 | lastThumbnailSize = 200; 368 | Array.from(document.querySelectorAll('#preview figure')).forEach( 369 | d => { 370 | var i = d.querySelector('img'); 371 | i.style.width = 'auto'; 372 | // i.style.height = `100%`; 373 | i.style.height = `${size}px`; 374 | // d.style.maxWidth =`${size}px`; 375 | // d.style.maxHeight = `${size * 0.67}px`; 376 | // d.style.maxHeight = `auto`; 377 | // d.style.maxHeight = `${size}px`; 378 | } 379 | ) 380 | } 381 | 382 | function clearThumbnails() { 383 | var proceed = confirm('Remove all photos?'); 384 | if (!proceed) return; 385 | photos.empty(); 386 | photosDomSync(); 387 | } 388 | 389 | function removeSelection() { 390 | var proceed = confirm('Remove ' + photos.selection.size + ' photos?'); 391 | if (!proceed) return; 392 | photos.selection.forEach(photos.remove.bind(photos)); 393 | photosDomSync(); 394 | } 395 | 396 | var toggleSelectAll = false; 397 | 398 | function selectAll() { 399 | // photos.list.forEach(photos.selection.add.bind(photos)); 400 | // photosDomSync(); 401 | 402 | toggleSelectAll = !toggleSelectAll; 403 | 404 | Array.from(document.querySelectorAll('#filmstrip input')).forEach( 405 | cb => { 406 | cb.checked = toggleSelectAll; 407 | cb.onchange(); 408 | } 409 | ) 410 | } 411 | 412 | function photosDomSync() { 413 | var dom = Array.from(document.querySelectorAll('[data-path]')) 414 | 415 | dom.forEach( d => { 416 | var match = photos.filter(d.dataset.path); 417 | 418 | // remove 419 | if (! match.length ) { 420 | return d.remove(); 421 | } 422 | 423 | // update 424 | var img = d.querySelector('.pic'); 425 | img.className = 'pic ' + match[0].effect; 426 | } ); 427 | } 428 | 429 | var effectsTray = true; 430 | var filterWrapper = document.getElementById('filter-wrapper'); 431 | 432 | function toggleEffects(button) { 433 | if (effectsTray) { 434 | filterWrapper.style.height = '0px'; 435 | effectsTray = false; 436 | button.className = ''; 437 | } else { 438 | filterWrapper.style.height = '128px'; 439 | effectsTray = true; 440 | button.className = 'active'; 441 | } 442 | } 443 | 444 | var slider = document.getElementById('preview-slider'); 445 | slider.onmousedown = function() { 446 | var moved = false; 447 | 448 | var onmove = function(e) { 449 | moved = true; 450 | var x = window.innerHeight - e.screenY; 451 | 452 | preview.style.height = x + 'px'; 453 | preview.style.transition = 'none'; 454 | e.stopPropagation(); 455 | e.preventDefault(); 456 | // preview.classList.add('no-transition'); 457 | }; 458 | 459 | var onup = function() { 460 | if (!moved) { 461 | togglePreviewSlider(); 462 | } 463 | // preview.classList.remove('no-transition'); 464 | preview.style.transition = ''; 465 | 466 | window.removeEventListener('mousemove', onmove); 467 | window.removeEventListener('mouseup', onup); 468 | } 469 | 470 | window.addEventListener('mousemove', onmove); 471 | window.addEventListener('mouseup', onup); 472 | } 473 | 474 | var FILTERS = { 475 | // type, min, max, default, step 476 | brightness: [0, 4, 1, 0.001], 477 | contrast: [0, 4, 1, 0.0001], 478 | sepia: [0, 1, 0, 0.0001], 479 | grayscale: [0, 1, 0, 0.001], 480 | saturate: [0, 2, 1, 0.0001], 481 | blur: [0, 10, 0, 1, 'px'], 482 | opacity: [0, 1, 1, 0.001], 483 | // 'drop-shadow': [0, 10, 1, 0.001, 'px'], 484 | 'hue-rotate': [0, 360, 0, 0.5, 'deg'], 485 | invert: [0, 1, 0, 0.001] 486 | }; 487 | 488 | var theSliders = {}; 489 | 490 | Object.keys(FILTERS).map( k => { 491 | var values = FILTERS[k]; 492 | var filterSlider = document.createElement('input'); 493 | filterSlider.type = 'range'; 494 | filterSlider.min = values[0]; 495 | filterSlider.max = values[1]; 496 | filterSlider.value = values[2]; 497 | filterSlider.step = values[3]; 498 | 499 | filterSlider.onmousemove = 500 | filterSlider.onchange = updateFilters; 501 | 502 | 503 | theSliders[k] = filterSlider; 504 | 505 | // sliders.appendChild(document.createElement('br')); 506 | sliders.appendChild(filterSlider); 507 | sliders.appendChild(document.createTextNode(k)); 508 | 509 | }) 510 | 511 | var before = true; 512 | 513 | function updateFilters() { 514 | var img = big.querySelector('img'); 515 | 516 | var css = '-webkit-filter: '; 517 | 518 | Object.keys(theSliders).map( k => { 519 | var input = theSliders[k]; 520 | var values = FILTERS[k]; 521 | if (input.value !== values[2]) css += ` ${k}(${input.value}${values[4] || ''})`; 522 | }); 523 | 524 | console.log(css); 525 | 526 | if (before) 527 | img.style.cssText = css; 528 | else 529 | big.style.cssText = css; 530 | } 531 | 532 | function brightness(input) { 533 | console.log(input.value); 534 | 535 | 536 | // big.style.cssText = `-webkit-filter: brightness(${input.value})`; 537 | } 538 | 539 | function contrast(input) { 540 | console.log(input.value); 541 | var img = big.querySelector('img'); 542 | img.style.cssText = `-webkit-filter: contrast(${input.value})`; 543 | // big.style.cssText = `-webkit-filter: brightness(${input.value})`; 544 | } -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lightbroom", 3 | "version": "0.3.0", 4 | "description": "photo workflow software", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "tests" 8 | }, 9 | "scripts": { 10 | "bumpcssgram": "rm cssgram.min.css && wget https://github.com/una/CSSgram/raw/master/site/css/cssgram.min.css" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/zz85/lightbroom.git" 15 | }, 16 | "keywords": [ 17 | "photo", 18 | "filters", 19 | "effect", 20 | "ps", 21 | "webgl" 22 | ], 23 | "author": "Joshua Koo", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/zz85/lightbroom/issues" 27 | }, 28 | "homepage": "https://github.com/zz85/lightbroom#readme", 29 | "electronVersion": "0.36.2", 30 | "devDependencies": { 31 | "cssgram": "^0.1.3", 32 | "jimp": "^0.2.21" 33 | }, 34 | "dependencies": { 35 | "exif-js": "^2.1.1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/photo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // model representation of photos to load 4 | 5 | function PhotoList() { 6 | this.list = []; // new Set(); 7 | 8 | this.selection = new Set(); 9 | } 10 | 11 | PhotoList.prototype.add = function(photo) { 12 | this.list.push(photo); 13 | this.save(); 14 | // this.selection.add(photo); 15 | } 16 | 17 | PhotoList.prototype.remove = function(photo) { 18 | var i = this.list.indexOf(photo); 19 | this.list.splice(i, 1); 20 | 21 | this.selection.delete(photo); 22 | this.save(); 23 | } 24 | 25 | PhotoList.prototype.count = function() { 26 | return this.list.length; 27 | } 28 | 29 | PhotoList.prototype.filenames = function() { 30 | return this.list.map( p => p.filename ); 31 | } 32 | 33 | PhotoList.prototype.items = function() { 34 | return Array.from(this.list); 35 | } 36 | 37 | PhotoList.prototype.toJSON = function() { 38 | this.list.map( p => { return { 39 | filename: p.filename, 40 | style: p.style 41 | }} ) 42 | } 43 | 44 | PhotoList.prototype.save = function() { 45 | localStorage.lastLoad = JSON.stringify(this.filenames()); 46 | // localStorage.lastLoad = JSON.stringify(this.toJSON()); 47 | } 48 | 49 | PhotoList.prototype.load = function() { 50 | // TODO 51 | var lastLoad = localStorage.lastLoad; 52 | if (lastLoad) { 53 | var list = JSON.parse(lastLoad); 54 | list.forEach( json => { 55 | this.list.push( 56 | new Photo() 57 | ) 58 | } ); 59 | } 60 | } 61 | 62 | PhotoList.prototype.empty = function() { 63 | this.list = []; 64 | this.save(); 65 | 66 | this.selection.clear() 67 | } 68 | 69 | PhotoList.prototype.exists = function(path) { 70 | return this.list.some( p => p.filename === path ); 71 | } 72 | 73 | PhotoList.prototype.filter = function(path) { 74 | return this.list.filter( p => p.filename === path ); 75 | } 76 | 77 | function Photo(filename, img, orientation) { 78 | this.filename = filename; 79 | this.img = img; 80 | this.orientation = orientation; 81 | 82 | this.effect = null; 83 | this.adjustment = ''; 84 | } -------------------------------------------------------------------------------- /app/svg-image.js: -------------------------------------------------------------------------------- 1 | function inlineSVGImg(data, width, height) { 2 | width = width || 20; 3 | hight = height || 20; 4 | var div = document.createElement('div'); 5 | 6 | var svg = ` 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 23 | 25 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 104 | 105 | 106 | 107 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 140 | 141 | 142 | `; 143 | 144 | console.log(svg); 145 | 146 | div.innerHTML = svg; 147 | 148 | // proxy svg image 149 | var img = new Image() 150 | img.src = 'data:image/svg+xml;utf8,' + svg 151 | 152 | // write svg image to canvas for pixels 153 | var canvas = document.createElement('canvas'); 154 | canvas.width = width; 155 | canvas.height = height; 156 | var context = canvas.getContext('2d'); 157 | 158 | context.drawImage(img, 0, 0, width, height); 159 | // var a = document.createElement("a"); 160 | // a.download = 'test.png'; 161 | // a.href = canvas.toDataURL("image/png"); 162 | // a.click() 163 | // z = cc.getImageData(0,0,c.width, c.height) 164 | 165 | 166 | document.body.appendChild(div); 167 | } -------------------------------------------------------------------------------- /app/transfer-image.js: -------------------------------------------------------------------------------- 1 | var saveTo = document.getElementById('saveTo'); 2 | var saveStatus = document.getElementById('status'); 3 | 4 | // this file extends the web app with file system + electron capabilities 5 | 6 | if (typeof(global) === 'object') { 7 | // check that we are in electron. then we cheat. 8 | var lastLoad = localStorage.lastLoad; 9 | if (lastLoad) { 10 | var filenames = JSON.parse(lastLoad); 11 | // TODO move this to PhotoList deserializer 12 | 13 | // var now = Date.now(); 14 | // filenames.forEach( (filename, i) => { 15 | // console.log('Previously loaded', filename); 16 | 17 | // var img = new Image(); 18 | // img.src = 'file://' + filename; 19 | // img.onload = function() { 20 | // console.log('loaded', Date.now() - now); 21 | // processImage(img, filename, i); 22 | // } 23 | // } ); 24 | 25 | var fs = require('fs'); 26 | 27 | filenames.forEach( (filename, i) => { 28 | var buffer = fs.readFileSync(filename); 29 | 30 | // fs.readFile(filename, function(err, buffer) {; 31 | var now = Date.now(); 32 | var img = document.createElement("img"); 33 | // img.src = objectURL; 34 | 35 | console.time('exif') 36 | var exif = EXIF.readFromBinaryFile(buffer.buffer); 37 | console.timeEnd('exif') 38 | 39 | img.src = filename; 40 | 41 | img.exifdata = exif; 42 | 43 | img.onload = function() { 44 | console.log('loaded', Date.now() - now); 45 | processImage(img, filename, i); 46 | } 47 | }) 48 | // }); 49 | } 50 | 51 | function saveImage() { 52 | var out = selectedPhoto.filename.split('/').pop(); 53 | // out = `${__dirname}/captures/${out}`; 54 | out = `${saveTo.innerHTML}/${out}`; 55 | saveImageTo(selectedPhoto, currentStyle, out); 56 | } 57 | 58 | function saveAll() { 59 | var items = photos.items(); 60 | 61 | var start = Date.now(); 62 | 63 | var ok = function() { 64 | var f = items.pop(); 65 | if (!f) { 66 | console.log('Batch Done!', (Date.now() - start) / 1000); 67 | saveStatus.innerHTML = 'Done!'; 68 | electron.shell.openItem(saveTo.innerHTML); 69 | if (win) win.close(); 70 | return; 71 | } 72 | 73 | var out = f.filename.split('/').pop(); 74 | // out = `${__dirname}/captures/${out}`; 75 | out = `${saveTo.innerHTML}/${out}`; 76 | saveImageTo(f, currentStyle, out, ok); 77 | } 78 | 79 | ok(); 80 | } 81 | 82 | var win; 83 | 84 | var electron = require('electron'); 85 | var remote = electron.remote; 86 | 87 | window.onbeforeunload = function() { 88 | console.log('unloading...'); 89 | if (win) { 90 | win.close(); 91 | win = null; 92 | } 93 | 94 | // remote.ipcMain.removeAllListeners(); 95 | } 96 | 97 | function saveImageTo(photo, currentStyle, out, done) { 98 | saveStatus.innerHTML = 'Exporting...'; 99 | 100 | var selectedFile = photo.filename; 101 | var currentImage = photo.img; 102 | var orientation = photo.orientation; 103 | 104 | var localScreen = electron.screen 105 | // var display = localScreen.getPrimaryDisplay().workAreaSize; 106 | var start = Date.now(); 107 | 108 | var longest = Math.max(currentImage.naturalWidth || 0, currentImage.natualHeight || 0); 109 | if (longside.value) longest = longside.value | 0; 110 | 111 | var width = currentImage.naturalWidth + 500 * 1; 112 | var height = currentImage.naturalHeight + 500 * 1; 113 | 114 | // var shift = 115 | if (orientation > 4) { 116 | var t = width; 117 | width = height; 118 | height = t; 119 | } 120 | 121 | width = longest + 200; 122 | height = longest + 200; 123 | 124 | if (!win) { 125 | win = new remote.BrowserWindow ({ 126 | x: 0, // display.width 127 | y: 0, // display.height 128 | width: width, 129 | height: height, 130 | // resizable: false, 131 | 'skip-taskbar': true, 132 | show: false, 133 | 'enable-larger-than-screen': true 134 | }); 135 | 136 | win.loadURL(`file://${__dirname}/capture-image.html`); 137 | 138 | console.log('saving image', selectedFile, currentStyle, out); 139 | win.webContents.on('did-finish-load', function() { 140 | console.log('did-finish-load') 141 | // window.webContents.send('ping', 'whoooooooh!'); 142 | win.webContents.executeJavaScript(`load('${selectedFile}', '${currentStyle}', '${out}', ${longest}, ${orientation})`); 143 | }); 144 | } else { 145 | win.webContents.executeJavaScript(`load('${selectedFile}', '${currentStyle}', '${out}', ${longest}, ${orientation})`); 146 | } 147 | 148 | // function ok (sender, msg) { 149 | // console.log('image transferred', (Date.now() - start) / 1000); 150 | 151 | // remote.ipcMain.removeListener('signal', ok); 152 | 153 | // if (done) done(); 154 | // } 155 | 156 | // remote.ipcMain.on('signal', ok); 157 | 158 | win.on('closed', function() { 159 | console.log('closed', (Date.now() - start) / 1000); 160 | win = null; 161 | 162 | if (done) { 163 | done(); 164 | } 165 | }); 166 | 167 | // 168 | } 169 | 170 | 171 | function outputFolder() { 172 | var dialog = remote.dialog; 173 | var moo = dialog.showOpenDialog(null, { 174 | properties: ['openDirectory', 'createDirectory'] 175 | }); 176 | 177 | if (moo.length) { 178 | saveTo.innerHTML = moo[0]; 179 | localStorage.saveToLocation = moo[0]; 180 | } 181 | 182 | console.log(moo); 183 | } 184 | saveTo.innerHTML = localStorage.saveToLocation || __dirname; 185 | } -------------------------------------------------------------------------------- /app/vendor/exif.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var debug = false; 4 | 5 | var root = this; 6 | 7 | var EXIF = function(obj) { 8 | if (obj instanceof EXIF) return obj; 9 | if (!(this instanceof EXIF)) return new EXIF(obj); 10 | this.EXIFwrapped = obj; 11 | }; 12 | 13 | if (typeof exports !== 'undefined') { 14 | if (typeof module !== 'undefined' && module.exports) { 15 | exports = module.exports = EXIF; 16 | } 17 | exports.EXIF = EXIF; 18 | } else { 19 | root.EXIF = EXIF; 20 | } 21 | 22 | var ExifTags = EXIF.Tags = { 23 | 24 | // version tags 25 | 0x9000 : "ExifVersion", // EXIF version 26 | 0xA000 : "FlashpixVersion", // Flashpix format version 27 | 28 | // colorspace tags 29 | 0xA001 : "ColorSpace", // Color space information tag 30 | 31 | // image configuration 32 | 0xA002 : "PixelXDimension", // Valid width of meaningful image 33 | 0xA003 : "PixelYDimension", // Valid height of meaningful image 34 | 0x9101 : "ComponentsConfiguration", // Information about channels 35 | 0x9102 : "CompressedBitsPerPixel", // Compressed bits per pixel 36 | 37 | // user information 38 | 0x927C : "MakerNote", // Any desired information written by the manufacturer 39 | 0x9286 : "UserComment", // Comments by user 40 | 41 | // related file 42 | 0xA004 : "RelatedSoundFile", // Name of related sound file 43 | 44 | // date and time 45 | 0x9003 : "DateTimeOriginal", // Date and time when the original image was generated 46 | 0x9004 : "DateTimeDigitized", // Date and time when the image was stored digitally 47 | 0x9290 : "SubsecTime", // Fractions of seconds for DateTime 48 | 0x9291 : "SubsecTimeOriginal", // Fractions of seconds for DateTimeOriginal 49 | 0x9292 : "SubsecTimeDigitized", // Fractions of seconds for DateTimeDigitized 50 | 51 | // picture-taking conditions 52 | 0x829A : "ExposureTime", // Exposure time (in seconds) 53 | 0x829D : "FNumber", // F number 54 | 0x8822 : "ExposureProgram", // Exposure program 55 | 0x8824 : "SpectralSensitivity", // Spectral sensitivity 56 | 0x8827 : "ISOSpeedRatings", // ISO speed rating 57 | 0x8828 : "OECF", // Optoelectric conversion factor 58 | 0x9201 : "ShutterSpeedValue", // Shutter speed 59 | 0x9202 : "ApertureValue", // Lens aperture 60 | 0x9203 : "BrightnessValue", // Value of brightness 61 | 0x9204 : "ExposureBias", // Exposure bias 62 | 0x9205 : "MaxApertureValue", // Smallest F number of lens 63 | 0x9206 : "SubjectDistance", // Distance to subject in meters 64 | 0x9207 : "MeteringMode", // Metering mode 65 | 0x9208 : "LightSource", // Kind of light source 66 | 0x9209 : "Flash", // Flash status 67 | 0x9214 : "SubjectArea", // Location and area of main subject 68 | 0x920A : "FocalLength", // Focal length of the lens in mm 69 | 0xA20B : "FlashEnergy", // Strobe energy in BCPS 70 | 0xA20C : "SpatialFrequencyResponse", // 71 | 0xA20E : "FocalPlaneXResolution", // Number of pixels in width direction per FocalPlaneResolutionUnit 72 | 0xA20F : "FocalPlaneYResolution", // Number of pixels in height direction per FocalPlaneResolutionUnit 73 | 0xA210 : "FocalPlaneResolutionUnit", // Unit for measuring FocalPlaneXResolution and FocalPlaneYResolution 74 | 0xA214 : "SubjectLocation", // Location of subject in image 75 | 0xA215 : "ExposureIndex", // Exposure index selected on camera 76 | 0xA217 : "SensingMethod", // Image sensor type 77 | 0xA300 : "FileSource", // Image source (3 == DSC) 78 | 0xA301 : "SceneType", // Scene type (1 == directly photographed) 79 | 0xA302 : "CFAPattern", // Color filter array geometric pattern 80 | 0xA401 : "CustomRendered", // Special processing 81 | 0xA402 : "ExposureMode", // Exposure mode 82 | 0xA403 : "WhiteBalance", // 1 = auto white balance, 2 = manual 83 | 0xA404 : "DigitalZoomRation", // Digital zoom ratio 84 | 0xA405 : "FocalLengthIn35mmFilm", // Equivalent foacl length assuming 35mm film camera (in mm) 85 | 0xA406 : "SceneCaptureType", // Type of scene 86 | 0xA407 : "GainControl", // Degree of overall image gain adjustment 87 | 0xA408 : "Contrast", // Direction of contrast processing applied by camera 88 | 0xA409 : "Saturation", // Direction of saturation processing applied by camera 89 | 0xA40A : "Sharpness", // Direction of sharpness processing applied by camera 90 | 0xA40B : "DeviceSettingDescription", // 91 | 0xA40C : "SubjectDistanceRange", // Distance to subject 92 | 93 | // other tags 94 | 0xA005 : "InteroperabilityIFDPointer", 95 | 0xA420 : "ImageUniqueID" // Identifier assigned uniquely to each image 96 | }; 97 | 98 | var TiffTags = EXIF.TiffTags = { 99 | 0x0100 : "ImageWidth", 100 | 0x0101 : "ImageHeight", 101 | 0x8769 : "ExifIFDPointer", 102 | 0x8825 : "GPSInfoIFDPointer", 103 | 0xA005 : "InteroperabilityIFDPointer", 104 | 0x0102 : "BitsPerSample", 105 | 0x0103 : "Compression", 106 | 0x0106 : "PhotometricInterpretation", 107 | 0x0112 : "Orientation", 108 | 0x0115 : "SamplesPerPixel", 109 | 0x011C : "PlanarConfiguration", 110 | 0x0212 : "YCbCrSubSampling", 111 | 0x0213 : "YCbCrPositioning", 112 | 0x011A : "XResolution", 113 | 0x011B : "YResolution", 114 | 0x0128 : "ResolutionUnit", 115 | 0x0111 : "StripOffsets", 116 | 0x0116 : "RowsPerStrip", 117 | 0x0117 : "StripByteCounts", 118 | 0x0201 : "JPEGInterchangeFormat", 119 | 0x0202 : "JPEGInterchangeFormatLength", 120 | 0x012D : "TransferFunction", 121 | 0x013E : "WhitePoint", 122 | 0x013F : "PrimaryChromaticities", 123 | 0x0211 : "YCbCrCoefficients", 124 | 0x0214 : "ReferenceBlackWhite", 125 | 0x0132 : "DateTime", 126 | 0x010E : "ImageDescription", 127 | 0x010F : "Make", 128 | 0x0110 : "Model", 129 | 0x0131 : "Software", 130 | 0x013B : "Artist", 131 | 0x8298 : "Copyright" 132 | }; 133 | 134 | var GPSTags = EXIF.GPSTags = { 135 | 0x0000 : "GPSVersionID", 136 | 0x0001 : "GPSLatitudeRef", 137 | 0x0002 : "GPSLatitude", 138 | 0x0003 : "GPSLongitudeRef", 139 | 0x0004 : "GPSLongitude", 140 | 0x0005 : "GPSAltitudeRef", 141 | 0x0006 : "GPSAltitude", 142 | 0x0007 : "GPSTimeStamp", 143 | 0x0008 : "GPSSatellites", 144 | 0x0009 : "GPSStatus", 145 | 0x000A : "GPSMeasureMode", 146 | 0x000B : "GPSDOP", 147 | 0x000C : "GPSSpeedRef", 148 | 0x000D : "GPSSpeed", 149 | 0x000E : "GPSTrackRef", 150 | 0x000F : "GPSTrack", 151 | 0x0010 : "GPSImgDirectionRef", 152 | 0x0011 : "GPSImgDirection", 153 | 0x0012 : "GPSMapDatum", 154 | 0x0013 : "GPSDestLatitudeRef", 155 | 0x0014 : "GPSDestLatitude", 156 | 0x0015 : "GPSDestLongitudeRef", 157 | 0x0016 : "GPSDestLongitude", 158 | 0x0017 : "GPSDestBearingRef", 159 | 0x0018 : "GPSDestBearing", 160 | 0x0019 : "GPSDestDistanceRef", 161 | 0x001A : "GPSDestDistance", 162 | 0x001B : "GPSProcessingMethod", 163 | 0x001C : "GPSAreaInformation", 164 | 0x001D : "GPSDateStamp", 165 | 0x001E : "GPSDifferential" 166 | }; 167 | 168 | // EXIF 2.3 Spec 169 | var IFD1Tags = EXIF.IFD1Tags = { 170 | 0x0100: "ImageWidth", 171 | 0x0101: "ImageHeight", 172 | 0x0102: "BitsPerSample", 173 | 0x0103: "Compression", 174 | 0x0106: "PhotometricInterpretation", 175 | 0x0111: "StripOffsets", 176 | 0x0112: "Orientation", 177 | 0x0115: "SamplesPerPixel", 178 | 0x0116: "RowsPerStrip", 179 | 0x0117: "StripByteCounts", 180 | 0x011A: "XResolution", 181 | 0x011B: "YResolution", 182 | 0x011C: "PlanarConfiguration", 183 | 0x0128: "ResolutionUnit", 184 | 0x0201: "JpegIFOffset", // When image format is JPEG, this value show offset to JPEG data stored.(aka "ThumbnailOffset" or "JPEGInterchangeFormat") 185 | 0x0202: "JpegIFByteCount", // When image format is JPEG, this value shows data size of JPEG image (aka "ThumbnailLength" or "JPEGInterchangeFormatLength") 186 | 0x0211: "YCbCrCoefficients", 187 | 0x0212: "YCbCrSubSampling", 188 | 0x0213: "YCbCrPositioning", 189 | 0x0214: "ReferenceBlackWhite" 190 | }; 191 | 192 | var StringValues = EXIF.StringValues = { 193 | ExposureProgram : { 194 | 0 : "Not defined", 195 | 1 : "Manual", 196 | 2 : "Normal program", 197 | 3 : "Aperture priority", 198 | 4 : "Shutter priority", 199 | 5 : "Creative program", 200 | 6 : "Action program", 201 | 7 : "Portrait mode", 202 | 8 : "Landscape mode" 203 | }, 204 | MeteringMode : { 205 | 0 : "Unknown", 206 | 1 : "Average", 207 | 2 : "CenterWeightedAverage", 208 | 3 : "Spot", 209 | 4 : "MultiSpot", 210 | 5 : "Pattern", 211 | 6 : "Partial", 212 | 255 : "Other" 213 | }, 214 | LightSource : { 215 | 0 : "Unknown", 216 | 1 : "Daylight", 217 | 2 : "Fluorescent", 218 | 3 : "Tungsten (incandescent light)", 219 | 4 : "Flash", 220 | 9 : "Fine weather", 221 | 10 : "Cloudy weather", 222 | 11 : "Shade", 223 | 12 : "Daylight fluorescent (D 5700 - 7100K)", 224 | 13 : "Day white fluorescent (N 4600 - 5400K)", 225 | 14 : "Cool white fluorescent (W 3900 - 4500K)", 226 | 15 : "White fluorescent (WW 3200 - 3700K)", 227 | 17 : "Standard light A", 228 | 18 : "Standard light B", 229 | 19 : "Standard light C", 230 | 20 : "D55", 231 | 21 : "D65", 232 | 22 : "D75", 233 | 23 : "D50", 234 | 24 : "ISO studio tungsten", 235 | 255 : "Other" 236 | }, 237 | Flash : { 238 | 0x0000 : "Flash did not fire", 239 | 0x0001 : "Flash fired", 240 | 0x0005 : "Strobe return light not detected", 241 | 0x0007 : "Strobe return light detected", 242 | 0x0009 : "Flash fired, compulsory flash mode", 243 | 0x000D : "Flash fired, compulsory flash mode, return light not detected", 244 | 0x000F : "Flash fired, compulsory flash mode, return light detected", 245 | 0x0010 : "Flash did not fire, compulsory flash mode", 246 | 0x0018 : "Flash did not fire, auto mode", 247 | 0x0019 : "Flash fired, auto mode", 248 | 0x001D : "Flash fired, auto mode, return light not detected", 249 | 0x001F : "Flash fired, auto mode, return light detected", 250 | 0x0020 : "No flash function", 251 | 0x0041 : "Flash fired, red-eye reduction mode", 252 | 0x0045 : "Flash fired, red-eye reduction mode, return light not detected", 253 | 0x0047 : "Flash fired, red-eye reduction mode, return light detected", 254 | 0x0049 : "Flash fired, compulsory flash mode, red-eye reduction mode", 255 | 0x004D : "Flash fired, compulsory flash mode, red-eye reduction mode, return light not detected", 256 | 0x004F : "Flash fired, compulsory flash mode, red-eye reduction mode, return light detected", 257 | 0x0059 : "Flash fired, auto mode, red-eye reduction mode", 258 | 0x005D : "Flash fired, auto mode, return light not detected, red-eye reduction mode", 259 | 0x005F : "Flash fired, auto mode, return light detected, red-eye reduction mode" 260 | }, 261 | SensingMethod : { 262 | 1 : "Not defined", 263 | 2 : "One-chip color area sensor", 264 | 3 : "Two-chip color area sensor", 265 | 4 : "Three-chip color area sensor", 266 | 5 : "Color sequential area sensor", 267 | 7 : "Trilinear sensor", 268 | 8 : "Color sequential linear sensor" 269 | }, 270 | SceneCaptureType : { 271 | 0 : "Standard", 272 | 1 : "Landscape", 273 | 2 : "Portrait", 274 | 3 : "Night scene" 275 | }, 276 | SceneType : { 277 | 1 : "Directly photographed" 278 | }, 279 | CustomRendered : { 280 | 0 : "Normal process", 281 | 1 : "Custom process" 282 | }, 283 | WhiteBalance : { 284 | 0 : "Auto white balance", 285 | 1 : "Manual white balance" 286 | }, 287 | GainControl : { 288 | 0 : "None", 289 | 1 : "Low gain up", 290 | 2 : "High gain up", 291 | 3 : "Low gain down", 292 | 4 : "High gain down" 293 | }, 294 | Contrast : { 295 | 0 : "Normal", 296 | 1 : "Soft", 297 | 2 : "Hard" 298 | }, 299 | Saturation : { 300 | 0 : "Normal", 301 | 1 : "Low saturation", 302 | 2 : "High saturation" 303 | }, 304 | Sharpness : { 305 | 0 : "Normal", 306 | 1 : "Soft", 307 | 2 : "Hard" 308 | }, 309 | SubjectDistanceRange : { 310 | 0 : "Unknown", 311 | 1 : "Macro", 312 | 2 : "Close view", 313 | 3 : "Distant view" 314 | }, 315 | FileSource : { 316 | 3 : "DSC" 317 | }, 318 | 319 | Components : { 320 | 0 : "", 321 | 1 : "Y", 322 | 2 : "Cb", 323 | 3 : "Cr", 324 | 4 : "R", 325 | 5 : "G", 326 | 6 : "B" 327 | } 328 | }; 329 | 330 | function addEvent(element, event, handler) { 331 | if (element.addEventListener) { 332 | element.addEventListener(event, handler, false); 333 | } else if (element.attachEvent) { 334 | element.attachEvent("on" + event, handler); 335 | } 336 | } 337 | 338 | function imageHasData(img) { 339 | return !!(img.exifdata); 340 | } 341 | 342 | 343 | function base64ToArrayBuffer(base64, contentType) { 344 | contentType = contentType || base64.match(/^data\:([^\;]+)\;base64,/mi)[1] || ''; // e.g. 'data:image/jpeg;base64,...' => 'image/jpeg' 345 | base64 = base64.replace(/^data\:([^\;]+)\;base64,/gmi, ''); 346 | var binary = atob(base64); 347 | var len = binary.length; 348 | var buffer = new ArrayBuffer(len); 349 | var view = new Uint8Array(buffer); 350 | for (var i = 0; i < len; i++) { 351 | view[i] = binary.charCodeAt(i); 352 | } 353 | return buffer; 354 | } 355 | 356 | function objectURLToBlob(url, callback) { 357 | var http = new XMLHttpRequest(); 358 | http.open("GET", url, true); 359 | http.responseType = "blob"; 360 | http.onload = function(e) { 361 | if (this.status == 200 || this.status === 0) { 362 | callback(this.response); 363 | } 364 | }; 365 | http.send(); 366 | } 367 | 368 | function getImageData(img, callback) { 369 | function handleBinaryFile(binFile) { 370 | var data = findEXIFinJPEG(binFile); 371 | img.exifdata = data || {}; 372 | var iptcdata = findIPTCinJPEG(binFile); 373 | img.iptcdata = iptcdata || {}; 374 | if (EXIF.isXmpEnabled) { 375 | var xmpdata= findXMPinJPEG(binFile); 376 | img.xmpdata = xmpdata || {}; 377 | } 378 | if (callback) { 379 | callback.call(img); 380 | } 381 | } 382 | 383 | if (img.src) { 384 | if (/^data\:/i.test(img.src)) { // Data URI 385 | var arrayBuffer = base64ToArrayBuffer(img.src); 386 | handleBinaryFile(arrayBuffer); 387 | 388 | } else if (/^blob\:/i.test(img.src)) { // Object URL 389 | var fileReader = new FileReader(); 390 | fileReader.onload = function(e) { 391 | handleBinaryFile(e.target.result); 392 | }; 393 | objectURLToBlob(img.src, function (blob) { 394 | fileReader.readAsArrayBuffer(blob); 395 | }); 396 | } else { 397 | var http = new XMLHttpRequest(); 398 | http.onload = function() { 399 | if (this.status == 200 || this.status === 0) { 400 | handleBinaryFile(http.response); 401 | } else { 402 | throw "Could not load image"; 403 | } 404 | http = null; 405 | }; 406 | http.open("GET", img.src, true); 407 | http.responseType = "arraybuffer"; 408 | http.send(null); 409 | } 410 | } else if (self.FileReader && (img instanceof self.Blob || img instanceof self.File)) { 411 | var fileReader = new FileReader(); 412 | fileReader.onload = function(e) { 413 | if (debug) console.log("Got file of length " + e.target.result.byteLength); 414 | handleBinaryFile(e.target.result); 415 | }; 416 | 417 | fileReader.readAsArrayBuffer(img); 418 | } 419 | } 420 | 421 | function findEXIFinJPEG(file) { 422 | var dataView = new DataView(file); 423 | 424 | if (debug) console.log("Got file of length " + file.byteLength); 425 | if ((dataView.getUint8(0) != 0xFF) || (dataView.getUint8(1) != 0xD8)) { 426 | if (debug) console.log("Not a valid JPEG"); 427 | return false; // not a valid jpeg 428 | } 429 | 430 | var offset = 2, 431 | length = file.byteLength, 432 | marker; 433 | 434 | while (offset < length) { 435 | if (dataView.getUint8(offset) != 0xFF) { 436 | if (debug) console.log("Not a valid marker at offset " + offset + ", found: " + dataView.getUint8(offset)); 437 | return false; // not a valid marker, something is wrong 438 | } 439 | 440 | marker = dataView.getUint8(offset + 1); 441 | if (debug) console.log(marker); 442 | 443 | // we could implement handling for other markers here, 444 | // but we're only looking for 0xFFE1 for EXIF data 445 | 446 | if (marker == 225) { 447 | if (debug) console.log("Found 0xFFE1 marker"); 448 | 449 | return readEXIFData(dataView, offset + 4, dataView.getUint16(offset + 2) - 2); 450 | 451 | // offset += 2 + file.getShortAt(offset+2, true); 452 | 453 | } else { 454 | offset += 2 + dataView.getUint16(offset+2); 455 | } 456 | 457 | } 458 | 459 | } 460 | 461 | function findIPTCinJPEG(file) { 462 | var dataView = new DataView(file); 463 | 464 | if (debug) console.log("Got file of length " + file.byteLength); 465 | if ((dataView.getUint8(0) != 0xFF) || (dataView.getUint8(1) != 0xD8)) { 466 | if (debug) console.log("Not a valid JPEG"); 467 | return false; // not a valid jpeg 468 | } 469 | 470 | var offset = 2, 471 | length = file.byteLength; 472 | 473 | 474 | var isFieldSegmentStart = function(dataView, offset){ 475 | return ( 476 | dataView.getUint8(offset) === 0x38 && 477 | dataView.getUint8(offset+1) === 0x42 && 478 | dataView.getUint8(offset+2) === 0x49 && 479 | dataView.getUint8(offset+3) === 0x4D && 480 | dataView.getUint8(offset+4) === 0x04 && 481 | dataView.getUint8(offset+5) === 0x04 482 | ); 483 | }; 484 | 485 | while (offset < length) { 486 | 487 | if ( isFieldSegmentStart(dataView, offset )){ 488 | 489 | // Get the length of the name header (which is padded to an even number of bytes) 490 | var nameHeaderLength = dataView.getUint8(offset+7); 491 | if(nameHeaderLength % 2 !== 0) nameHeaderLength += 1; 492 | // Check for pre photoshop 6 format 493 | if(nameHeaderLength === 0) { 494 | // Always 4 495 | nameHeaderLength = 4; 496 | } 497 | 498 | var startOffset = offset + 8 + nameHeaderLength; 499 | var sectionLength = dataView.getUint16(offset + 6 + nameHeaderLength); 500 | 501 | return readIPTCData(file, startOffset, sectionLength); 502 | 503 | break; 504 | 505 | } 506 | 507 | 508 | // Not the marker, continue searching 509 | offset++; 510 | 511 | } 512 | 513 | } 514 | var IptcFieldMap = { 515 | 0x78 : 'caption', 516 | 0x6E : 'credit', 517 | 0x19 : 'keywords', 518 | 0x37 : 'dateCreated', 519 | 0x50 : 'byline', 520 | 0x55 : 'bylineTitle', 521 | 0x7A : 'captionWriter', 522 | 0x69 : 'headline', 523 | 0x74 : 'copyright', 524 | 0x0F : 'category' 525 | }; 526 | function readIPTCData(file, startOffset, sectionLength){ 527 | var dataView = new DataView(file); 528 | var data = {}; 529 | var fieldValue, fieldName, dataSize, segmentType, segmentSize; 530 | var segmentStartPos = startOffset; 531 | while(segmentStartPos < startOffset+sectionLength) { 532 | if(dataView.getUint8(segmentStartPos) === 0x1C && dataView.getUint8(segmentStartPos+1) === 0x02){ 533 | segmentType = dataView.getUint8(segmentStartPos+2); 534 | if(segmentType in IptcFieldMap) { 535 | dataSize = dataView.getInt16(segmentStartPos+3); 536 | segmentSize = dataSize + 5; 537 | fieldName = IptcFieldMap[segmentType]; 538 | fieldValue = getStringFromDB(dataView, segmentStartPos+5, dataSize); 539 | // Check if we already stored a value with this name 540 | if(data.hasOwnProperty(fieldName)) { 541 | // Value already stored with this name, create multivalue field 542 | if(data[fieldName] instanceof Array) { 543 | data[fieldName].push(fieldValue); 544 | } 545 | else { 546 | data[fieldName] = [data[fieldName], fieldValue]; 547 | } 548 | } 549 | else { 550 | data[fieldName] = fieldValue; 551 | } 552 | } 553 | 554 | } 555 | segmentStartPos++; 556 | } 557 | return data; 558 | } 559 | 560 | 561 | 562 | function readTags(file, tiffStart, dirStart, strings, bigEnd) { 563 | var entries = file.getUint16(dirStart, !bigEnd), 564 | tags = {}, 565 | entryOffset, tag, 566 | i; 567 | 568 | for (i=0;i 4 ? valueOffset : (entryOffset + 8); 593 | vals = []; 594 | for (n=0;n 4 ? valueOffset : (entryOffset + 8); 602 | return getStringFromDB(file, offset, numValues-1); 603 | 604 | case 3: // short, 16 bit int 605 | if (numValues == 1) { 606 | return file.getUint16(entryOffset + 8, !bigEnd); 607 | } else { 608 | offset = numValues > 2 ? valueOffset : (entryOffset + 8); 609 | vals = []; 610 | for (n=0;n dataView.byteLength) { // this should not happen 695 | // console.log('******** IFD1Offset is outside the bounds of the DataView ********'); 696 | return {}; 697 | } 698 | // console.log('******* thumbnail IFD offset (IFD1) is: %s', IFD1OffsetPointer); 699 | 700 | var thumbTags = readTags(dataView, tiffStart, tiffStart + IFD1OffsetPointer, IFD1Tags, bigEnd) 701 | 702 | // EXIF 2.3 specification for JPEG format thumbnail 703 | 704 | // If the value of Compression(0x0103) Tag in IFD1 is '6', thumbnail image format is JPEG. 705 | // Most of Exif image uses JPEG format for thumbnail. In that case, you can get offset of thumbnail 706 | // by JpegIFOffset(0x0201) Tag in IFD1, size of thumbnail by JpegIFByteCount(0x0202) Tag. 707 | // Data format is ordinary JPEG format, starts from 0xFFD8 and ends by 0xFFD9. It seems that 708 | // JPEG format and 160x120pixels of size are recommended thumbnail format for Exif2.1 or later. 709 | 710 | if (thumbTags['Compression']) { 711 | // console.log('Thumbnail image found!'); 712 | 713 | switch (thumbTags['Compression']) { 714 | case 6: 715 | // console.log('Thumbnail image format is JPEG'); 716 | if (thumbTags.JpegIFOffset && thumbTags.JpegIFByteCount) { 717 | // extract the thumbnail 718 | var tOffset = tiffStart + thumbTags.JpegIFOffset; 719 | var tLength = thumbTags.JpegIFByteCount; 720 | thumbTags['blob'] = new Blob([new Uint8Array(dataView.buffer, tOffset, tLength)], { 721 | type: 'image/jpeg' 722 | }); 723 | } 724 | break; 725 | 726 | case 1: 727 | console.log("Thumbnail image format is TIFF, which is not implemented."); 728 | break; 729 | default: 730 | console.log("Unknown thumbnail image format '%s'", thumbTags['Compression']); 731 | } 732 | } 733 | else if (thumbTags['PhotometricInterpretation'] == 2) { 734 | console.log("Thumbnail image format is RGB, which is not implemented."); 735 | } 736 | return thumbTags; 737 | } 738 | 739 | function getStringFromDB(buffer, start, length) { 740 | var outstr = ""; 741 | for (n = start; n < start+length; n++) { 742 | outstr += String.fromCharCode(buffer.getUint8(n)); 743 | } 744 | return outstr; 745 | } 746 | 747 | function readEXIFData(file, start) { 748 | if (getStringFromDB(file, start, 4) != "Exif") { 749 | if (debug) console.log("Not valid EXIF data! " + getStringFromDB(file, start, 4)); 750 | return false; 751 | } 752 | 753 | var bigEnd, 754 | tags, tag, 755 | exifData, gpsData, 756 | tiffOffset = start + 6; 757 | 758 | // test for TIFF validity and endianness 759 | if (file.getUint16(tiffOffset) == 0x4949) { 760 | bigEnd = false; 761 | } else if (file.getUint16(tiffOffset) == 0x4D4D) { 762 | bigEnd = true; 763 | } else { 764 | if (debug) console.log("Not valid TIFF data! (no 0x4949 or 0x4D4D)"); 765 | return false; 766 | } 767 | 768 | if (file.getUint16(tiffOffset+2, !bigEnd) != 0x002A) { 769 | if (debug) console.log("Not valid TIFF data! (no 0x002A)"); 770 | return false; 771 | } 772 | 773 | var firstIFDOffset = file.getUint32(tiffOffset+4, !bigEnd); 774 | 775 | if (firstIFDOffset < 0x00000008) { 776 | if (debug) console.log("Not valid TIFF data! (First offset less than 8)", file.getUint32(tiffOffset+4, !bigEnd)); 777 | return false; 778 | } 779 | 780 | tags = readTags(file, tiffOffset, tiffOffset + firstIFDOffset, TiffTags, bigEnd); 781 | 782 | if (tags.ExifIFDPointer) { 783 | exifData = readTags(file, tiffOffset, tiffOffset + tags.ExifIFDPointer, ExifTags, bigEnd); 784 | for (tag in exifData) { 785 | switch (tag) { 786 | case "LightSource" : 787 | case "Flash" : 788 | case "MeteringMode" : 789 | case "ExposureProgram" : 790 | case "SensingMethod" : 791 | case "SceneCaptureType" : 792 | case "SceneType" : 793 | case "CustomRendered" : 794 | case "WhiteBalance" : 795 | case "GainControl" : 796 | case "Contrast" : 797 | case "Saturation" : 798 | case "Sharpness" : 799 | case "SubjectDistanceRange" : 800 | case "FileSource" : 801 | exifData[tag] = StringValues[tag][exifData[tag]]; 802 | break; 803 | 804 | case "ExifVersion" : 805 | case "FlashpixVersion" : 806 | exifData[tag] = String.fromCharCode(exifData[tag][0], exifData[tag][1], exifData[tag][2], exifData[tag][3]); 807 | break; 808 | 809 | case "ComponentsConfiguration" : 810 | exifData[tag] = 811 | StringValues.Components[exifData[tag][0]] + 812 | StringValues.Components[exifData[tag][1]] + 813 | StringValues.Components[exifData[tag][2]] + 814 | StringValues.Components[exifData[tag][3]]; 815 | break; 816 | } 817 | tags[tag] = exifData[tag]; 818 | } 819 | } 820 | 821 | if (tags.GPSInfoIFDPointer) { 822 | gpsData = readTags(file, tiffOffset, tiffOffset + tags.GPSInfoIFDPointer, GPSTags, bigEnd); 823 | for (tag in gpsData) { 824 | switch (tag) { 825 | case "GPSVersionID" : 826 | gpsData[tag] = gpsData[tag][0] + 827 | "." + gpsData[tag][1] + 828 | "." + gpsData[tag][2] + 829 | "." + gpsData[tag][3]; 830 | break; 831 | } 832 | tags[tag] = gpsData[tag]; 833 | } 834 | } 835 | 836 | // extract thumbnail 837 | tags['thumbnail'] = readThumbnailImage(file, tiffOffset, firstIFDOffset, bigEnd); 838 | 839 | return tags; 840 | } 841 | 842 | function findXMPinJPEG(file) { 843 | 844 | if (!('DOMParser' in self)) { 845 | // console.warn('XML parsing not supported without DOMParser'); 846 | return; 847 | } 848 | var dataView = new DataView(file); 849 | 850 | if (debug) console.log("Got file of length " + file.byteLength); 851 | if ((dataView.getUint8(0) != 0xFF) || (dataView.getUint8(1) != 0xD8)) { 852 | if (debug) console.log("Not a valid JPEG"); 853 | return false; // not a valid jpeg 854 | } 855 | 856 | var offset = 2, 857 | length = file.byteLength, 858 | dom = new DOMParser(); 859 | 860 | while (offset < (length-4)) { 861 | if (getStringFromDB(dataView, offset, 4) == "http") { 862 | var startOffset = offset - 1; 863 | var sectionLength = dataView.getUint16(offset - 2) - 1; 864 | var xmpString = getStringFromDB(dataView, startOffset, sectionLength) 865 | var xmpEndIndex = xmpString.indexOf('xmpmeta>') + 8; 866 | xmpString = xmpString.substring( xmpString.indexOf( ' 0) { 898 | json['@attributes'] = {}; 899 | for (var j = 0; j < xml.attributes.length; j++) { 900 | var attribute = xml.attributes.item(j); 901 | json['@attributes'][attribute.nodeName] = attribute.nodeValue; 902 | } 903 | } 904 | } else if (xml.nodeType == 3) { // text node 905 | return xml.nodeValue; 906 | } 907 | 908 | // deal with children 909 | if (xml.hasChildNodes()) { 910 | for(var i = 0; i < xml.childNodes.length; i++) { 911 | var child = xml.childNodes.item(i); 912 | var nodeName = child.nodeName; 913 | if (json[nodeName] == null) { 914 | json[nodeName] = xml2json(child); 915 | } else { 916 | if (json[nodeName].push == null) { 917 | var old = json[nodeName]; 918 | json[nodeName] = []; 919 | json[nodeName].push(old); 920 | } 921 | json[nodeName].push(xml2json(child)); 922 | } 923 | } 924 | } 925 | 926 | return json; 927 | } 928 | 929 | function xml2Object(xml) { 930 | try { 931 | var obj = {}; 932 | if (xml.children.length > 0) { 933 | for (var i = 0; i < xml.children.length; i++) { 934 | var item = xml.children.item(i); 935 | var attributes = item.attributes; 936 | for(var idx in attributes) { 937 | var itemAtt = attributes[idx]; 938 | var dataKey = itemAtt.nodeName; 939 | var dataValue = itemAtt.nodeValue; 940 | 941 | if(dataKey !== undefined) { 942 | obj[dataKey] = dataValue; 943 | } 944 | } 945 | var nodeName = item.nodeName; 946 | 947 | if (typeof (obj[nodeName]) == "undefined") { 948 | obj[nodeName] = xml2json(item); 949 | } else { 950 | if (typeof (obj[nodeName].push) == "undefined") { 951 | var old = obj[nodeName]; 952 | 953 | obj[nodeName] = []; 954 | obj[nodeName].push(old); 955 | } 956 | obj[nodeName].push(xml2json(item)); 957 | } 958 | } 959 | } else { 960 | obj = xml.textContent; 961 | } 962 | return obj; 963 | } catch (e) { 964 | console.log(e.message); 965 | } 966 | } 967 | 968 | EXIF.enableXmp = function() { 969 | EXIF.isXmpEnabled = true; 970 | } 971 | 972 | EXIF.disableXmp = function() { 973 | EXIF.isXmpEnabled = false; 974 | } 975 | 976 | EXIF.getData = function(img, callback) { 977 | if (((self.Image && img instanceof self.Image) 978 | || (self.HTMLImageElement && img instanceof self.HTMLImageElement)) 979 | && !img.complete) 980 | return false; 981 | 982 | if (!imageHasData(img)) { 983 | getImageData(img, callback); 984 | } else { 985 | if (callback) { 986 | callback.call(img); 987 | } 988 | } 989 | return true; 990 | } 991 | 992 | EXIF.getTag = function(img, tag) { 993 | if (!imageHasData(img)) return; 994 | return img.exifdata[tag]; 995 | } 996 | 997 | EXIF.getIptcTag = function(img, tag) { 998 | if (!imageHasData(img)) return; 999 | return img.iptcdata[tag]; 1000 | } 1001 | 1002 | EXIF.getAllTags = function(img) { 1003 | if (!imageHasData(img)) return {}; 1004 | var a, 1005 | data = img.exifdata, 1006 | tags = {}; 1007 | for (a in data) { 1008 | if (data.hasOwnProperty(a)) { 1009 | tags[a] = data[a]; 1010 | } 1011 | } 1012 | return tags; 1013 | } 1014 | 1015 | EXIF.getAllIptcTags = function(img) { 1016 | if (!imageHasData(img)) return {}; 1017 | var a, 1018 | data = img.iptcdata, 1019 | tags = {}; 1020 | for (a in data) { 1021 | if (data.hasOwnProperty(a)) { 1022 | tags[a] = data[a]; 1023 | } 1024 | } 1025 | return tags; 1026 | } 1027 | 1028 | EXIF.pretty = function(img) { 1029 | if (!imageHasData(img)) return ""; 1030 | var a, 1031 | data = img.exifdata, 1032 | strPretty = ""; 1033 | for (a in data) { 1034 | if (data.hasOwnProperty(a)) { 1035 | if (typeof data[a] == "object") { 1036 | if (data[a] instanceof Number) { 1037 | strPretty += a + " : " + data[a] + " [" + data[a].numerator + "/" + data[a].denominator + "]\r\n"; 1038 | } else { 1039 | strPretty += a + " : [" + data[a].length + " values]\r\n"; 1040 | } 1041 | } else { 1042 | strPretty += a + " : " + data[a] + "\r\n"; 1043 | } 1044 | } 1045 | } 1046 | return strPretty; 1047 | } 1048 | 1049 | EXIF.readFromBinaryFile = function(file) { 1050 | return findEXIFinJPEG(file); 1051 | } 1052 | 1053 | if (typeof define === 'function' && define.amd) { 1054 | define('exif-js', [], function() { 1055 | return EXIF; 1056 | }); 1057 | } 1058 | }.call(this)); 1059 | 1060 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lightbroom", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "git@github.com:zz85/lightbroom.git", 6 | "author": "Joshua Koo ", 7 | "license": "MIT", 8 | "scripts": { 9 | "app": "electron app", 10 | "build": "electron-packager app LightBroom --platform=darwin --arch=x64 --version=0.36.2 --version-string.CompanyName=zz85 --version-string.ProductName=LightBroom" 11 | }, 12 | "dependencies": { 13 | "electron-prebuilt": "^0.36.2", 14 | "electron-packager": "^12.0.0" 15 | } 16 | } 17 | --------------------------------------------------------------------------------