clone

a photo toolkit for your phone that never uploads: fuji film looks for raw files, social crops, error level analysis

Fuji tool: extract the in-camera JPEG from a RAF with film simulation and camera details

Dami committed 22d0b46
commit
22d0b46654c9ca6c0cd0a818189410b6546e0ec0
parent
30fe6299788ae3a70b850705dbb797ee5b4c67b1
author
Dami <dami.etoile@gmail.com>
9 files changed, +180 -3
MREADME.md2+
Afuji.html53+++++++++++++++++++++++++++++
Afuji.mjs54++++++++++++++++++++++++++++++
Mindex.html11++++++
Mmanifest.webmanifest3+-
Araf.mjs48+++++++++++++++++++++++++++
Mscripts/package.py2+-
Mstyle.css8++++
Msw.js2+-
M README.md +2 -0
A fuji.html +53 -0
@@ -0,0 +1,53 @@
1+<!doctype html>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>
A fuji.mjs +54 -0
@@ -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();
M index.html +11 -0
M manifest.webmanifest +2 -1
A raf.mjs +48 -0
@@ -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+}
M scripts/package.py +1 -1
M style.css +8 -0
M sw.js +1 -1