clone

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

main
gudpics / social.mjs
social.mjs 10.0K
1// SPDX-License-Identifier: AGPL-3.0-or-later2const $=id=>document.getElementById(id);3// Ratios as [long, short], grouped and sorted. Social ratios export with a 1080 short side; photo ratios keep resolution.4const GROUPS=[5 ['',[['original','Original',null],['1:1','1:1',[1,1]]]],6 ['Photo',[['5:4','5:4',[5,4]],['4:3','4:3',[4,3]],['3:2','3:2',[3,2]]]],7 ['Social',[['5:4s','5:4',[5,4]],['16:9','16:9',[16,9]],['1.91:1','1.91:1',[1.91,1]]]]];8const FORMATS=GROUPS.flatMap(([group,items])=>items.map(([id,label,ratio])=>[id,label,ratio,group]));9const MAX_EDGE=2160,PREVIEW_EDGE=1400;10const tool=$('tool'),canvas=$('canvas'),ctx=canvas.getContext('2d'),stage=$('stage');11let source=null,name='photo',busy=false,toastTimer,format='original',fit='crop',dark=false,pan={x:0,y:0},dragging=null,renderTimer,portrait=false;12function ratioOf(f){if(!f[2])return null;const [a,b]=f[2];return portrait?b/a:a/b;}13function labelOf(f){if(!f[2])return f[1];const [a,b]=f[2];return a===b?f[1]:(portrait?`${b}:${a}`:`${a}:${b}`);}14// One large noise tile drawn at 1:1 so grain is pixel sized and never resampled.15const NOISE=1024;const noise=(()=>{const c=document.createElement('canvas');c.width=c.height=NOISE;const x=c.getContext('2d'),d=x.createImageData(NOISE,NOISE);let s=1234567;for(let i=0;i<d.data.length;i+=4){s=(s*1664525+1013904223)>>>0;const v=(s>>>8)&255;d.data[i]=d.data[i+1]=d.data[i+2]=v;d.data[i+3]=255;}x.putImageData(d,0,0);return c;})();1617function 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);}18function sync(){19 tool.dataset.state=source?'detected':'empty';20 const ready=Boolean(source)&&!busy;21 for(const id of ['save','share','color','reset','frame','vignette','grain'])$(id).disabled=!ready;22 $('open').disabled=busy;$('open-empty').disabled=busy;$('busy').hidden=!busy;23 for(const b of $('formats').querySelectorAll('.chip-list .chip-btn')){const f=FORMATS.find(f=>f[0]===b.dataset.format);b.textContent=labelOf(f);b.setAttribute('aria-checked',String(b.dataset.format===format));b.disabled=!ready;}24 const cur=FORMATS.find(f=>f[0]===format);$('rotate').disabled=!ready||!cur[2]||cur[2][0]===cur[2][1];25 for(const b of $('fit').children){b.setAttribute('aria-pressed',String(b.dataset.fit===fit));b.disabled=!ready||format==='original';}26 $('color').setAttribute('aria-pressed',String(dark));27 $('frame-value').value=$('frame').value+'%';$('vignette-value').value=$('vignette').value;$('grain-value').value=$('grain').value;28}29function layout(){30 if(!canvas.width)return;31 const cs=getComputedStyle(stage),w=stage.clientWidth-parseFloat(cs.paddingLeft)-parseFloat(cs.paddingRight),h=stage.clientHeight-parseFloat(cs.paddingTop)-parseFloat(cs.paddingBottom);32 const s=Math.min(w/canvas.width,h/canvas.height);canvas.style.width=Math.max(1,Math.floor(canvas.width*s))+'px';canvas.style.height=Math.max(1,Math.floor(canvas.height*s))+'px';33}34new ResizeObserver(layout).observe(stage);35// Output geometry: returns {w,h, sx,sy,sw,sh (source crop), dx,dy,dw,dh (placement)} for a given long edge budget.36function geometry(maxEdge,fixed){37 const f=FORMATS.find(f=>f[0]===format),ratio=ratioOf(f),sw0=source.width,sh0=source.height;38 let w,h;39 if(fixed&&f[3]==='Social'){const short=1080;[w,h]=ratio>=1?[Math.round(short*ratio),short]:[short,Math.round(short/ratio)];}40 else{41 const r=ratio||sw0/sh0;42 if(fit==='pad'&&ratio){const contained=sw0/sh0>r?[sw0,Math.round(sw0/r)]:[Math.round(sh0*r),sh0];[w,h]=contained;}43 else{[w,h]=sw0/sh0>r?[Math.round(sh0*r),sh0]:[sw0,Math.round(sw0/r)];}44 const s=Math.min(1,maxEdge/Math.max(w,h));w=Math.round(w*s);h=Math.round(h*s);45 }46 const r=w/h;47 if(fit==='pad'&&ratio){48 const s=Math.min(w/sw0,h/sh0),dw=sw0*s,dh=sh0*s;49 return {w,h,sx:0,sy:0,sw:sw0,sh:sh0,dx:(w-dw)/2,dy:(h-dh)/2,dw,dh};50 }51 let sw=sw0,sh=sh0;if(sw0/sh0>r)sw=sh0*r;else sh=sw0/r;52 const sx=(sw0-sw)/2*(1+pan.x),sy=(sh0-sh)/2*(1+pan.y);53 return {w,h,sx,sy,sw,sh,dx:0,dy:0,dw:w,dh:h};54}55function paint(target,g){56 const x=target.getContext('2d'),bg=dark?'#000':'#fff';57 target.width=g.w;target.height=g.h;58 x.fillStyle=bg;x.fillRect(0,0,g.w,g.h);59 const framePct=Number($('frame').value)/100,border=Math.round(Math.min(g.w,g.h)*framePct);60 // Picture area inside the frame61 const aw=g.w-2*border,ah=g.h-2*border;62 x.save();x.beginPath();x.rect(border,border,aw,ah);x.clip();63 const scale=Math.min(aw/g.w,ah/g.h);64 x.translate(border,border);x.scale(scale,scale);65 x.imageSmoothingQuality='high';66 x.drawImage(source,g.sx,g.sy,g.sw,g.sh,g.dx,g.dy,g.dw,g.dh);67 const vig=Number($('vignette').value)/100;68 if(vig>0){const cx=g.dx+g.dw/2,cy=g.dy+g.dh/2,rad=Math.hypot(g.dw,g.dh)/2;const grad=x.createRadialGradient(cx,cy,rad*0.35,cx,cy,rad);grad.addColorStop(0,'rgba(0,0,0,0)');grad.addColorStop(1,`rgba(0,0,0,${(0.85*vig).toFixed(3)})`);x.fillStyle=grad;x.fillRect(g.dx,g.dy,g.dw,g.dh);}69 const grain=Number($('grain').value)/100;70 if(grain>0){x.save();x.beginPath();x.rect(g.dx,g.dy,g.dw,g.dh);x.clip();x.setTransform(1,0,0,1,0,0);x.globalCompositeOperation='overlay';x.globalAlpha=Math.min(1,grain*0.55);71 for(let ty=0;ty<g.h;ty+=NOISE)for(let tx=0;tx<g.w;tx+=NOISE)x.drawImage(noise,tx,ty);x.restore();}72 x.restore();73}74function render(){75 if(!source)return;76 const g=geometry(PREVIEW_EDGE,false);paint(canvas,g);layout();77 const full=geometry(MAX_EDGE,true);$('dims').textContent=`${full.w} x ${full.h}`;$('dims').hidden=false;78}79function scheduleRender(){sync();clearTimeout(renderTimer);renderTimer=setTimeout(render,16);}80async function exportBlob(){const c=document.createElement('canvas');paint(c,geometry(MAX_EDGE,true));const blob=await new Promise(res=>c.toBlob(res,'image/jpeg',0.92));if(!blob)throw Error('Could not encode the JPEG.');return {blob,w:c.width,h:c.height};}81function download(blob,fname){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=fname;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);}82async function openFile(file){83 if(busy)return;busy=true;sync();84 try{85 if(!/^image\/(jpeg|png|webp)$/.test(file.type))throw Error('Choose a JPEG, PNG or WebP.');86 const bitmap=await createImageBitmap(file,{imageOrientation:'from-image'});87 source?.close?.();source=bitmap;name=file.name.replace(/\.[^.]+$/,'');pan={x:0,y:0};portrait=bitmap.height>bitmap.width;88 status('');render();89 }catch(e){status(e.message||'Could not open this image.',true);}90 finally{busy=false;sync();}91}92// Controls93const list=$('formats').querySelector('.chip-list');94for(const [group,items] of GROUPS){95 if(group){const l=document.createElement('span');l.className='chip-group';l.textContent=group;list.append(l);}96 for(const [id,label] of items){const b=document.createElement('button');b.className='chip-btn';b.role='radio';b.dataset.format=id;b.textContent=label;b.disabled=true;b.onclick=()=>{format=id;pan={x:0,y:0};scheduleRender();b.scrollIntoView({inline:'nearest',block:'nearest',behavior:'smooth'});};list.append(b);}97}98$('rotate').onclick=()=>{portrait=!portrait;pan={x:0,y:0};scheduleRender();};99for(const b of $('fit').children)b.onclick=()=>{fit=b.dataset.fit;scheduleRender();};100for(const id of ['frame','vignette','grain'])$(id).oninput=scheduleRender;101$('color').onclick=()=>{dark=!dark;scheduleRender();};102$('reset').onclick=()=>{format='original';fit='crop';dark=false;pan={x:0,y:0};portrait=source?source.height>source.width:false;$('frame').value=0;$('vignette').value=0;$('grain').value=0;scheduleRender();};103$('file').onchange=()=>{const f=$('file').files[0];if(f)openFile(f);$('file').value='';};104$('open').onclick=()=>$('file').click();$('open-empty').onclick=()=>$('file').click();105// Drag to position the crop.106stage.addEventListener('pointerdown',e=>{if(!source||fit==='pad'||format==='original'||e.target.closest('button'))return;dragging={x:e.clientX,y:e.clientY,pan:{...pan}};try{stage.setPointerCapture(e.pointerId);}catch{}});107stage.addEventListener('pointermove',e=>{if(!dragging)return;const g=geometry(PREVIEW_EDGE,false),rect=canvas.getBoundingClientRect(),k=g.w/rect.width;108 const rangeX=(source.width-g.sw)/2*(g.w/g.sw),rangeY=(source.height-g.sh)/2*(g.h/g.sh);109 pan.x=rangeX>0?Math.max(-1,Math.min(1,dragging.pan.x-(e.clientX-dragging.x)*k/rangeX)):0;pan.y=rangeY>0?Math.max(-1,Math.min(1,dragging.pan.y-(e.clientY-dragging.y)*k/rangeY)):0;scheduleRender();});110for(const t of ['pointerup','pointercancel'])stage.addEventListener(t,()=>{dragging=null;});111stage.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='copy';$('open-empty').classList.add('over');});112stage.addEventListener('dragleave',()=>$('open-empty').classList.remove('over'));113stage.addEventListener('drop',e=>{e.preventDefault();$('open-empty').classList.remove('over');const f=e.dataTransfer.files[0];if(f)openFile(f);});114stage.addEventListener('contextmenu',e=>e.preventDefault());115$('save').onclick=async()=>{if(!source)return;busy=true;sync();try{const {blob,w,h}=await exportBlob();download(blob,`${name}-${format}.jpg`);status(`Saved ${w} x ${h}`);}catch(e){status(e.message,true);}finally{busy=false;sync();}};116if(navigator.canShare&&navigator.share)$('share').hidden=false;117$('share').onclick=async()=>{118 if(!source)return;119 try{120 const {blob}=await exportBlob();const file=new File([blob],`${name}-${format}.jpg`,{type:'image/jpeg'});121 if(!navigator.canShare({files:[file]}))throw Error('Sharing files is not supported here.');122 await navigator.share({files:[file]});123 }catch(e){if(e.name==='AbortError')return;if(e.name==='NotAllowedError'){const {blob}=await exportBlob();download(blob,`${name}-${format}.jpg`);status('Saved. Share it from your photos.');}else status(e.message,true);}124};125if('serviceWorker'in navigator){navigator.serviceWorker.register('./sw.js').catch(()=>{});let hadController=Boolean(navigator.serviceWorker.controller);navigator.serviceWorker.addEventListener('controllerchange',()=>{if(hadController&&!source)location.reload();hadController=true;});}126sync();