clone

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

main
gudpics / fuji.mjs
fuji.mjs 11.0K
1// SPDX-License-Identifier: AGPL-3.0-or-later2import {parseRaf} from './raf.mjs';3const $=id=>document.getElementById(id);4const worker=new Worker(new URL('./rawworker.mjs',import.meta.url),{type:'module'});5let nextId=0;const pending=new Map();6worker.onmessage=({data})=>{const p=pending.get(data.id);if(!p)return;pending.delete(data.id);data.error?p.reject(Error(data.error)):p.resolve(data.result);};7worker.onerror=()=>{for(const p of pending.values())p.reject(Error('The raw engine stopped. Reload to try again.'));pending.clear();};8const rpc=(type,args={},transfer=[])=>new Promise((resolve,reject)=>{const id=++nextId;pending.set(id,{resolve,reject});worker.postMessage({type,id,...args},transfer);});910// Film simulation LUTs (GPL, see NOTICE.md). Names are the looks being approximated.11const SIMS=[12 ['provia','Provia'],['velvia','Velvia'],['astia','Astia'],['classic_chrome','Classic Chrome'],['pro_neg_high','Pro Neg. Hi'],['pro_neg_std','Pro Neg. Std'],['eterna','Eterna'],13 ['acros','Acros'],['acros-red','Acros+R'],['acros-yellow','Acros+Ye'],['acros-green','Acros+G'],['mono','Mono'],['mono-red','Mono+R'],['mono-yellow','Mono+Ye'],['mono-green','Mono+G'],['sepia','Sepia']];14const LUT_URLS={15 provia:new URL('./assets/luts/provia.png',import.meta.url),velvia:new URL('./assets/luts/velvia.png',import.meta.url),astia:new URL('./assets/luts/astia.png',import.meta.url),16 classic_chrome:new URL('./assets/luts/classic_chrome.png',import.meta.url),pro_neg_high:new URL('./assets/luts/pro_neg_high.png',import.meta.url),pro_neg_std:new URL('./assets/luts/pro_neg_std.png',import.meta.url),17 eterna:new URL('./assets/luts/eterna.png',import.meta.url),acros:new URL('./assets/luts/acros.png',import.meta.url),'acros-red':new URL('./assets/luts/acros-red.png',import.meta.url),18 'acros-yellow':new URL('./assets/luts/acros-yellow.png',import.meta.url),'acros-green':new URL('./assets/luts/acros-green.png',import.meta.url),mono:new URL('./assets/luts/mono.png',import.meta.url),19 'mono-red':new URL('./assets/luts/mono-red.png',import.meta.url),'mono-yellow':new URL('./assets/luts/mono-yellow.png',import.meta.url),'mono-green':new URL('./assets/luts/mono-green.png',import.meta.url),20 sepia:new URL('./assets/luts/sepia.png',import.meta.url)};21const NEUTRAL_URLS={100:new URL('./assets/luts/neutral-dr100.png',import.meta.url),200:new URL('./assets/luts/neutral-dr200.png',import.meta.url),400:new URL('./assets/luts/neutral-dr400.png',import.meta.url)};22// Camera film mode and monochrome names (from the RAF makernote) to our LUT names.23const FROM_CAMERA={'Provia':'provia','Velvia':'velvia','Astia':'astia','Classic Chrome':'classic_chrome','Pro Neg. Hi':'pro_neg_high','Pro Neg. Std':'pro_neg_std','Eterna':'eterna',24 'Acros':'acros','Acros+R':'acros-red','Acros+Ye':'acros-yellow','Acros+G':'acros-green','Monochrome':'mono','Monochrome+R':'mono-red','Monochrome+Ye':'mono-yellow','Monochrome+G':'mono-green','Sepia':'sepia'};2526const tool=$('tool'),canvas=$('canvas'),ctx=canvas.getContext('2d'),photo=$('photo'),stageEl=$('stage');27// Size the picture box to fit the stage, same for the developed canvas and the camera JPEG.28function layout(){29 const cs=getComputedStyle(stageEl),w=stageEl.clientWidth-parseFloat(cs.paddingLeft)-parseFloat(cs.paddingRight),h=stageEl.clientHeight-parseFloat(cs.paddingTop)-parseFloat(cs.paddingBottom);30 for(const [el,iw,ih] of [[canvas,canvas.width,canvas.height],[photo,photo.naturalWidth,photo.naturalHeight]]){31 if(!iw||!ih)continue;const s=Math.min(w/iw,h/ih);el.style.width=Math.max(1,Math.floor(iw*s))+'px';el.style.height=Math.max(1,Math.floor(ih*s))+'px';32 }33}34new ResizeObserver(layout).observe(stageEl);photo.addEventListener('load',layout);35let current=null,busy=false,toastTimer,sim='provia',dr=100,showCamera=false,holdCamera=false,renderId=0,cameraUrl=null,lensOn=true;36let exportCache=null,exportTimer,exportKey='';37const loaded=new Set(),loading=new Map();3839function 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);}40function sync(){41 tool.dataset.state=current?'detected':'empty';42 $('save').disabled=!current||busy;$('share').disabled=!current||busy;$('open').disabled=busy;$('open-empty').disabled=busy;$('compare').disabled=!current||!current.camera;43 $('busy').hidden=!busy;44 $('lens').disabled=!current?.info.lens||busy;$('lens').setAttribute('aria-pressed',String(lensOn&&Boolean(current?.info.lens)));45 for(const b of $('sims').children){b.setAttribute('aria-checked',String(b.dataset.sim===sim));b.disabled=!current||busy;}46 const cam=(showCamera||holdCamera)&&current?.camera;47 canvas.hidden=Boolean(cam);photo.hidden=!cam;$('compare').setAttribute('aria-pressed',String(Boolean(cam)));48}49async function decodeLut(url){50 const bitmap=await createImageBitmap(await (await fetch(url)).blob());51 const c=document.createElement('canvas');c.width=bitmap.width;c.height=bitmap.height;const x=c.getContext('2d');x.drawImage(bitmap,0,0);52 const d=x.getImageData(0,0,c.width,c.height).data,out=new Uint8Array(c.width*c.height*3);53 for(let i=0,o=0;i<d.length;i+=4,o+=3){out[o]=d[i];out[o+1]=d[i+1];out[o+2]=d[i+2];}54 return out;55}56function ensureLut(kind,name,url){57 const key=kind+':'+name;if(loaded.has(key))return Promise.resolve();58 if(!loading.has(key))loading.set(key,decodeLut(url).then(table=>rpc('lut',{kind,name,table},[table.buffer])).then(()=>{loaded.add(key);}).catch(e=>{loading.delete(key);throw e;}));59 return loading.get(key);60}61async function render(){62 if(!current)return;const id=++renderId;63 try{64 await Promise.all([ensureLut('neutral',dr,NEUTRAL_URLS[dr]||NEUTRAL_URLS[100]),ensureLut('sim',sim,LUT_URLS[sim])]);65 const r=await rpc('render',{dr:NEUTRAL_URLS[dr]?dr:100,sim});66 if(id!==renderId)return;67 canvas.width=r.w;canvas.height=r.h;ctx.putImageData(new ImageData(r.data,r.w,r.h),0,0);layout();68 scheduleExport();69 }catch(e){status(e.message,true);}70}71async function openFile(file){72 if(busy)return;busy=true;sync();73 try{74 if(file.size>200*1024*1024)throw Error('Choose a RAF smaller than 200 MB.');75 const buffer=await file.arrayBuffer();76 let camera=null,shot='',cropf=1;77 try{const r=parseRaf(buffer.slice(0));camera=new Blob([r.jpeg],{type:'image/jpeg'});shot=r.simulation;if(r.cropMode===2||r.cropMode===4)cropf=1.25;}catch{}78 const info=await rpc('open',{buffer,cropf},[buffer]);79 if(cameraUrl)URL.revokeObjectURL(cameraUrl);cameraUrl=camera?URL.createObjectURL(camera):null;photo.src=cameraUrl||'';80 current={name:file.name.replace(/\.[^.]+$/,''),camera,info};81 dr=[100,200,400].includes(info.dr)?info.dr:100;sim=FROM_CAMERA[shot]||'provia';showCamera=false;lensOn=true;82 $('info').textContent=[info.model,shot,dr!==100?'DR'+dr:''].filter(Boolean).join(' ');$('info').hidden=false;83 sync();await render();84 }catch(e){status(e.message||'Could not read this file.',true);}85 finally{busy=false;sync();}86}87// Chips88for(const [name,label] of SIMS){const b=document.createElement('button');b.className='chip-btn';b.role='radio';b.dataset.sim=name;b.textContent=label;b.disabled=true;b.onclick=()=>{sim=name;showCamera=false;sync();render();b.scrollIntoView({inline:'center',block:'nearest',behavior:'smooth'});};$('sims').append(b);}89$('file').onchange=()=>{const f=$('file').files[0];if(f)openFile(f);$('file').value='';};90$('open').onclick=()=>$('file').click();$('open-empty').onclick=()=>$('file').click();91const stage=$('stage'),zone=$('open-empty');92stage.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='copy';zone.classList.add('over');});93stage.addEventListener('dragleave',()=>zone.classList.remove('over'));94stage.addEventListener('drop',e=>{e.preventDefault();zone.classList.remove('over');const f=e.dataTransfer.files[0];if(f)openFile(f);});95stage.addEventListener('contextmenu',e=>e.preventDefault());96stage.addEventListener('pointerdown',e=>{if(!current?.camera||busy||e.target.closest('button'))return;holdCamera=true;sync();});97for(const t of ['pointerup','pointercancel','pointerleave'])stage.addEventListener(t,()=>{if(!holdCamera)return;holdCamera=false;sync();});98$('compare').onclick=()=>{showCamera=!showCamera;sync();};99$('lens').onclick=async()=>{lensOn=!lensOn;sync();try{await rpc('lens',{on:lensOn});await render();}catch(e){status(e.message,true);}};100// Save101const saveSheet=$('save-sheet');for(const b of saveSheet.querySelectorAll('[data-close]'))b.onclick=()=>saveSheet.close();saveSheet.addEventListener('click',e=>{if(e.target===saveSheet)saveSheet.close();});102$('save').onclick=()=>saveSheet.showModal();103for(const b of saveSheet.querySelectorAll('[data-save]'))b.onclick=()=>{saveSheet.close();save(b.dataset.save);};104function download(blob,name){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);}105// The system share sheet must open inside the tap, so the full-size JPEG is prepared ahead of time.106function scheduleExport(){exportCache=null;clearTimeout(exportTimer);const key=[current?.name,sim,dr,lensOn].join('|');exportTimer=setTimeout(async()=>{try{const r=await developedBlob();if([current?.name,sim,dr,lensOn].join('|')===key){exportCache=r;exportKey=key;}}catch{}},900);}107async function developedBlob(){108 const r=await rpc('render',{dr:NEUTRAL_URLS[dr]?dr:100,sim,full:true});109 const c=document.createElement('canvas');c.width=r.w;c.height=r.h;c.getContext('2d').putImageData(new ImageData(r.data,r.w,r.h),0,0);110 const blob=await new Promise(res=>c.toBlob(res,'image/jpeg',0.94));if(!blob)throw Error('Could not encode the JPEG.');return {blob,w:r.w,h:r.h};111}112async function save(what){113 if(!current)return;busy=true;sync();114 try{115 if(what==='camera'){if(!current.camera)throw Error('No camera JPEG in this file.');download(current.camera,current.name+'.jpg');status('Saved camera JPG');}116 else{const {blob,w,h}=await developedBlob();download(blob,`${current.name}-${sim}.jpg`);status(`Saved ${w} x ${h}`);}117 }catch(e){status(e.message,true);}finally{busy=false;sync();}118}119if(navigator.canShare&&navigator.share)$('share').hidden=false;120$('share').onclick=()=>{121 if(!current)return;122 const fname=`${current.name}-${sim}.jpg`,ready=exportCache&&exportKey===[current.name,sim,dr,lensOn].join('|');123 if(!ready){busy=true;sync();developedBlob().then(({blob})=>{download(blob,fname);status('Saved. Share it from your photos.');}).catch(e=>status(e.message,true)).finally(()=>{busy=false;sync();});return;}124 const file=new File([exportCache.blob],fname,{type:'image/jpeg'});125 if(!navigator.canShare({files:[file]}))return status('Sharing files is not supported here.',true);126 navigator.share({files:[file]}).catch(e=>{if(e.name==='AbortError')return;download(exportCache.blob,fname);status('Saved. Share it from your photos.');});127};128if('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;});}129sync();