clone

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

Social: grouped aspect ratios with a pinned rotate control

Dami committed 970c76f
commit
970c76f7112597ab3673e375819565c02b03117e
parent
5bfdae4ed6ad18784ea7c78e5435ecceb11441db
author
Dami <dami.etoile@gmail.com>
4 files changed, +30 -13
MREADME.md2+-
Msocial.html5++++-
Msocial.mjs32++++++++++++++++++++----------
Mstyle.css4++++
M README.md +1 -1
M social.html +4 -1
M social.mjs +21 -11
@@ -1,12 +1,16 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later22 const $=id=>document.getElementById(id);3-// Formats: ratio null keeps the original; size is the export size for platform presets.4-const FORMATS=[5- ['original','Original',null,null],['square','Square',1,[1080,1080]],['post','Post 4:5',4/5,[1080,1350]],6- ['story','Story 9:16',9/16,[1080,1920]],['wide','Wide 16:9',16/9,[1920,1080]],['landscape','Post 1.91:1',1.91,[1080,566]],['classic','Classic 3:2',3/2,null]];3+// Ratios as [long, short], grouped and sorted. Social ratios export with a 1080 short side; photo ratios keep resolution.4+const 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]]]]];8+const FORMATS=GROUPS.flatMap(([group,items])=>items.map(([id,label,ratio])=>[id,label,ratio,group]));79 const MAX_EDGE=2160,PREVIEW_EDGE=1400;810 const tool=$('tool'),canvas=$('canvas'),ctx=canvas.getContext('2d'),stage=$('stage');9-let source=null,name='photo',busy=false,toastTimer,format='original',fit='crop',dark=false,pan={x:0,y:0},dragging=null,renderTimer;11+let source=null,name='photo',busy=false,toastTimer,format='original',fit='crop',dark=false,pan={x:0,y:0},dragging=null,renderTimer,portrait=false;12+function ratioOf(f){if(!f[2])return null;const [a,b]=f[2];return portrait?b/a:a/b;}13+function labelOf(f){if(!f[2])return f[1];const [a,b]=f[2];return a===b?f[1]:(portrait?`${b}:${a}`:`${a}:${b}`);}1014 // One large noise tile drawn at 1:1 so grain is pixel sized and never resampled.1115 const 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;})();1216
@@ -16,7 +20,8 @@ function sync(){
1620 const ready=Boolean(source)&&!busy;1721 for(const id of ['save','share','color','reset','frame','vignette','grain'])$(id).disabled=!ready;1822 $('open').disabled=busy;$('open-empty').disabled=busy;$('busy').hidden=!busy;19- for(const b of $('formats').children){b.setAttribute('aria-checked',String(b.dataset.format===format));b.disabled=!ready;}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];2025 for(const b of $('fit').children){b.setAttribute('aria-pressed',String(b.dataset.fit===fit));b.disabled=!ready||format==='original';}2126 $('color').setAttribute('aria-pressed',String(dark));2227 $('frame-value').value=$('frame').value+'%';$('vignette-value').value=$('vignette').value;$('grain-value').value=$('grain').value;
@@ -29,9 +34,9 @@ function layout(){
2934 new ResizeObserver(layout).observe(stage);3035 // Output geometry: returns {w,h, sx,sy,sw,sh (source crop), dx,dy,dw,dh (placement)} for a given long edge budget.3136 function geometry(maxEdge,fixed){32- const f=FORMATS.find(f=>f[0]===format),ratio=f[2],size=f[3],sw0=source.width,sh0=source.height;37+ const f=FORMATS.find(f=>f[0]===format),ratio=ratioOf(f),sw0=source.width,sh0=source.height;3338 let w,h;34- if(fixed&&size){[w,h]=size;}39+ if(fixed&&f[3]==='Social'){const short=1080;[w,h]=ratio>=1?[Math.round(short*ratio),short]:[short,Math.round(short/ratio)];}3540 else{3641 const r=ratio||sw0/sh0;3742 if(fit==='pad'&&ratio){const contained=sw0/sh0>r?[sw0,Math.round(sw0/r)]:[Math.round(sh0*r),sh0];[w,h]=contained;}
@@ -79,17 +84,22 @@ async function openFile(file){
7984 try{8085 if(!/^image\/(jpeg|png|webp)$/.test(file.type))throw Error('Choose a JPEG, PNG or WebP.');8186 const bitmap=await createImageBitmap(file,{imageOrientation:'from-image'});82- source?.close?.();source=bitmap;name=file.name.replace(/\.[^.]+$/,'');pan={x:0,y:0};87+ source?.close?.();source=bitmap;name=file.name.replace(/\.[^.]+$/,'');pan={x:0,y:0};portrait=bitmap.height>bitmap.width;8388 status('');render();8489 }catch(e){status(e.message||'Could not open this image.',true);}8590 finally{busy=false;sync();}8691 }8792 // Controls88-for(const [id,label] of FORMATS){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:'center',block:'nearest',behavior:'smooth'});};$('formats').append(b);}93+const list=$('formats').querySelector('.chip-list');94+for(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();};8999 for(const b of $('fit').children)b.onclick=()=>{fit=b.dataset.fit;scheduleRender();};90100 for(const id of ['frame','vignette','grain'])$(id).oninput=scheduleRender;91101 $('color').onclick=()=>{dark=!dark;scheduleRender();};92-$('reset').onclick=()=>{format='original';fit='crop';dark=false;pan={x:0,y:0};$('frame').value=0;$('vignette').value=0;$('grain').value=0;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();};93103 $('file').onchange=()=>{const f=$('file').files[0];if(f)openFile(f);$('file').value='';};94104 $('open').onclick=()=>$('file').click();$('open-empty').onclick=()=>$('file').click();95105 // Drag to position the crop.
M style.css +4 -0