- commit
- 970c76f7112597ab3673e375819565c02b03117e
- parent
- 5bfdae4ed6ad18784ea7c78e5435ecceb11441db
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 2 | +- |
| M | social.html | 5 | ++++- |
| M | social.mjs | 32 | ++++++++++++++++++++---------- |
| M | style.css | 4 | ++++ |
@@ -5,7 +5,7 @@ A mobile-first, installable photo toolkit. Source: https://github.com/FelineStat
55 Tools:66 77 - `/fuji`: develops a Fujifilm RAF in the browser with LibRaw (WebAssembly) and applies any of 16 film simulation looks via 3D LUTs, then saves a JPEG. The in-camera JPEG can be viewed and saved too. See NOTICE.md for where the LUTs come from and how close they are.8-- `/social`: crop to post formats (square, 4:5, 9:16, 16:9, 1.91:1, 3:2) or pad out, drag to position, frame in white or black, vignette and grain, then save or share a JPEG at the platform size.8+- `/social`: crop to an aspect ratio (1:1, photo 5:4 4:3 3:2, social 4:5 9:16 1.91:1, each rotatable) or pad out, drag to position, frame in white or black, vignette and grain, then save or share a JPEG at the platform size.99 - `/perspective`: straightens converging verticals and horizontals using darktable's C algorithms (LSD line detection, RANSAC, perspective fit, homography) compiled to WebAssembly.1010 1111 ## Run@@ -36,7 +36,10 @@
3636 </section>3737 3838 <section class="panel" aria-label="Adjustments">39- <div class="chips" id="formats" role="radiogroup" aria-label="Format"></div>39+ <div class="chips chips-sticky" id="formats" role="radiogroup" aria-label="Aspect ratio">40+ <div class="chip-list"></div>41+ <button class="chip-btn chip-pin" id="rotate" aria-label="Rotate ratio" disabled><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg></button>42+ </div>4043 <div class="segment" id="fit" role="group" aria-label="Fit">4144 <button data-fit="crop" aria-pressed="true" aria-label="Crop"><svg viewBox="0 0 28 28" aria-hidden="true"><path d="M8 3v17h17M3 8h17v17"/></svg></button>4245 <button data-fit="pad" aria-pressed="false" aria-label="Pad"><svg viewBox="0 0 28 28" aria-hidden="true"><rect x="4" y="4" width="20" height="20" rx="2"/><rect x="9" y="8" width="10" height="12" rx="1"/></svg></button>@@ -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.@@ -242,6 +242,10 @@ input[type=range]::-moz-range-thumb { width: 26px; height: 26px; border: 0; bord
242242 .chip-btn:disabled { color: var(--label-3); }243243 .chip-btn:active:not(:disabled) { opacity: .7; }244244 .tool[data-state="empty"] .chips { opacity: .4; }245+.chips-sticky { position: relative; padding-right: 0; }246+.chip-list { display: flex; gap: 8px; align-items: center; padding-right: 8px; }247+.chip-group { flex: none; font-size: 12px; color: var(--label-2); padding: 0 2px 0 8px; }248+.chip-pin { position: sticky; right: 0; margin-left: auto; width: 44px; padding: 0; display: grid; place-items: center; box-shadow: -14px 0 12px -6px var(--surface); }245249 246250 /* ---------- Sheets ---------- */247251