- commit
- 22d0b46654c9ca6c0cd0a818189410b6546e0ec0
- parent
- 30fe6299788ae3a70b850705dbb797ee5b4c67b1
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 2 | + |
| A | fuji.html | 53 | +++++++++++++++++++++++++++++ |
| A | fuji.mjs | 54 | ++++++++++++++++++++++++++++++ |
| M | index.html | 11 | ++++++ |
| M | manifest.webmanifest | 3 | +- |
| A | raf.mjs | 48 | +++++++++++++++++++++++++++ |
| M | scripts/ | 2 | +- |
| M | style.css | 8 | ++++ |
| M | sw.js | 2 | +- |
@@ -4,6 +4,7 @@ A mobile-first, installable photo toolkit. Source: https://github.com/FelineStat
44 55 Tools:66 7+- `/fuji`: extracts the in-camera JPEG from a Fujifilm RAF byte for byte, shows the film simulation and camera from its EXIF, and saves or shares it. Pure JavaScript, no decoding of raw sensor data.78 - `/perspective`: straightens converging verticals and horizontals using darktable's C algorithms (LSD line detection, RANSAC, perspective fit, homography) compiled to WebAssembly.89 910 ## Run@@ -43,6 +44,7 @@ The config serves only `dist/`; it has no backend, secrets, storage bindings, an
4344 ## Layout4445 4546 - `index.html` + `home.mjs`: landing grid of tools, install and about.47+- `fuji.html` + `fuji.mjs` + `raf.mjs`: the Fuji tool. `raf.mjs` parses the RAF container and the Fuji makernote.4648 - `perspective.html` + `app.mjs`: the perspective tool UI. `worker.mjs` runs the WebAssembly core off the main thread.4749 - `style.css`: shared styles. Dark, system font, safe-area aware; the tool page never scrolls.4850 @@ -0,0 +1,53 @@
1+2+<html lang="en">3+<head>4+ <meta charset="utf-8">5+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">6+ <script src="./viewport.js"></script>7+ <meta name="theme-color" content="#1a1a1a">8+ <meta name="description" content="Pull the in-camera JPEG out of a Fujifilm RAF. Runs on your device.">9+ <meta name="apple-mobile-web-app-capable" content="yes">10+ <meta name="mobile-web-app-capable" content="yes">11+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">12+ <meta name="apple-mobile-web-app-title" content="gud.pics">13+ <link rel="manifest" href="./manifest.webmanifest">14+ <link rel="icon" href="./assets/icon.svg" type="image/svg+xml">15+ <link rel="apple-touch-icon" href="./assets/icon-192.png">16+ <link rel="stylesheet" href="./style.css">17+ <title>Fuji</title>18+</head>19+<body class="tool-body">20+ <div class="tool" id="tool" data-state="empty">21+ <header class="nav">22+ <a class="nav-btn" href="./" aria-label="Tools"><svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>23+ <h1 class="nav-title">Fuji</h1>24+ <button class="nav-btn" id="save" aria-label="Save" disabled><svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-5-5l5 5 5-5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>25+ </header>26+27+ <section class="stage" id="stage" aria-label="Photo">28+ <div class="canvas-wrap"><img id="photo" alt="Camera JPEG"></div>29+ <button class="empty" id="open-empty" aria-label="Open RAF">30+ <svg width="64" height="64" viewBox="0 0 56 56" aria-hidden="true"><path d="M14 6h20l10 10v34H14z" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linejoin="round"/><path d="M34 6v10h10" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linejoin="round"/><text x="28" y="40" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor" font-family="-apple-system,system-ui,sans-serif">RAF</text></svg>31+ <svg class="plus" width="28" height="28" viewBox="0 0 28 28" aria-hidden="true"><path d="M14 5v18M5 14h18" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"/></svg>32+ </button>33+ <div class="toast" id="status" role="status" aria-live="polite" hidden></div>34+ <div class="busy" id="busy" hidden><span class="spinner"></span></div>35+ </section>36+37+ <section class="panel" aria-label="Details">38+ <dl class="facts" id="facts">39+ <div><dt>Camera</dt><dd id="camera">-</dd></div>40+ <div><dt>Simulation</dt><dd id="simulation">-</dd></div>41+ <div><dt>Size</dt><dd id="size">-</dd></div>42+ <div><dt>Taken</dt><dd id="date">-</dd></div>43+ </dl>44+ <div class="toolbar">45+ <button class="tb" id="open" aria-label="Open"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="8.5" cy="10" r="1.8" fill="currentColor"/><path d="M4 17l5-5 4 4 2.5-2.5L20 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg></button>46+ <button class="tb" id="share" aria-label="Share" disabled hidden><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v13M8 7l4-4 4 4M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></button>47+ </div>48+ </section>49+ <input id="file" type="file" hidden>50+ </div>51+ <script type="module" src="./fuji.mjs"></script>52+</body>53+</html>@@ -0,0 +1,54 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later2+import {parseRaf} from './raf.mjs';3+const $=id=>document.getElementById(id);4+const tool=$('tool'),photo=$('photo');5+let current=null,filename='photo',busy=false,toastTimer,previewUrl;6+function status(message,error=false){clearTimeout(toastTimer);const el=$('status');el.textContent=message;el.classList.toggle('error',error);el.hidden=!message;if(message)toastTimer=setTimeout(()=>{el.hidden=true;},error?5000:2500);}7+function sync(){8+ tool.dataset.state=current?'loaded':'empty';9+ $('save').disabled=!current||busy;$('share').disabled=!current||busy;$('open').disabled=busy;$('open-empty').disabled=busy;10+ $('busy').hidden=!busy;11+}12+function fmtDate(s){const m=/^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2})/.exec(s||'');return m?`${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}`:'-';}13+async function openFile(file){14+ if(busy)return;busy=true;sync();15+ try{16+ if(file.size>200*1024*1024)throw Error('Choose a RAF smaller than 200 MB.');17+ const r=parseRaf(await file.arrayBuffer());18+ if(previewUrl)URL.revokeObjectURL(previewUrl);19+ const blob=new Blob([r.jpeg],{type:'image/jpeg'});20+ previewUrl=URL.createObjectURL(blob);21+ await new Promise((res,rej)=>{photo.onload=res;photo.onerror=()=>rej(Error('The embedded JPEG could not be decoded.'));photo.src=previewUrl;});22+ current={blob,name:file.name.replace(/\.[^.]+$/,'')+'.jpg'};23+ filename=current.name;24+ $('camera').textContent=r.camera||r.model||'-';25+ $('simulation').textContent=r.simulation||'-';26+ $('size').textContent=r.width?`${r.width} x ${r.height}, ${(r.jpeg.length/1e6).toFixed(1)} MB`:`${(r.jpeg.length/1e6).toFixed(1)} MB`;27+ $('date').textContent=fmtDate(r.date);28+ status('');29+ }catch(e){status(e.message||'Could not read this file.',true);}30+ finally{busy=false;sync();}31+}32+$('file').onchange=()=>{const f=$('file').files[0];if(f)openFile(f);$('file').value='';};33+$('open').onclick=()=>$('file').click();$('open-empty').onclick=()=>$('file').click();34+const stage=$('stage'),zone=$('open-empty');35+stage.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='copy';zone.classList.add('over');});36+stage.addEventListener('dragleave',()=>zone.classList.remove('over'));37+stage.addEventListener('drop',e=>{e.preventDefault();zone.classList.remove('over');const f=e.dataTransfer.files[0];if(f)openFile(f);});38+stage.addEventListener('contextmenu',e=>e.preventDefault());39+$('save').onclick=()=>{40+ if(!current)return;41+ const url=URL.createObjectURL(current.blob),a=document.createElement('a');a.href=url;a.download=current.name;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);42+ status('Saved');43+};44+// Share sheet is the native way to get a file into the photo library on phones.45+const canShare=Boolean(navigator.canShare&&navigator.share);46+if(canShare)$('share').hidden=false;47+$('share').onclick=async()=>{48+ if(!current)return;49+ const file=new File([current.blob],current.name,{type:'image/jpeg'});50+ if(!navigator.canShare({files:[file]}))return status('Sharing files is not supported here.',true);51+ try{await navigator.share({files:[file]});}catch(e){if(e.name!=='AbortError')status(e.message,true);}52+};53+if('serviceWorker'in navigator){navigator.serviceWorker.register('./sw.js').catch(()=>{});let hadController=Boolean(navigator.serviceWorker.controller);navigator.serviceWorker.addEventListener('controllerchange',()=>{if(hadController&&!current)location.reload();hadController=true;});}54+sync();@@ -32,6 +32,17 @@
3232 <h2>Perspective</h2>3333 </a>3434 </li>35+ <li>36+ <a class="tool-card" href="./fuji">37+ <svg viewBox="0 0 160 96" aria-hidden="true">38+ <path d="M34 14h62l30 30v38H34z" fill="none" stroke="#5e5e5e" stroke-width="4" stroke-linejoin="round"/>39+ <path d="M96 14v30h30" fill="none" stroke="#5e5e5e" stroke-width="4" stroke-linejoin="round"/>40+ <rect x="52" y="44" width="56" height="26" rx="4" fill="#f0c04a"/>41+ <text x="80" y="63" text-anchor="middle" font-size="16" font-weight="700" fill="#1a1400" font-family="-apple-system,system-ui,sans-serif">JPG</text>42+ </svg>43+ <h2>Fuji</h2>44+ </a>45+ </li>3546 </ul>3647 3748 <section class="list" aria-label="App">@@ -8,6 +8,7 @@
88 {"src": "assets/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"}99 ],1010 "shortcuts": [11- {"name": "Perspective", "url": "./perspective", "icons": [{"src": "assets/icon-192.png", "sizes": "192x192"}]}11+ {"name": "Perspective", "url": "./perspective", "icons": [{"src": "assets/icon-192.png", "sizes": "192x192"}]},12+ {"name": "Fuji", "url": "./fuji", "icons": [{"src": "assets/icon-192.png", "sizes": "192x192"}]}1213 ]1314 }@@ -0,0 +1,48 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later2+// Fujifilm RAF container: extract the embedded in-camera JPEG and read what the camera applied.3+const MAGIC='FUJIFILMCCD-RAW ';4+export function parseRaf(buffer){5+ const bytes=new Uint8Array(buffer),view=new DataView(buffer);6+ if(bytes.length<112||String.fromCharCode(...bytes.subarray(0,16))!==MAGIC)throw Error('Not a Fujifilm RAF file.');7+ const model=String.fromCharCode(...bytes.subarray(28,60)).replace(/\0+$/,'').trim();8+ const offset=view.getUint32(84),length=view.getUint32(88);9+ if(offset+length>bytes.length||length<4||bytes[offset]!==0xFF||bytes[offset+1]!==0xD8)throw Error('No embedded JPEG found in this RAF.');10+ const jpeg=bytes.slice(offset,offset+length);11+ return {model,jpeg,...readExif(jpeg)};12+}13+// Fuji makernote tags (as documented by ExifTool).14+const FILM={0:'Provia',0x100:'Studio Portrait',0x110:'Studio Portrait Enhanced',0x120:'Astia',0x130:'Studio Portrait Sharp',0x200:'Velvia',0x300:'Studio Portrait Ex',0x400:'Velvia',0x500:'Pro Neg. Std',0x501:'Pro Neg. Hi',0x600:'Classic Chrome',0x700:'Eterna',0x800:'Classic Neg.',0x900:'Eterna Bleach Bypass',0xa00:'Nostalgic Neg.',0xb00:'Reala Ace'};15+const MONO={0x300:'Monochrome',0x301:'Monochrome+R',0x302:'Monochrome+Ye',0x303:'Monochrome+G',0x310:'Sepia',0x500:'Acros',0x501:'Acros+R',0x502:'Acros+Ye',0x503:'Acros+G'};16+function readExif(jpeg){17+ const out={simulation:'',camera:'',width:0,height:0,date:''};18+ try{19+ const v=new DataView(jpeg.buffer,jpeg.byteOffset,jpeg.byteLength);20+ let p=2;21+ while(p+4<=jpeg.length&&jpeg[p]===0xFF){22+ const marker=jpeg[p+1],len=v.getUint16(p+2);23+ if(marker===0xE1&&String.fromCharCode(...jpeg.subarray(p+4,p+10))==='Exif\0\0'){parseTiff(jpeg.subarray(p+10,p+2+len),out);}24+ if(marker>=0xC0&&marker<=0xC3){out.height=v.getUint16(p+5);out.width=v.getUint16(p+7);}25+ if(marker===0xDA)break;26+ p+=2+len;27+ }28+ }catch{}29+ return out;30+}31+function parseTiff(t,out){32+ const v=new DataView(t.buffer,t.byteOffset,t.byteLength),le=t[0]===0x49;33+ const u16=o=>v.getUint16(o,le),u32=o=>v.getUint32(o,le);34+ const str=(o,n)=>String.fromCharCode(...t.subarray(o,o+n)).replace(/\0+$/,'').trim();35+ const ifd=(o,cb)=>{const n=u16(o);for(let i=0;i<n;i++){const e=o+2+i*12,tag=u16(e),type=u16(e+2),count=u32(e+4);const size=[0,1,1,2,4,8,1,1,2,4,8,4,8][type]*count;const val=size<=4?e+8:u32(e+8);cb(tag,type,count,val);}};36+ let exifOffset=0;37+ ifd(u32(4),(tag,type,count,val)=>{if(tag===0x110)out.camera=str(val,count);if(tag===0x8769)exifOffset=u32(val);});38+ if(!exifOffset)return;39+ let maker=0,makerLen=0;40+ ifd(exifOffset,(tag,type,count,val)=>{if(tag===0x927c){maker=val;makerLen=count;}if(tag===0x9003)out.date=str(val,count);});41+ if(!maker||str(maker,8)!=='FUJIFILM')return;42+ // Fuji makernote: 8 byte signature, 4 byte IFD offset relative to the makernote start, little endian.43+ const m=new DataView(t.buffer,t.byteOffset+maker,makerLen);44+ const mo=m.getUint32(8,true),n=m.getUint16(mo,true);45+ let film=0,mono=-1;46+ for(let i=0;i<n;i++){const e=mo+2+i*12,tag=m.getUint16(e,true),type=m.getUint16(e+2,true);const val=type===3?m.getUint16(e+8,true):m.getUint32(e+8,true);if(tag===0x1401)film=val;if(tag===0x1003)mono=val;}47+ out.simulation=MONO[mono]||FILM[film]||'';48+}@@ -6,7 +6,7 @@ import shutil, hashlib
66 root=Path(__file__).resolve().parent.parent77 dist=root/'dist'88 dist.mkdir(exist_ok=True)9-public=['index.html','perspective.html','style.css','viewport.js','home.mjs','app.mjs','worker.mjs','geometry.mjs','manifest.webmanifest','NOTICE.md','README.md','_headers']9+public=['index.html','perspective.html','fuji.html','style.css','viewport.js','home.mjs','app.mjs','fuji.mjs','raf.mjs','worker.mjs','geometry.mjs','manifest.webmanifest','NOTICE.md','README.md','_headers']1010 for f in public: shutil.copyfile(root/f,dist/f)1111 for d in ['assets','LICENSES']: shutil.copytree(root/d,dist/d,dirs_exist_ok=True)1212 digest=hashlib.sha256()@@ -231,6 +231,14 @@ input[type=range]::-moz-range-thumb { width: 26px; height: 26px; border: 0; bord
231231 .tb[aria-pressed="true"]:not(:disabled) { color: var(--tint); }232232 .tb svg { width: 26px; height: 26px; }233233 234+/* ---------- Fuji ---------- */235+236+#photo { display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; }237+.facts { margin: 0; display: grid; gap: 2px; }238+.facts div { display: flex; justify-content: space-between; gap: 12px; min-height: 30px; align-items: center; font-size: 14px; }239+.facts dt { color: var(--label-2); }240+.facts dd { margin: 0; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }241+234242 /* ---------- Sheets ---------- */235243 236244 dialog.sheet {@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later22 const CACHE='gudpics-v1';3-const FILES=['./','./index.html','./perspective','./style.css','./viewport.js','./home.mjs','./app.mjs','./worker.mjs','./geometry.mjs','./manifest.webmanifest','./assets/core.mjs','./assets/core.wasm','./assets/icon.svg','./assets/icon-192.png','./assets/icon-512.png','./NOTICE.md','./LICENSES/AGPL-3.0.txt','./LICENSES/GPL-3.0.txt'];3+const FILES=['./','./index.html','./perspective','./fuji','./style.css','./viewport.js','./home.mjs','./app.mjs','./fuji.mjs','./raf.mjs','./worker.mjs','./geometry.mjs','./manifest.webmanifest','./assets/core.mjs','./assets/core.wasm','./assets/icon.svg','./assets/icon-192.png','./assets/icon-512.png','./NOTICE.md','./LICENSES/AGPL-3.0.txt','./LICENSES/GPL-3.0.txt'];44 self.addEventListener('install',e=>{self.skipWaiting();e.waitUntil(caches.open(CACHE).then(c=>c.addAll(FILES)));});55 self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k)))).then(()=>self.clients.claim())));66 self.addEventListener('fetch',e=>{