- commit
- 27136f2f67a0763f0f199eb1ae2224832a58d378
- parent
- fba043ee54181fa22d0a23dc1888169a29a5ef3d
- author
- Dami <dami.etoile@gmail.com>
| M | NOTICE.md | 1 | + |
| M | fuji.html | 1 | + |
| M | fuji.mjs | 12 | +++-- |
| A | lens.mjs | 72 | ++++++++++++++++++++++++++++++ |
| M | raf.mjs | 4 | +- |
| M | rawworker.mjs | 24 | +++++++--- |
| M | scripts/ | 2 | +- |
@@ -19,6 +19,7 @@ Original notices are retained in the source. Both GPL and AGPL license texts are
1919 2020 - LibRaw (https://github.com/LibRaw/LibRaw), revision `116924e5bcb9483ef9c5fbe7f3cfc5a8a4b97064`, unmodified, compiled to WebAssembly with the thin wrapper in `native/raw/wrapper.cpp`. LibRaw is used under the LGPL-2.1; see `LICENSES/LGPL-2.1.txt` and `LICENSES/LIBRAW-COPYRIGHT.txt`. Obtain its source at the URL above at the pinned revision.2121 - Film simulation LUTs in `assets/luts/` (provia, velvia, astia, classic_chrome, pro_neg_std, pro_neg_high, eterna, acros, mono and filter variants, sepia): Bastian Bechtold, https://github.com/bastibe/Fujifilm-Auto-Settings-for-Darktable revision `79a458ce9bc28c870091c978b829aaed8dbdb45a`, GPL, unmodified. They were fitted from a Fujifilm camera's own renders against darktable's default render and approximate the camera looks.22+- `lens.mjs`: Fujifilm in-camera lens correction (distortion, chromatic aberration, vignetting) from the RAF metadata splines. Algorithm adapted from darktable 4.6 `src/iop/lens.cc` and RawTherapee `rtengine/lensmetadata.cc`, both GPL-3.0-or-later.2223 - `assets/luts/neutral-dr*.png`: generated by `scripts/neutral-lut.py` using darktable-cli 5.6.1 (GPL-3.0-or-later). They map LibRaw's render to darktable's default scene-referred render so the film simulation LUTs apply as intended. Film simulation names are Fujifilm trademarks used to identify the looks being approximated; this project is not affiliated with Fujifilm.2324 2425 - Interface glyphs on the landing page and empty states: Lucide Icons (https://lucide.dev), ISC license, see `LICENSES/LUCIDE.txt`.@@ -42,6 +42,7 @@
4242 <div class="chips" id="sims" role="radiogroup" aria-label="Film simulation"></div>4343 <div class="toolbar">4444 <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>45+ <button class="tb" id="lens" aria-label="Lens correction" aria-pressed="true" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M12 3v18M3 12h18" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 7.5q5.5 2 11 0M6.5 16.5q5.5-2 11 0" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg></button>4546 <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>4647 </div>4748 </section>@@ -32,7 +32,7 @@ function layout(){
3232 }3333 }3434 new ResizeObserver(layout).observe(stageEl);photo.addEventListener('load',layout);35-let current=null,busy=false,toastTimer,sim='provia',dr=100,showCamera=false,holdCamera=false,renderId=0,cameraUrl=null;35+let current=null,busy=false,toastTimer,sim='provia',dr=100,showCamera=false,holdCamera=false,renderId=0,cameraUrl=null,lensOn=true;3636 const loaded=new Set(),loading=new Map();3737 3838 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);}@@ -40,6 +40,7 @@ function sync(){
4040 tool.dataset.state=current?'detected':'empty';4141 $('save').disabled=!current||busy;$('share').disabled=!current||busy;$('open').disabled=busy;$('open-empty').disabled=busy;$('compare').disabled=!current||!current.camera;4242 $('busy').hidden=!busy;43+ $('lens').disabled=!current?.info.lens||busy;$('lens').setAttribute('aria-pressed',String(lensOn&&Boolean(current?.info.lens)));4344 for(const b of $('sims').children){b.setAttribute('aria-checked',String(b.dataset.sim===sim));b.disabled=!current||busy;}4445 const cam=(showCamera||holdCamera)&¤t?.camera;4546 canvas.hidden=Boolean(cam);photo.hidden=!cam;$('compare').setAttribute('aria-pressed',String(Boolean(cam)));@@ -70,12 +71,12 @@ async function openFile(file){
7071 try{7172 if(file.size>200*1024*1024)throw Error('Choose a RAF smaller than 200 MB.');7273 const buffer=await file.arrayBuffer();73- let camera=null,shot='';74- try{const r=parseRaf(buffer.slice(0));camera=new Blob([r.jpeg],{type:'image/jpeg'});shot=r.simulation;}catch{}75- const info=await rpc('open',{buffer},[buffer]);74+ let camera=null,shot='',cropf=1;75+ 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{}76+ const info=await rpc('open',{buffer,cropf},[buffer]);7677 if(cameraUrl)URL.revokeObjectURL(cameraUrl);cameraUrl=camera?URL.createObjectURL(camera):null;photo.src=cameraUrl||'';7778 current={name:file.name.replace(/\.[^.]+$/,''),camera,info};78- dr=[100,200,400].includes(info.dr)?info.dr:100;sim=FROM_CAMERA[shot]||'provia';showCamera=false;79+ dr=[100,200,400].includes(info.dr)?info.dr:100;sim=FROM_CAMERA[shot]||'provia';showCamera=false;lensOn=true;7980 $('info').textContent=[info.model,shot,dr!==100?'DR'+dr:''].filter(Boolean).join(' ');$('info').hidden=false;8081 sync();await render();8182 }catch(e){status(e.message||'Could not read this file.',true);}@@ -93,6 +94,7 @@ stage.addEventListener('contextmenu',e=>e.preventDefault());
9394 stage.addEventListener('pointerdown',e=>{if(!current?.camera||busy||e.target.closest('button'))return;holdCamera=true;sync();});9495 for(const t of ['pointerup','pointercancel','pointerleave'])stage.addEventListener(t,()=>{if(!holdCamera)return;holdCamera=false;sync();});9596 $('compare').onclick=()=>{showCamera=!showCamera;sync();};97+$('lens').onclick=async()=>{lensOn=!lensOn;sync();try{await rpc('lens',{on:lensOn});await render();}catch(e){status(e.message,true);}};9698 // Save9799 const 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();});98100 $('save').onclick=()=>saveSheet.showModal();@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later2+// Fujifilm in-camera lens corrections from the RAF metadata: distortion, lateral chromatic3+// aberration and vignetting splines. Algorithm adapted from darktable 4.6 src/iop/lens.cc4+// and RawTherapee rtengine/lensmetadata.cc (GPL-3.0-or-later).5+const TYPES={1:1,2:1,3:2,4:4,5:8,7:1,8:2,9:4,10:8,11:4,12:8};6+// Reads GeometricDistortionParams, ChromaticAberrationParams and VignettingParams from the RAF's Fuji IFD.7+export function parseLens(buffer){8+ try{9+ const b=new Uint8Array(buffer),dv=new DataView(buffer);10+ if(String.fromCharCode(...b.subarray(0,16))!=='FUJIFILMCCD-RAW ')return null;11+ const base=dv.getUint32(100);12+ if(b[base]!==0x49||b[base+1]!==0x49)return null; // little endian TIFF13+ const u16=o=>dv.getUint16(base+o,true),u32=o=>dv.getUint32(base+o,true),i32=o=>dv.getInt32(base+o,true);14+ const read=(off,tag)=>{const n=u16(off);for(let i=0;i<n;i++){const e=off+2+i*12;if(u16(e)!==tag)continue;const type=u16(e+2),count=u32(e+4),size=(TYPES[type]||1)*count,val=size<=4?e+8:u32(e+8);15+ if(type!==10)return null;const out=[];for(let k=0;k<count;k++){const d=i32(val+k*8+4);out.push(d?i32(val+k*8)/d:0);}return out;}return null;};16+ const fujiIfd=(()=>{const off=u32(4),n=u16(off);for(let i=0;i<n;i++){const e=off+2+i*12;if(u16(e)===0xf000)return u32(e+8);}return 0;})();17+ if(!fujiIfd)return null;18+ const d=read(fujiIfd,0xf00b),c=read(fujiIfd,0xf00f),v=read(fujiIfd,0xf010);19+ if(!d||!c||!v)return null;20+ let nc,knots,dist,caR,caB,vig;21+ if(d.length===19&&c.length===29&&v.length===19){nc=9;knots=d.slice(1,10);dist=d.slice(10,19);caR=c.slice(10,19);caB=c.slice(19,28);vig=v.slice(10,19);}22+ else if(d.length===23&&c.length===31&&v.length===23){nc=11;knots=d.slice(1,12);dist=d.slice(12,23);caR=[0,...c.slice(11,21)];caB=[0,...c.slice(21,31)];vig=v.slice(12,23);}23+ else return null;24+ return {nc,knots,dist,caR,caB,vig};25+ }catch{return null;}26+}27+function spline(xs,ys,x){28+ if(x<xs[0])return ys[0];29+ for(let i=1;i<xs.length;i++)if(x>=xs[i-1]&&x<=xs[i])return ys[i-1]+(x-xs[i-1])*(ys[i]-ys[i-1])/(xs[i]-xs[i-1]);30+ return ys[ys.length-1];31+}32+// Builds per-radius factor tables. cropf is 1.25 for the 1.25x crop modes. aspect is width/height of the33+// image: the camera scales the corrected image so the field of view at the short-edge midpoint is kept.34+export function lensTables(p,cropf=1,aspect=1.5){35+ const kin=[],din=[],crin=[],cbin=[],kvig=[],vig=[];36+ if(p.knots[0]>0){kin.push(0);din.push(1);crin.push(0);cbin.push(0);kvig.push(0);vig.push(1);}37+ for(let i=0;i<p.nc;i++){kin.push(cropf*p.knots[i]);din.push(p.dist[i]/100+1);crin.push(p.caR[i]);cbin.push(p.caB[i]);kvig.push(cropf*p.knots[i]);vig.push(p.vig[i]?100/p.vig[i]:1);}38+ // Convert from source-radius splines to destination-radius splines.39+ const kd=[],dd=[],cr=[],cb=[];40+ for(let i=0;i<16;i++){const rin=i/(p.nc-1),m=spline(kin,din,rin);kd.push(rin/m);dd.push(m);cr.push(spline(kin,crin,rin)+1);cb.push(spline(kin,cbin,rin)+1);}41+ // Global scale g: the short-edge midpoint (radius h/2 over the half diagonal) maps onto itself.42+ const rv=1/Math.hypot(aspect,1);let g=1;for(let i=0;i<30;i++)g=spline(kin,din,rv/g);43+ const N=1024,fr=new Float32Array(N+1),fg=new Float32Array(N+1),fb=new Float32Array(N+1),fv=new Float32Array(N+1);44+ for(let i=0;i<=N;i++){const r=i/N*1.5,rr=r/g,m=spline(kd,dd,rr)/g;fr[i]=m*spline(kd,cr,rr);fg[i]=m;fb[i]=m*spline(kd,cb,rr);fv[i]=spline(kvig,vig,r);}45+ const hasVig=vig.some(x=>Math.abs(x-1)>1e-4),hasDist=dd.some(x=>Math.abs(x-1)>1e-6)||cr.some(x=>Math.abs(x-1)>1e-6)||cb.some(x=>Math.abs(x-1)>1e-6);46+ return {fr,fg,fb,fv,N,hasVig,hasDist,g};47+}48+const toLin=new Float32Array(256),toSrgb=new Uint8Array(4097);49+for(let i=0;i<256;i++){const u=i/255;toLin[i]=u<=0.04045?u/12.92:Math.pow((u+0.055)/1.055,2.4);}50+for(let i=0;i<=4096;i++){const l=i/4096;const u=l<=0.0031308?l*12.92:1.055*Math.pow(l,1/2.4)-0.055;toSrgb[i]=Math.max(0,Math.min(255,Math.round(u*255)));}51+// Applies vignetting (in linear light) then distortion and CA (output to input mapping, bilinear) to an 8-bit sRGB RGB image.52+export function correctLens(img,t){53+ const {w,h}=img;let src=img.rgb;54+ const w2=w/2,h2=h/2,rf=1/Math.hypot(w2,h2),scale=t.N/1.5;55+ if(t.hasVig){56+ const v=new Uint8Array(src.length);57+ for(let y=0,i=0;y<h;y++){const yc=y+0.5-h2;for(let x=0;x<w;x++,i+=3){const r=rf*Math.hypot(x+0.5-w2,yc),k=Math.min(t.N,r*scale)|0,f=t.fv[k];58+ for(let c=0;c<3;c++){const l=toLin[src[i+c]]*f;v[i+c]=toSrgb[Math.min(4096,l*4096)|0];}}}59+ src=v;60+ }61+ if(!t.hasDist)return {w,h,rgb:src};62+ const out=new Uint8Array(src.length),tabs=[t.fr,t.fg,t.fb];63+ for(let y=0,o=0;y<h;y++){const yc=y+0.5-h2;64+ for(let x=0;x<w;x++,o+=3){const xc=x+0.5-w2,r=rf*Math.hypot(xc,yc),k=Math.min(t.N,r*scale)|0;65+ for(let c=0;c<3;c++){const cf=tabs[c][k],sx=cf*xc+w2-0.5,sy=cf*yc+h2-0.5;66+ const x0=Math.floor(sx),y0=Math.floor(sy);67+ if(x0<0||y0<0||x0>=w-1||y0>=h-1){out[o+c]=0;continue;}68+ const fx=sx-x0,fy=sy-y0,i=(y0*w+x0)*3+c;69+ out[o+c]=src[i]*(1-fx)*(1-fy)+src[i+3]*fx*(1-fy)+src[i+w*3]*(1-fx)*fy+src[i+w*3+3]*fx*fy+0.5;70+ }}}71+ return {w,h,rgb:out};72+}@@ -14,7 +14,7 @@ export function parseRaf(buffer){
1414 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'};1515 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'};1616 function readExif(jpeg){17- const out={simulation:'',camera:'',width:0,height:0,date:''};17+ const out={simulation:'',camera:'',width:0,height:0,date:'',cropMode:0};1818 try{1919 const v=new DataView(jpeg.buffer,jpeg.byteOffset,jpeg.byteLength);2020 let p=2;@@ -43,6 +43,6 @@ function parseTiff(t,out){
4343 const m=new DataView(t.buffer,t.byteOffset+maker,makerLen);4444 const mo=m.getUint32(8,true),n=m.getUint16(mo,true);4545 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;}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;if(tag===0x104d)out.cropMode=val;}4747 out.simulation=MONO[mono]||FILM[film]||'';4848 }@@ -1,9 +1,10 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later22 // Develops Fujifilm RAF files with LibRaw and applies film simulation LUTs off the main thread.33 import createRaw from './assets/raw.mjs';4+import {parseLens,lensTables,correctLens} from './lens.mjs';45 const corePromise=createRaw();56 const N=33; // neutral LUT grid6-let full=null,preview=null; // {w,h,rgb:Uint8Array}7+let developed=null,corrected=null,full=null,preview=null,lens=null; // {w,h,rgb:Uint8Array}78 let luts={neutral:{},sim:{}}; // decoded LUT tables: neutral[dr] = Uint8Array(N^3*3), sim[name] = Uint8Array(16^3*3)89 let composed=new Map(); // key dr:sim -> Uint8Array(N^3*3)910 @@ -48,8 +49,14 @@ function render(img,table){
4849 }4950 return {w,h,data:out};5051 }51-async function open(buffer){52+function select(useLens){53+ full=useLens&&corrected?corrected:developed;54+ const factor=Math.max(1,Math.ceil(Math.max(full.w,full.h)/1600));55+ preview=factor>1?downscale(full.rgb,full.w,full.h,factor):full;56+}57+async function open(buffer,cropf){5258 const core=await corePromise;59+ const params=parseLens(buffer);5360 const p=core._malloc(buffer.byteLength);if(!p)throw Error('Not enough memory for this file.');5461 core.HEAPU8.set(new Uint8Array(buffer),p);5562 try{@@ -58,19 +65,22 @@ async function open(buffer){
5865 if(!/fuji/i.test(info.make))throw Error('Only Fujifilm RAF files are supported.');5966 r=core._raw_develop(1,1,1,8);if(r)throw Error('Developing failed.');6067 const w=core._raw_out_width(),h=core._raw_out_height(),d=core._raw_data();61- full={w,h,rgb:core.HEAPU8.slice(d,d+w*h*3)};62- const factor=Math.max(1,Math.ceil(Math.max(w,h)/1600));63- preview=factor>1?downscale(full.rgb,w,h,factor):full;68+ developed={w,h,rgb:core.HEAPU8.slice(d,d+w*h*3)};69+ lens=params?lensTables(params,cropf,w/h):null;70+ corrected=lens&&(lens.hasDist||lens.hasVig)?correctLens(developed,lens):null;71+ info.lens=Boolean(corrected);72+ select(true);6473 return info;6574 }finally{core._raw_free();core._free(p);}6675 }6776 self.onmessage=async({data})=>{6877 try{6978 let result,transfer=[];70- if(data.type==='open'){result=await open(data.buffer);}79+ if(data.type==='open'){result=await open(data.buffer,data.cropf||1);}80+ else if(data.type==='lens'){select(data.on);result={ok:true};}7181 else if(data.type==='lut'){luts[data.kind][data.name]=data.table;composed.clear();result={ok:true};}7282 else if(data.type==='render'){const img=data.full?full:preview;if(!img)throw Error('Open a RAF first.');result=render(img,compose(data.dr,data.sim));transfer=[result.data.buffer];}73- else if(data.type==='close'){full=preview=null;result={ok:true};}83+ else if(data.type==='close'){developed=corrected=full=preview=null;result={ok:true};}7484 else throw Error('Unknown operation.');7585 self.postMessage({id:data.id,result},transfer);7686 }catch(e){self.postMessage({id:data.id,error:e.message||'Could not process this file.'});}@@ -14,7 +14,7 @@ if dist.exists(): shutil.rmtree(dist)
1414 1515 # Hashed files in dependency order: leaves first, so a file's hash covers its rewritten references.1616 LUTS = sorted(p.relative_to(root).as_posix() for p in (root / 'assets' / 'luts').glob('*.png'))17-HASHED = ['assets/core.wasm', 'assets/core.mjs', 'assets/raw.wasm', 'assets/raw.mjs'] + LUTS + ['geometry.mjs', 'worker.mjs', 'app.mjs', 'raf.mjs', 'rawworker.mjs', 'fuji.mjs', 'home.mjs', 'viewport.js', 'style.css']17+HASHED = ['assets/core.wasm', 'assets/core.mjs', 'assets/raw.wasm', 'assets/raw.mjs'] + LUTS + ['geometry.mjs', 'worker.mjs', 'app.mjs', 'raf.mjs', 'lens.mjs', 'rawworker.mjs', 'fuji.mjs', 'home.mjs', 'viewport.js', 'style.css']1818 # Fixed-name text files that reference hashed ones.1919 PAGES = ['index.html', 'perspective.html', 'fuji.html']2020 FIXED = ['manifest.webmanifest', 'NOTICE.md', 'README.md', '_headers']