1// SPDX-License-Identifier: AGPL-3.0-or-later2import {project} from './geometry.mjs';3const $=id=>document.getElementById(id);4const worker=new Worker(new URL('./worker.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 photo engine stopped. Reload to try again.'));pending.clear();};8function rpc(type,args={},transfer=[]) {return new Promise((resolve,reject)=>{const id=++nextId;pending.set(id,{resolve,reject});worker.postMessage({type,id,...args},transfer);});}910const tool=$('tool'),canvas=$('canvas'),overlay=$('overlay'),ctx=canvas.getContext('2d');11let original=null,rendered=null,lines=[],analysisSize=null,base=[0,0,0,0],activeMode=null,showBefore=false,holdBefore=false,busy=false,hasDetected=false,renderId=0;12let filename='photo',lastRender=null,renderTimer,cropMode='largest',showLines=true,showGuides=false;13const fitButtons=[...document.querySelectorAll('[data-mode]')];1415let toastTimer;16function 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);}17function syncControls(){18 const ready=Boolean(original)&&!busy;19 tool.dataset.state=!original?'empty':hasDetected?'detected':'loaded';20 for(const id of ['detect','rotation','crop','reset','save','lines','guides'])$(id).disabled=!ready;21 $('open').disabled=busy;$('open-empty').disabled=busy;22 $('strength').disabled=!ready||activeMode===null;23 $('strength-row').classList.toggle('disabled',$('strength').disabled);24 $('rotation-row').classList.toggle('disabled',!ready);25 $('compare').disabled=!ready||!rendered;26 fitButtons.forEach(b=>{b.disabled=!ready||!hasDetected;b.setAttribute('aria-pressed',Number(b.dataset.mode)===activeMode?'true':'false');});27 $('lines').setAttribute('aria-pressed',String(showLines));$('guides').setAttribute('aria-pressed',String(showGuides));28}29function setBusy(value) {busy=value;$('busy').hidden=!value;syncControls();}30function currentParams(){const s=Number($('strength').value)/100;return [Number($('rotation').value),base[1]*s,base[2]*s,base[3]*s];}31function updateLabels(){const r=Number($('rotation').value);$('rotation-value').value=`${r>0?'+':''}${r.toFixed(2)}°`;$('strength-value').value=`${$('strength').value}%`;}32// Size the canvas box to the picture inside the stage, so the overlay covers exactly the picture.33function layout(){34 if(!canvas.width)return;35 const cs=getComputedStyle(stage),w=stage.clientWidth-parseFloat(cs.paddingLeft)-parseFloat(cs.paddingRight),h=stage.clientHeight-parseFloat(cs.paddingTop)-parseFloat(cs.paddingBottom);36 const s=Math.min(w/canvas.width,h/canvas.height),cw=Math.max(1,Math.floor(canvas.width*s)),ch=Math.max(1,Math.floor(canvas.height*s));37 canvas.style.width=overlay.style.width=cw+'px';canvas.style.height=overlay.style.height=ch+'px';38}39function draw(){40 const before=showBefore||holdBefore,source=before||!rendered?original:rendered;41 if(!source)return;42 canvas.width=source.width;canvas.height=source.height;ctx.drawImage(source,0,0);layout();43 $('compare').setAttribute('aria-pressed',String(before&&Boolean(rendered)));44 drawOverlay();45}46function drawOverlay(){47 overlay.width=canvas.width;overlay.height=canvas.height;48 const c=overlay.getContext('2d'),scale=canvas.width/Math.max(canvas.clientWidth,1),before=showBefore||holdBefore;49 if(showGuides){c.strokeStyle='#fff9';c.lineWidth=scale*.75;c.beginPath();for(let i=1;i<4;i++){c.moveTo(i*canvas.width/4,0);c.lineTo(i*canvas.width/4,canvas.height);c.moveTo(0,i*canvas.height/4);c.lineTo(canvas.width,i*canvas.height/4);}c.stroke();}50 if(!showLines||!analysisSize)return;51 const transform=(x,y)=>{52 if(before||!rendered||!lastRender)return [x/analysisSize.w*canvas.width,y/analysisSize.h*canvas.height];53 const r=lastRender,p=project(r.matrix,x/analysisSize.w*r.sourceWidth,y/analysisSize.h*r.sourceHeight);54 return [(p[0]-r.crop.x)/r.crop.w*canvas.width,(p[1]-r.crop.y)/r.crop.h*canvas.height];55 };56 const rejectedToo=$('show-rejected').checked;57 for(let i=0;i<lines.length;i+=6){58 const type=lines[i+4],selected=type&4,vertical=type&2;59 if(!selected&&!rejectedToo)continue;60 const color=!(type&1)?'#8d979a':vertical?(selected?'#6af074':'#ff706b'):(selected?'#5e9dff':'#ffe15c');61 try{const a=transform(lines[i],lines[i+1]),b=transform(lines[i+2],lines[i+3]);c.beginPath();c.moveTo(...a);c.lineTo(...b);c.strokeStyle='#00000066';c.lineWidth=3*scale;c.stroke();c.strokeStyle=color;c.lineWidth=1.25*scale;c.stroke();}catch{}62 }63}64function scaledCanvas(source,max){const c=document.createElement('canvas');const s=Math.min(1,max/Math.max(source.width,source.height),Math.sqrt(4e6/(source.width*source.height)));c.width=Math.max(2,Math.round(source.width*s));c.height=Math.max(2,Math.round(source.height*s));const x=c.getContext('2d');x.fillStyle='white';x.fillRect(0,0,c.width,c.height);x.drawImage(source,0,0,c.width,c.height);return c;}65const pixels=c=>({w:c.width,h:c.height,pixels:c.getContext('2d').getImageData(0,0,c.width,c.height).data});66function resetValues(){base=[0,0,0,0];activeMode=null;showBefore=false;rendered=null;lastRender=null;$('rotation').value=0;$('strength').value=100;updateLabels();}67async function load(source,name){68 clearTimeout(renderTimer);renderId++;resetValues();hasDetected=false;lines=[];analysisSize=null;69 filename=name.replace(/\.[^.]+$/,'');70 const full=scaledCanvas(source,2560);original=scaledCanvas(full,1200);71 const a=pixels(full),b=pixels(original);72 await rpc('load',{photo:a,preview:b},[a.pixels.buffer,b.pixels.buffer]);73 $('line-count').textContent='';74 draw();status('');75}76async function detect(){77 setBusy(true);78 try{79 const a=pixels(scaledCanvas(original,960)),start=performance.now();80 const result=await rpc('detect',{...a},[a.pixels.buffer]);81 lines=result.lines;analysisSize={w:result.w,h:result.h};hasDetected=true;showLines=true;$('show-lines').checked=true;82 let v=0,h=0,rejected=0,other=0;83 for(let i=4;i<lines.length;i+=6){const t=lines[i];if(!(t&1))other++;else if(!(t&4))rejected++;else if(t&2)v++;else h++;}84 $('line-count').textContent=`${v+h} lines`;85 $('debug-counts').textContent=`${v} vertical and ${h} horizontal lines used. ${rejected} rejected, ${other} other edges, ${lines.length/6} found in ${Math.round(performance.now()-start)} ms.`;86 status(v+h>=2?'':'Few usable lines. Try clearer straight edges.',v+h<2);drawOverlay();87 }catch(e){hasDetected=false;status(e.message,true);}finally{setBusy(false);}88}89async function render(exporting=false){90 const id=++renderId;91 const result=await rpc('render',{params:currentParams(),crop:cropMode,export:exporting});92 const c=document.createElement('canvas');c.width=result.w;c.height=result.h;c.getContext('2d').putImageData(new ImageData(new Uint8ClampedArray(result.pixels),result.w,result.h),0,0);93 if(exporting)return c;94 if(id!==renderId)return;95 rendered=c;lastRender=result;showBefore=false;draw();syncControls();96}97async function fit(mode){98 clearTimeout(renderTimer);setBusy(true);99 const old={base:base.slice(),mode:activeMode,rotation:$('rotation').value};100 try{const r=await rpc('fit',{mode});base=r.params;activeMode=mode;$('rotation').value=base[0];$('strength').value=100;updateLabels();await render();}101 catch(e){base=old.base;activeMode=old.mode;$('rotation').value=old.rotation;updateLabels();status(e.message,true);}finally{setBusy(false);}102}103function scheduleRender(){updateLabels();clearTimeout(renderTimer);renderTimer=setTimeout(()=>{render().catch(e=>status(e.message,true));},100);}104105$('detect').onclick=detect;fitButtons.forEach(b=>b.onclick=()=>fit(Number(b.dataset.mode)));106$('rotation').oninput=scheduleRender;$('strength').oninput=scheduleRender;107function setLines(v){showLines=v;$('show-lines').checked=v;syncControls();drawOverlay();}108$('lines').onclick=()=>setLines(!showLines);$('show-lines').onchange=()=>setLines($('show-lines').checked);109$('guides').onclick=()=>{showGuides=!showGuides;syncControls();drawOverlay();};110$('show-rejected').onchange=drawOverlay;111$('compare').onclick=()=>{showBefore=!showBefore;draw();};112$('reset').onclick=()=>{clearTimeout(renderTimer);renderId++;resetValues();cropMode='largest';syncCrop();draw();syncControls();status('');};113114// Press and hold the photo to peek at the original.115const stage=$('stage');116new ResizeObserver(()=>{layout();drawOverlay();}).observe(stage);117stage.addEventListener('pointerdown',e=>{if(!rendered||busy||e.target.closest('button'))return;holdBefore=true;draw();});118for(const type of ['pointerup','pointercancel','pointerleave'])stage.addEventListener(type,()=>{if(!holdBefore)return;holdBefore=false;draw();});119stage.addEventListener('contextmenu',e=>e.preventDefault());120// Drag and drop onto the stage.121const zone=$('open-empty');122stage.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='copy';zone.classList.add('over');});123stage.addEventListener('dragleave',()=>zone.classList.remove('over'));124stage.addEventListener('drop',e=>{e.preventDefault();zone.classList.remove('over');const file=e.dataTransfer.files[0];if(file)openFile(file);});125126// Sheets127function sheet(id){const d=$(id);for(const b of d.querySelectorAll('[data-close]'))b.onclick=()=>d.close();d.addEventListener('click',e=>{if(e.target===d)d.close();});return d;}128const cropSheet=sheet('crop-sheet'),saveSheet=sheet('save-sheet'),linesSheet=sheet('lines-sheet');129function syncCrop(){for(const b of cropSheet.querySelectorAll('[data-crop]'))b.setAttribute('aria-checked',String(b.dataset.crop===cropMode));}130$('crop').onclick=()=>cropSheet.showModal();131for(const b of cropSheet.querySelectorAll('[data-crop]'))b.onclick=()=>{cropMode=b.dataset.crop;syncCrop();cropSheet.close();if(rendered)scheduleRender();};132$('line-chip').onclick=()=>linesSheet.showModal();133$('save').onclick=()=>saveSheet.showModal();134for(const b of saveSheet.querySelectorAll('[data-format]'))b.onclick=()=>{saveSheet.close();save(b.dataset.format);};135136$('open').onclick=()=>$('file').click();$('open-empty').onclick=()=>$('file').click();137$('file').onchange=()=>{const file=$('file').files[0];if(file)openFile(file);};138async function openFile(file){139 if(busy)return;140 setBusy(true);141 let bitmap;142 try{143 if(file.size>60*1024*1024)throw Error('Choose a JPEG or PNG smaller than 60 MB.');144 const head=new Uint8Array(await file.slice(0,8).arrayBuffer());145 if(!((head[0]===255&&head[1]===216)||(head[0]===137&&head[1]===80&&head[2]===78&&head[3]===71)))throw Error('Only JPEG and PNG photos are supported.');146 bitmap=await createImageBitmap(file,{imageOrientation:'from-image'});147 if(bitmap.width<8||bitmap.height<8)throw Error('Choose an image at least 8 by 8 pixels.');148 const originalWidth=bitmap.width,originalHeight=bitmap.height;149 await load(bitmap,file.name);150 if(originalWidth>2560||originalHeight>2560||originalWidth*originalHeight>4e6)status('Reduced to 4 MP');151 }catch(e){status(e.message||'This image could not be opened.',true);}finally{bitmap?.close();$('file').value='';setBusy(false);}152};153async function save(format){154 clearTimeout(renderTimer);setBusy(true);155 try{156 const c=await render(true);157 const blob=await new Promise(resolve=>c.toBlob(resolve,`image/${format}`,0.94));158 if(!blob)throw Error('Save failed. Try a smaller photo.');159 const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=`${filename}-perspective.${format==='jpeg'?'jpg':'png'}`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);160 status(`Saved ${c.width} x ${c.height}`);161 }catch(e){status(e.message,true);}finally{setBusy(false);}162}163if('serviceWorker'in navigator){navigator.serviceWorker.register('./sw.js').catch(()=>{});let hadController=Boolean(navigator.serviceWorker.controller);navigator.serviceWorker.addEventListener('controllerchange',()=>{if(hadController&&!original)location.reload();hadController=true;});}164syncCrop();165try{await rpc('init');}catch(e){status(e.message,true);}finally{setBusy(false);}